diff options
Diffstat (limited to 'src')
44 files changed, 13074 insertions, 11073 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 4afb0368..fe1817f6 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -1,3 +1,7 @@ +/** + * @format + */ + // Generally extendable class for caching properties and handling dependencies, // with a few key properties: // @@ -74,21 +78,21 @@ // function, which provides a mapping of exposed property names to whether // or not their dependencies are yet met. -import { color, ENABLE_COLOR } from '../util/cli.js'; +import {color, ENABLE_COLOR} from '../util/cli.js'; -import { inspect as nodeInspect } from 'util'; +import {inspect as nodeInspect} from 'util'; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); + static instance = Symbol('CacheableObject `this` instance'); - #propertyUpdateValues = Object.create(null); - #propertyUpdateCacheInvalidators = Object.create(null); + #propertyUpdateValues = Object.create(null); + #propertyUpdateCacheInvalidators = Object.create(null); - /* + /* // Note the constructor doesn't take an initial data source. Due to a quirk // of JavaScript, private members can't be accessed before the superclass's // constructor is finished processing - so if we call the overridden @@ -99,211 +103,233 @@ export default class CacheableObject { // after constructing the new instance of the Thing (sub)class. */ - constructor() { - this.#defineProperties(); - this.#initializeUpdatingPropertyValues(); - - if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return new Proxy(this, { - get: (obj, key) => { - if (!Object.hasOwn(obj, key)) { - if (key !== 'constructor') { - CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); - } - } - return obj[key]; - } - }); - } - } - - #initializeUpdatingPropertyValues() { - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags, update } = descriptor; - - if (!flags.update) { - continue; - } - - if (update?.default) { - this[property] = update?.default; - } else { - this[property] = null; + constructor() { + this.#defineProperties(); + this.#initializeUpdatingPropertyValues(); + + if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return new Proxy(this, { + get: (obj, key) => { + if (!Object.hasOwn(obj, key)) { + if (key !== 'constructor') { + CacheableObject._invalidAccesses.add( + `(${obj.constructor.name}).${key}` + ); } - } + } + return obj[key]; + }, + }); } - - #defineProperties() { - if (!this.constructor.propertyDescriptors) { - throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`); - } - - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags } = descriptor; - - const definition = { - configurable: false, - enumerable: true - }; - - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } - - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } - - Object.defineProperty(this, property, definition); - } - - Object.seal(this); + } + + #initializeUpdatingPropertyValues() { + for (const [property, descriptor] of Object.entries( + this.constructor.propertyDescriptors + )) { + const {flags, update} = descriptor; + + if (!flags.update) { + continue; + } + + if (update?.default) { + this[property] = update?.default; + } else { + this[property] = null; + } } + } - #getUpdateObjectDefinitionSetterFunction(property) { - const { update } = this.#getPropertyDescriptor(property); - const validate = update?.validate; - const allowNull = update?.allowNull; - - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; + #defineProperties() { + if (!this.constructor.propertyDescriptors) { + throw new Error( + `Expected constructor ${this.constructor.name} to define propertyDescriptors` + ); + } - if (newValue === undefined) { - throw new TypeError(`Properties cannot be set to undefined`); - } + for (const [property, descriptor] of Object.entries( + this.constructor.propertyDescriptors + )) { + const {flags} = descriptor; - if (newValue === oldValue) { - return; - } + const definition = { + configurable: false, + enumerable: true, + }; - if (newValue !== null && validate) { - try { - const result = validate(newValue); - if (result === undefined) { - throw new TypeError(`Validate function returned undefined`); - } else if (result !== true) { - throw new TypeError(`Validation failed for value ${newValue}`); - } - } catch (error) { - error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`; - throw error; - } - } + if (flags.update) { + definition.set = + this.#getUpdateObjectDefinitionSetterFunction(property); + } - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } + if (flags.expose) { + definition.get = + this.#getExposeObjectDefinitionGetterFunction(property); + } - #getUpdatePropertyValidateFunction(property) { - const descriptor = this.#getPropertyDescriptor(property); + Object.defineProperty(this, property, definition); } - #getPropertyDescriptor(property) { - return this.constructor.propertyDescriptors[property]; - } - - #invalidateCachesDependentUpon(property) { - for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) { - invalidate(); - } - } - - #getExposeObjectDefinitionGetterFunction(property) { - const { flags } = this.#getPropertyDescriptor(property); - const compute = this.#getExposeComputeFunction(property); - - if (compute) { - let cachedValue; - const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); - return () => { - if (checkCacheValid()) { - return cachedValue; - } else { - return (cachedValue = compute()); - } - }; - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } else { - return () => this.#propertyUpdateValues[property]; + Object.seal(this); + } + + #getUpdateObjectDefinitionSetterFunction(property) { + const {update} = this.#getPropertyDescriptor(property); + const validate = update?.validate; + + return (newValue) => { + const oldValue = this.#propertyUpdateValues[property]; + + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } + + if (newValue === oldValue) { + return; + } + + if (newValue !== null && validate) { + try { + const result = validate(newValue); + if (result === undefined) { + throw new TypeError(`Validate function returned undefined`); + } else if (result !== true) { + throw new TypeError(`Validation failed for value ${newValue}`); + } + } catch (error) { + error.message = `Property ${color.green(property)} (${inspect( + this[property] + )} -> ${inspect(newValue)}): ${error.message}`; + throw error; } - } - - #getExposeComputeFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); - - const compute = expose?.compute; - const transform = expose?.transform; + } - if (flags.update && !transform) { - return null; - } else if (flags.update && compute) { - throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } - const dependencyKeys = expose.dependencies || []; - const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]); - const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()) - .concat([[this.constructor.instance, this]])); + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); + #invalidateCachesDependentUpon(property) { + for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || + []) { + invalidate(); + } + } + + #getExposeObjectDefinitionGetterFunction(property) { + const {flags} = this.#getPropertyDescriptor(property); + const compute = this.#getExposeComputeFunction(property); + + if (compute) { + let cachedValue; + const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); + return () => { + if (checkCacheValid()) { + return cachedValue; } else { - return () => compute(getAllDependencies()); + return (cachedValue = compute()); } + }; + } else if (!flags.update && !compute) { + throw new Error( + `Exposed property ${property} does not update and is missing compute function` + ); + } else { + return () => this.#propertyUpdateValues[property]; + } + } + + #getExposeComputeFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); + + const compute = expose?.compute; + const transform = expose?.transform; + + if (flags.update && !transform) { + return null; + } else if (flags.update && compute) { + throw new Error( + `Updating property ${property} has compute function, should be formatted as transform` + ); + } else if (!flags.update && !compute) { + throw new Error( + `Exposed property ${property} does not update and is missing compute function` + ); } - #getExposeCheckCacheValidFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); - - let valid = false; + const dependencyKeys = expose.dependencies || []; + const dependencyGetters = dependencyKeys.map((key) => () => [ + key, + this.#propertyUpdateValues[key], + ]); + const getAllDependencies = () => + Object.fromEntries( + dependencyGetters + .map((f) => f()) + .concat([[this.constructor.instance, this]]) + ); + + if (flags.update) { + return () => + transform(this.#propertyUpdateValues[property], getAllDependencies()); + } else { + return () => compute(getAllDependencies()); + } + } - const invalidate = () => { - valid = false; - }; + #getExposeCheckCacheValidFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); - const dependencyKeys = new Set(expose?.dependencies); + let valid = false; - if (flags.update) { - dependencyKeys.add(property); - } + const invalidate = () => { + valid = false; + }; - for (const key of dependencyKeys) { - if (this.#propertyUpdateCacheInvalidators[key]) { - this.#propertyUpdateCacheInvalidators[key].push(invalidate); - } else { - this.#propertyUpdateCacheInvalidators[key] = [invalidate]; - } - } + const dependencyKeys = new Set(expose?.dependencies); - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; + if (flags.update) { + dependencyKeys.add(property); } - static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; - static _invalidAccesses = new Set(); + for (const key of dependencyKeys) { + if (this.#propertyUpdateCacheInvalidators[key]) { + this.#propertyUpdateCacheInvalidators[key].push(invalidate); + } else { + this.#propertyUpdateCacheInvalidators[key] = [invalidate]; + } + } - static showInvalidAccesses() { - if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return; - } + return () => { + if (!valid) { + valid = true; + return false; + } else { + return true; + } + }; + } + + static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; + static _invalidAccesses = new Set(); + + static showInvalidAccesses() { + if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return; + } - if (!this._invalidAccesses.size) { - return; - } + if (!this._invalidAccesses.size) { + return; + } - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } + console.log(`${this._invalidAccesses.size} unique invalid accesses:`); + for (const line of this._invalidAccesses) { + console.log(` - ${line}`); } + } } diff --git a/src/data/patches.js b/src/data/patches.js index 3ed4fad0..dc757fa9 100644 --- a/src/data/patches.js +++ b/src/data/patches.js @@ -1,370 +1,389 @@ +/** @format */ + // --> Patch export class Patch { - static INPUT_NONE = 0; - static INPUT_CONSTANT = 1; - static INPUT_DIRECT_CONNECTION = 2; - static INPUT_MANAGED_CONNECTION = 3; - - static INPUT_UNAVAILABLE = 0; - static INPUT_AVAILABLE = 1; + static INPUT_NONE = 0; + static INPUT_CONSTANT = 1; + static INPUT_DIRECT_CONNECTION = 2; + static INPUT_MANAGED_CONNECTION = 3; - static OUTPUT_UNAVAILABLE = 0; - static OUTPUT_AVAILABLE = 1; + static INPUT_UNAVAILABLE = 0; + static INPUT_AVAILABLE = 1; - static inputNames = []; inputNames = null; - static outputNames = []; outputNames = null; + static OUTPUT_UNAVAILABLE = 0; + static OUTPUT_AVAILABLE = 1; - manager = null; - inputs = Object.create(null); + static inputNames = []; + inputNames = null; + static outputNames = []; + outputNames = null; - constructor({ - manager, + manager = null; + inputs = Object.create(null); - inputNames, - outputNames, + constructor({ + manager, - inputs, - } = {}) { - this.inputNames = inputNames ?? this.constructor.inputNames; - this.outputNames = outputNames ?? this.constructor.outputNames; + inputNames, + outputNames, - manager?.addManagedPatch(this); + inputs, + } = {}) { + this.inputNames = inputNames ?? this.constructor.inputNames; + this.outputNames = outputNames ?? this.constructor.outputNames; - if (inputs) { - Object.assign(this.inputs, inputs); - } + manager?.addManagedPatch(this); - this.initializeInputs(); + if (inputs) { + Object.assign(this.inputs, inputs); } - initializeInputs() { - for (const inputName of this.inputNames) { - if (!this.inputs[inputName]) { - this.inputs[inputName] = [Patch.INPUT_NONE]; - } - } - } + this.initializeInputs(); + } - computeInputs() { - const inputs = Object.create(null); - - for (const inputName of this.inputNames) { - const input = this.inputs[inputName]; - switch (input[0]) { - case Patch.INPUT_NONE: - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - - case Patch.INPUT_CONSTANT: - inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - - case Patch.INPUT_DIRECT_CONNECTION: { - const patch = input[1]; - const outputName = input[2]; - const output = patch.computeOutputs()[outputName]; - switch (output[0]) { - case Patch.OUTPUT_UNAVAILABLE: - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - case Patch.OUTPUT_AVAILABLE: - inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]]; - break; - } - throw new Error('Unreachable'); - } - - case Patch.INPUT_MANAGED_CONNECTION: { - if (!this.manager) { - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - } - - inputs[inputName] = this.manager.getManagedInput(input[1]); - break; - } - } + initializeInputs() { + for (const inputName of this.inputNames) { + if (!this.inputs[inputName]) { + this.inputs[inputName] = [Patch.INPUT_NONE]; + } + } + } + + computeInputs() { + const inputs = Object.create(null); + + for (const inputName of this.inputNames) { + const input = this.inputs[inputName]; + switch (input[0]) { + case Patch.INPUT_NONE: + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + + case Patch.INPUT_CONSTANT: + inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + + case Patch.INPUT_DIRECT_CONNECTION: { + const patch = input[1]; + const outputName = input[2]; + const output = patch.computeOutputs()[outputName]; + switch (output[0]) { + case Patch.OUTPUT_UNAVAILABLE: + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + case Patch.OUTPUT_AVAILABLE: + inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]]; + break; + } + throw new Error('Unreachable'); } - return inputs; - } + case Patch.INPUT_MANAGED_CONNECTION: { + if (!this.manager) { + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + } - computeOutputs() { - const inputs = this.computeInputs(); - const outputs = Object.create(null); - console.log(`Compute: ${this.constructor.name}`); - this.compute(inputs, outputs); - return outputs; + inputs[inputName] = this.manager.getManagedInput(input[1]); + break; + } + } } - compute(inputs, outputs) { - // No-op. Return all outputs as unavailable. This should be overridden - // in subclasses. + return inputs; + } - for (const outputName of this.constructor.outputNames) { - outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE]; - } - } + computeOutputs() { + const inputs = this.computeInputs(); + const outputs = Object.create(null); + console.log(`Compute: ${this.constructor.name}`); + this.compute(inputs, outputs); + return outputs; + } + + compute(inputs, outputs) { + // No-op. Return all outputs as unavailable. This should be overridden + // in subclasses. - attachToManager(manager) { - manager.addManagedPatch(this); + for (const outputName of this.constructor.outputNames) { + outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE]; } + } - detachFromManager() { - if (this.manager) { - this.manager.removeManagedPatch(this); - } + attachToManager(manager) { + manager.addManagedPatch(this); + } + + detachFromManager() { + if (this.manager) { + this.manager.removeManagedPatch(this); } + } } // --> PatchManager export class PatchManager extends Patch { - managedPatches = []; - managedInputs = {}; - - #externalInputPatch = null; - #externalOutputPatch = null; - - constructor(...args) { - super(...args); - - this.#externalInputPatch = new PatchManagerExternalInputPatch({manager: this}); - this.#externalOutputPatch = new PatchManagerExternalOutputPatch({manager: this}); + managedPatches = []; + managedInputs = {}; + + #externalInputPatch = null; + #externalOutputPatch = null; + + constructor(...args) { + super(...args); + + this.#externalInputPatch = new PatchManagerExternalInputPatch({ + manager: this, + }); + this.#externalOutputPatch = new PatchManagerExternalOutputPatch({ + manager: this, + }); + } + + addManagedPatch(patch) { + if (patch.manager === this) { + return false; } - addManagedPatch(patch) { - if (patch.manager === this) { - return false; - } - - patch.detachFromManager(); - patch.manager = this; + patch.detachFromManager(); + patch.manager = this; - if (patch.manager === this) { - this.managedPatches.push(patch); - return true; - } else { - return false; - } + if (patch.manager === this) { + this.managedPatches.push(patch); + return true; + } else { + return false; } + } - removeManagedPatch(patch) { - if (patch.manager !== this) { - return false; - } - - patch.manager = null; - - if (patch.manager === this) { - return false; - } - - for (const inputNames of patch.inputNames) { - const input = patch.inputs[inputName]; - if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { - this.dropManagedInput(input[1]); - patch.inputs[inputName] = [Patch.INPUT_NONE]; - } - } - - this.managedPatches.splice(this.managedPatches.indexOf(patch), 1); - - return true; + removeManagedPatch(patch) { + if (patch.manager !== this) { + return false; } - addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) { - if (patchWithInput.manager !== this || patchWithOutput.manager !== this) { - throw new Error(`Input and output patches must belong to same manager (this)`); - } - - const input = patchWithInput.inputs[inputName]; - if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { - this.managedInputs[input[1]] = [patchWithOutput, outputName, {}]; - } else { - const key = this.getManagedConnectionIdentifier(); - this.managedInputs[key] = [patchWithOutput, outputName, {}]; - patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key]; - } - - return true; - } + patch.manager = null; - dropManagedInput(identifier) { - return delete this.managedInputs[key]; + if (patch.manager === this) { + return false; } - getManagedInput(identifier) { - const connection = this.managedInputs[identifier]; - const patch = connection[0]; - const outputName = connection[1]; - const memory = connection[2]; - return this.computeManagedInput(patch, outputName, memory); + for (const inputName of patch.inputNames) { + const input = patch.inputs[inputName]; + if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { + this.dropManagedInput(input[1]); + patch.inputs[inputName] = [Patch.INPUT_NONE]; + } } - computeManagedInput(patch, outputName, memory) { - // Override this function in subclasses to alter behavior of the "wire" - // used for connecting patches. - - const output = patch.computeOutputs()[outputName]; - switch (output[0]) { - case Patch.OUTPUT_UNAVAILABLE: - return [Patch.INPUT_UNAVAILABLE]; - case Patch.OUTPUT_AVAILABLE: - return [Patch.INPUT_AVAILABLE, output[1]]; - } - } + this.managedPatches.splice(this.managedPatches.indexOf(patch), 1); - #managedConnectionIdentifier = 0; - getManagedConnectionIdentifier() { - return this.#managedConnectionIdentifier++; - } + return true; + } - addExternalInput(patchWithInput, patchInputName, managerInputName) { - return this.addManagedInput(patchWithInput, patchInputName, this.#externalInputPatch, managerInputName); + addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) { + if (patchWithInput.manager !== this || patchWithOutput.manager !== this) { + throw new Error( + `Input and output patches must belong to same manager (this)` + ); } - setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) { - return this.addManagedInput(this.#externalOutputPatch, managerOutputName, patchWithOutput, patchOutputName); + const input = patchWithInput.inputs[inputName]; + if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { + this.managedInputs[input[1]] = [patchWithOutput, outputName, {}]; + } else { + const key = this.getManagedConnectionIdentifier(); + this.managedInputs[key] = [patchWithOutput, outputName, {}]; + patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key]; } - compute(inputs, outputs) { - Object.assign(outputs, this.#externalOutputPatch.computeOutputs()); + return true; + } + + dropManagedInput(identifier) { + return delete this.managedInputs[identifier]; + } + + getManagedInput(identifier) { + const connection = this.managedInputs[identifier]; + const patch = connection[0]; + const outputName = connection[1]; + const memory = connection[2]; + return this.computeManagedInput(patch, outputName, memory); + } + + computeManagedInput(patch, outputName) { + // Override this function in subclasses to alter behavior of the "wire" + // used for connecting patches. + + const output = patch.computeOutputs()[outputName]; + switch (output[0]) { + case Patch.OUTPUT_UNAVAILABLE: + return [Patch.INPUT_UNAVAILABLE]; + case Patch.OUTPUT_AVAILABLE: + return [Patch.INPUT_AVAILABLE, output[1]]; } + } + + #managedConnectionIdentifier = 0; + getManagedConnectionIdentifier() { + return this.#managedConnectionIdentifier++; + } + + addExternalInput(patchWithInput, patchInputName, managerInputName) { + return this.addManagedInput( + patchWithInput, + patchInputName, + this.#externalInputPatch, + managerInputName + ); + } + + setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) { + return this.addManagedInput( + this.#externalOutputPatch, + managerOutputName, + patchWithOutput, + patchOutputName + ); + } + + compute(inputs, outputs) { + Object.assign(outputs, this.#externalOutputPatch.computeOutputs()); + } } class PatchManagerExternalInputPatch extends Patch { - constructor({manager, ...rest}) { - super({ - manager, - inputNames: manager.inputNames, - outputNames: manager.inputNames, - ...rest - }); - } - - computeInputs() { - return this.manager.computeInputs(); - } - - compute(inputs, outputs) { - for (const name of this.inputNames) { - const input = inputs[name]; - switch (input[0]) { - case Patch.INPUT_UNAVAILABLE: - outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; - break; - case Patch.INPUT_AVAILABLE: - outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - } - } + constructor({manager, ...rest}) { + super({ + manager, + inputNames: manager.inputNames, + outputNames: manager.inputNames, + ...rest, + }); + } + + computeInputs() { + return this.manager.computeInputs(); + } + + compute(inputs, outputs) { + for (const name of this.inputNames) { + const input = inputs[name]; + switch (input[0]) { + case Patch.INPUT_UNAVAILABLE: + outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; + break; + case Patch.INPUT_AVAILABLE: + outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + } } + } } class PatchManagerExternalOutputPatch extends Patch { - constructor({manager, ...rest}) { - super({ - manager, - inputNames: manager.outputNames, - outputNames: manager.outputNames, - ...rest - }); - } - - compute(inputs, outputs) { - for (const name of this.inputNames) { - const input = inputs[name]; - switch (input[0]) { - case Patch.INPUT_UNAVAILABLE: - outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; - break; - case Patch.INPUT_AVAILABLE: - outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - } - } + constructor({manager, ...rest}) { + super({ + manager, + inputNames: manager.outputNames, + outputNames: manager.outputNames, + ...rest, + }); + } + + compute(inputs, outputs) { + for (const name of this.inputNames) { + const input = inputs[name]; + switch (input[0]) { + case Patch.INPUT_UNAVAILABLE: + outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; + break; + case Patch.INPUT_AVAILABLE: + outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + } } + } } // --> demo const caches = Symbol(); const common = Symbol(); -const hsmusic = Symbol(); Patch[caches] = { - WireCachedPatchManager: class extends PatchManager { - // "Wire" caching for PatchManager: Remembers the last outputs to come - // from each patch. As long as the inputs for a patch do not change, its - // cached outputs are reused. - - // TODO: This has a unique cache for each managed input. It should - // re-use a cache for the same patch and output name. How can we ensure - // the cache is dropped when the patch is removed, though? (Spoilers: - // probably just override removeManagedPatch) - computeManagedInput(patch, outputName, memory) { - let cache = true; - - const { previousInputs } = memory; - const { inputs } = patch; - if (memory.previousInputs) { - for (const inputName of patch.inputNames) { - // TODO: This doesn't account for connections whose values - // have changed (analogous to bubbling cache invalidation). - if (inputs[inputName] !== previousInputs[inputName]) { - cache = false; - break; - } - } - } else { - cache = false; - } - - if (cache) { - return memory.previousOutputs[outputName]; - } - - const outputs = patch.computeOutputs(); - memory.previousOutputs = outputs; - memory.previousInputs = {...inputs}; - return outputs[outputName]; + WireCachedPatchManager: class extends PatchManager { + // "Wire" caching for PatchManager: Remembers the last outputs to come + // from each patch. As long as the inputs for a patch do not change, its + // cached outputs are reused. + + // TODO: This has a unique cache for each managed input. It should + // re-use a cache for the same patch and output name. How can we ensure + // the cache is dropped when the patch is removed, though? (Spoilers: + // probably just override removeManagedPatch) + computeManagedInput(patch, outputName, memory) { + let cache = true; + + const {previousInputs} = memory; + const {inputs} = patch; + if (memory.previousInputs) { + for (const inputName of patch.inputNames) { + // TODO: This doesn't account for connections whose values + // have changed (analogous to bubbling cache invalidation). + if (inputs[inputName] !== previousInputs[inputName]) { + cache = false; + break; + } } - }, + } else { + cache = false; + } + + if (cache) { + return memory.previousOutputs[outputName]; + } + + const outputs = patch.computeOutputs(); + memory.previousOutputs = outputs; + memory.previousInputs = {...inputs}; + return outputs[outputName]; + } + }, }; Patch[common] = { - Stringify: class extends Patch { - static inputNames = ['value']; - static outputNames = ['value']; - - compute(inputs, outputs) { - if (inputs.value[0] === Patch.INPUT_AVAILABLE) { - outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()]; - } else { - outputs.value = [Patch.OUTPUT_UNAVAILABLE]; - } - } - }, - - Echo: class extends Patch { - static inputNames = ['value']; - static outputNames = ['value']; - - compute(inputs, outputs) { - if (inputs.value[0] === Patch.INPUT_AVAILABLE) { - outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]]; - } else { - outputs.value = [Patch.OUTPUT_UNAVAILABLE]; - } - } - }, + Stringify: class extends Patch { + static inputNames = ['value']; + static outputNames = ['value']; + + compute(inputs, outputs) { + if (inputs.value[0] === Patch.INPUT_AVAILABLE) { + outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()]; + } else { + outputs.value = [Patch.OUTPUT_UNAVAILABLE]; + } + } + }, + + Echo: class extends Patch { + static inputNames = ['value']; + static outputNames = ['value']; + + compute(inputs, outputs) { + if (inputs.value[0] === Patch.INPUT_AVAILABLE) { + outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]]; + } else { + outputs.value = [Patch.OUTPUT_UNAVAILABLE]; + } + } + }, }; const PM = new Patch[caches].WireCachedPatchManager({ - inputNames: ['externalInput'], - outputNames: ['externalOutput'], + inputNames: ['externalInput'], + outputNames: ['externalOutput'], }); const P1 = new Patch[common].Stringify({manager: PM}); diff --git a/src/data/serialize.js b/src/data/serialize.js index 9d4e8885..a4206fd0 100644 --- a/src/data/serialize.js +++ b/src/data/serialize.js @@ -1,22 +1,24 @@ +/** @format */ + // serialize-util.js: simple interface and utility functions for converting // Things into a directly serializeable format // Utility functions export function id(x) { - return x; + return x; } export function toRef(thing) { - return thing?.constructor.getReference(thing); + return thing?.constructor.getReference(thing); } export function toRefs(things) { - return things?.map(toRef); + return things?.map(toRef); } export function toContribRefs(contribs) { - return contribs?.map(({ who, what }) => ({who: toRef(who), what})); + return contribs?.map(({who, what}) => ({who: toRef(who), what})); } // Interface @@ -24,15 +26,21 @@ export function toContribRefs(contribs) { export const serializeDescriptors = Symbol(); export function serializeThing(thing) { - const descriptors = thing.constructor[serializeDescriptors]; - if (!descriptors) { - throw new Error(`Constructor ${thing.constructor.name} does not provide serialize descriptors`); - } - - return Object.fromEntries(Object.entries(descriptors) - .map(([ property, transform ]) => [property, transform(thing[property])])); + const descriptors = thing.constructor[serializeDescriptors]; + if (!descriptors) { + throw new Error( + `Constructor ${thing.constructor.name} does not provide serialize descriptors` + ); + } + + return Object.fromEntries( + Object.entries(descriptors).map(([property, transform]) => [ + property, + transform(thing[property]), + ]) + ); } export function serializeThings(things) { - return things.map(serializeThing); + return things.map(serializeThing); } diff --git a/src/data/things.js b/src/data/things.js index 6a5cdb5e..fa7a8d54 100644 --- a/src/data/things.js +++ b/src/data/things.js @@ -1,45 +1,45 @@ +/** @format */ + // things.js: class definitions for various object types used across the wiki, // most of which correspond to an output page, such as Track, Album, Artist import CacheableObject from './cacheable-object.js'; import { - isAdditionalFileList, - isBoolean, - isColor, - isCommentary, - isCountingNumber, - isContributionList, - isDate, - isDimensions, - isDirectory, - isDuration, - isInstance, - isFileExtension, - isLanguageCode, - isName, - isNumber, - isURL, - isString, - isWholeNumber, - oneOf, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, + isAdditionalFileList, + isBoolean, + isColor, + isCommentary, + isCountingNumber, + isContributionList, + isDate, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isLanguageCode, + isName, + isNumber, + isURL, + isString, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, } from './validators.js'; import * as S from './serialize.js'; import { - getKebabCase, - sortAlbumsTracksChronologically, + getKebabCase, + sortAlbumsTracksChronologically, } from '../util/wiki-data.js'; import find from '../util/find.js'; -import { inspect } from 'util'; -import { color } from '../util/cli.js'; +import {inspect} from 'util'; +import {color} from '../util/cli.js'; // Stub classes (and their exports) at the top of the file - these are // referenced later when we actually define static class fields. We deliberately @@ -112,551 +112,579 @@ Flash[Thing.referenceType] = 'flash'; // duplicating less code across wiki data types. These are specialized utility // functions, so check each for how its own arguments behave! Thing.common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName} - }), - - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor} - }), - - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - dependencies: ['name'], - transform(directory, { name }) { - if (directory === null && name === null) - return null; - else if (directory === null) - return getKebabCase(name); - else - return directory; - } - } - }), - - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }), - - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: value => value ?? defaultFileExtension} - }), - - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } + name: (defaultName) => ({ + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }), + + color: () => ({ + flags: {update: true, expose: true}, + update: {validate: isColor}, + }), + + directory: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, + }, + }), + + urls: () => ({ + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + }), + + // A file extension! Or the default, if provided when calling this. + fileExtension: (defaultFileExtension = null) => ({ + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }), + + // Straightforward flag descriptor for a variety of property purposes. + // Provide a default value, true or false! + flag: (defaultValue = false) => { + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; + }, + + // General date type, used as the descriptor for a bunch of properties. + // This isn't dynamic though - it won't inherit from a date stored on + // another object, for example. + simpleDate: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDate}, + }), + + // General string type. This should probably generally be avoided in favor + // of more specific validation, but using it makes it easy to find where we + // might want to improve later, and it's a useful shorthand meanwhile. + simpleString: () => ({ + flags: {update: true, expose: true}, + update: {validate: isString}, + }), + + // External function. These should only be used as dependencies for other + // properties, so they're left unexposed. + externalFunction: () => ({ + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }), + + // Super simple "contributions by reference" list, used for a variety of + // properties (Artists, Cover Artists, etc). This is the property which is + // externally provided, in the form: + // + // [ + // {who: 'Artist Name', what: 'Viola'}, + // {who: 'artist:john-cena', what: null}, + // ... + // ] + // + // ...processed from YAML, spreadsheet, or any other kind of input. + contribsByRef: () => ({ + flags: {update: true, expose: true}, + update: {validate: isContributionList}, + }), + + // Artist commentary! Generally present on tracks and albums. + commentary: () => ({ + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }), + + // This is a somewhat more involved data structure - it's for additional + // or "bonus" files associated with albums or tracks (or anything else). + // It's got this form: + // + // [ + // {title: 'Booklet', files: ['Booklet.pdf']}, + // { + // title: 'Wallpaper', + // description: 'Cool Wallpaper!', + // files: ['1440x900.png', '1920x1080.png'] + // }, + // {title: 'Alternate Covers', description: null, files: [...]}, + // ... + // ] + // + additionalFiles: () => ({ + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + }), + + // A reference list! Keep in mind this is for general references to wiki + // objects of (usually) other Thing subclasses, not specifically leitmotif + // references in tracks (although that property uses referenceList too!). + // + // The underlying function validateReferenceList expects a string like + // 'artist' or 'track', but this utility keeps from having to hard-code the + // string in multiple places by referencing the value saved on the class + // instead. + referenceList: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error( + `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!` + ); + } - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate} - }), - - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString} - }), - - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: t => typeof t === 'function'} - }), - - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }), - - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary} - }), - - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList} - }), - - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: thingClass => { - const { [Thing.referenceType]: referenceType } = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } + return { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList(referenceType)}, + }; + }, + + // Corresponding function for a single reference. + singleReference: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error( + `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!` + ); + } - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: validateReference(referenceType)}, + }; + }, + + // Corresponding dynamic property to referenceList, which takes the values + // in the provided property and searches the specified wiki data for + // matching actual Thing-subclass objects. + dynamicThingsFromReferenceList: ( + referenceListProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - // Corresponding function for a single reference. - singleReference: thingClass => { - const { [Thing.referenceType]: referenceType } = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } + expose: { + dependencies: [referenceListProperty, thingDataProperty], + compute: ({ + [referenceListProperty]: refs, + [thingDataProperty]: thingData, + }) => + refs && thingData + ? refs + .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }), + + // Corresponding function for a single reference. + dynamicThingFromSingleReference: ( + singleReferenceProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)} - }; - }, + expose: { + dependencies: [singleReferenceProperty, thingDataProperty], + compute: ({ + [singleReferenceProperty]: ref, + [thingDataProperty]: thingData, + }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), + }, + }), + + // Corresponding dynamic property to contribsByRef, which takes the values + // in the provided property and searches the object's artistData for + // matching actual Artist objects. The computed structure has the same form + // as contribsByRef, but with Artist objects instead of string references: + // + // [ + // {who: (an Artist), what: 'Viola'}, + // {who: (an Artist), what: null}, + // ... + // ] + // + // Contributions whose "who" values don't match anything in artistData are + // filtered out. (So if the list is all empty, chances are that either the + // reference list is somehow messed up, or artistData isn't being provided + // properly.) + dynamicContribs: (contribsByRefProperty) => ({ + flags: {expose: true}, + expose: { + dependencies: ['artistData', contribsByRefProperty], + compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => + contribsByRef && artistData + ? contribsByRef + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who) + : [], + }, + }), + + // Dynamically inherit a contribution list from some other object, if it + // hasn't been overridden on this object. This is handy for solo albums + // where all tracks have the same artist, for example. + // + // Note: The arguments of this function aren't currently final! The final + // format will look more like (contribsByRef, parentContribsByRef), e.g. + // ('artistContribsByRef', '@album/artistContribsByRef'). + dynamicInheritContribs: ( + contribsByRefProperty, + parentContribsByRefProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, + expose: { + dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], + compute({ + [Thing.instance]: thing, + [contribsByRefProperty]: contribsByRef, + [thingDataProperty]: thingData, + artistData, + }) { + if (!artistData) return []; + const refs = + contribsByRef ?? + findFn(thing, thingData, {mode: 'quiet'})?.[ + parentContribsByRefProperty + ]; + if (!refs) return []; + return refs + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who); + }, + }, + }), + + // Neat little shortcut for "reversing" the reference lists stored on other + // things - for example, tracks specify a "referenced tracks" property, and + // you would use this to compute a corresponding "referenced *by* tracks" + // property. Naturally, the passed ref list property is of the things in the + // wiki data provided, not the requesting Thing itself. + reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => ( - (refs && thingData - ? (refs - .map(ref => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => ( - (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null) - ) - } - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => ( - ((contribsByRef && artistData) - ? (contribsByRef - .map(({ who: ref, what }) => ({ - who: find.artist(ref, artistData), - what - })) - .filter(({ who }) => who)) - : []) - ) - } - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - // - // Note: The arguments of this function aren't currently final! The final - // format will look more like (contribsByRef, parentContribsByRef), e.g. - // ('artistContribsByRef', '@album/artistContribsByRef'). - dynamicInheritContribs: ( - contribsByRefProperty, - parentContribsByRefProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], - compute({ - [Thing.instance]: thing, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData - }) { - if (!artistData) return []; - const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]); - if (!refs) return []; - return (refs - .map(({ who: ref, what }) => ({ - who: find.artist(ref, artistData), - what - })) - .filter(({ who }) => who)); - } - } - }), - - // Neat little shortcut for "reversing" the reference lists stored on other - // things - for example, tracks specify a "referenced tracks" property, and - // you would use this to compute a corresponding "referenced *by* tracks" - // property. Naturally, the passed ref list property is of the things in the - // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [wikiDataProperty], - - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - (wikiData - ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing)) - : []) + expose: { + dependencies: [wikiDataProperty], + + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData + ? wikiData.filter((t) => + t[referencerRefListProperty]?.includes(thing) ) - } - }), + : [], + }, + }), - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, + // Corresponding function for single references. Note that the return value + // is still a list - this is for matching all the objects whose single + // reference (in the given property) matches this Thing. + reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - expose: { - dependencies: [wikiDataProperty], + expose: { + dependencies: [wikiDataProperty], - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - wikiData?.filter(t => t[referencerRefListProperty] === thing)) - } - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)) - } - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({ artistData, commentary }) => ( - (artistData && commentary - ? Array.from(new Set((Array - .from(commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g)) - .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'}))))) - : [])) - } - }), + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData?.filter((t) => t[referencerRefListProperty] === thing), + }, + }), + + // General purpose wiki data constructor, for properties like artistData, + // trackData, etc. + wikiData: (thingClass) => ({ + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }), + + // This one's kinda tricky: it parses artist "references" from the + // commentary content, and finds the matching artist for each reference. + // This is mostly useful for credits and listings on artist pages. + commentatorArtists: () => ({ + flags: {expose: true}, + + expose: { + dependencies: ['artistData', 'commentary'], + + compute: ({artistData, commentary}) => + artistData && commentary + ? Array.from( + new Set( + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g) + ).map(({groups: {who}}) => + find.artist(who, artistData, {mode: 'quiet'}) + ) + ) + ) + : [], + }, + }), }; // Get a reference to a thing (e.g. track:showtime-piano-refrain), using its // constructor's [Thing.referenceType] as the prefix. This will throw an error // if the thing's directory isn't yet provided/computable. -Thing.getReference = function(thing) { - if (!thing.constructor[Thing.referenceType]) - throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); - - if (!thing.directory) - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - - return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; +Thing.getReference = function (thing) { + if (!thing.constructor[Thing.referenceType]) + throw TypeError( + `Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]` + ); + + if (!thing.directory) + throw TypeError( + `Passed ${thing.constructor.name} is missing its directory` + ); + + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; }; // Default custom inspect function, which may be overridden by Thing subclasses. // This will be used when displaying aggregate errors and other in command-line // logging - it's the place to provide information useful in identifying the // Thing being presented. -Thing.prototype[inspect.custom] = function() { - const cname = this.constructor.name; - - return (this.name - ? `${cname} ${color.green(`"${this.name}"`)}` - : `${cname}`) + (this.directory - ? ` (${color.blue(Thing.getReference(this))})` - : ''); +Thing.prototype[inspect.custom] = function () { + const cname = this.constructor.name; + + return ( + (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + ); }; // -> Album Album.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), + name: Thing.common.name('Unnamed Album'), + color: Thing.common.color(), + directory: Thing.common.directory(), + urls: Thing.common.urls(), - date: Thing.common.simpleDate(), - trackArtDate: Thing.common.simpleDate(), - dateAddedToWiki: Thing.common.simpleDate(), + date: Thing.common.simpleDate(), + trackArtDate: Thing.common.simpleDate(), + dateAddedToWiki: Thing.common.simpleDate(), - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['date'], - transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null - } + expose: { + dependencies: ['date'], + transform: (coverArtDate, {date}) => coverArtDate ?? date ?? null, }, + }, - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), + trackCoverArtistContribsByRef: Thing.common.contribsByRef(), + wallpaperArtistContribsByRef: Thing.common.contribsByRef(), + bannerArtistContribsByRef: Thing.common.contribsByRef(), - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), + groupsByRef: Thing.common.referenceList(Group), + artTagsByRef: Thing.common.referenceList(ArtTag), - trackGroups: { - flags: {update: true, expose: true}, + trackGroups: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)) - } + update: { + validate: validateArrayItems(validateInstanceOf(TrackGroup)), }, + }, - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), + trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), - - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions} - }, + wallpaperStyle: Thing.common.simpleString(), + wallpaperFileExtension: Thing.common.fileExtension('jpg'), - hasCoverArt: Thing.common.flag(true), - hasTrackArt: Thing.common.flag(true), - hasTrackNumbers: Thing.common.flag(true), - isMajorRelease: Thing.common.flag(false), - isListedOnHomepage: Thing.common.flag(true), + bannerStyle: Thing.common.simpleString(), + bannerFileExtension: Thing.common.fileExtension('jpg'), + bannerDimensions: { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }, - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + hasCoverArt: Thing.common.flag(true), + hasTrackArt: Thing.common.flag(true), + hasTrackNumbers: Thing.common.flag(true), + isMajorRelease: Thing.common.flag(false), + isListedOnHomepage: Thing.common.flag(true), - // Update only + commentary: Thing.common.commentary(), + additionalFiles: Thing.common.additionalFiles(), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + // Update only - // Expose only + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + groupData: Thing.common.wikiData(Group), + trackData: Thing.common.wikiData(Track), - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), + coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), + trackCoverArtistContribs: Thing.common.dynamicContribs( + 'trackCoverArtistContribsByRef' + ), + wallpaperArtistContribs: Thing.common.dynamicContribs( + 'wallpaperArtistContribsByRef' + ), + bannerArtistContribs: Thing.common.dynamicContribs( + 'bannerArtistContribsByRef' + ), - tracks: { - flags: {expose: true}, + commentatorArtists: Thing.common.commentatorArtists(), - expose: { - dependencies: ['trackGroups', 'trackData'], - compute: ({ trackGroups, trackData }) => ( - (trackGroups && trackData - ? (trackGroups - .flatMap(group => group.tracksByRef ?? []) - .map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }, - - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + tracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackGroups', 'trackData'], + compute: ({trackGroups, trackData}) => + trackGroups && trackData + ? trackGroups + .flatMap((group) => group.tracksByRef ?? []) + .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }, + + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), }; Album[S.serializeDescriptors] = { - name: S.id, - color: S.id, - directory: S.id, - urls: S.id, - - date: S.id, - coverArtDate: S.id, - trackArtDate: S.id, - dateAddedToWiki: S.id, - - artistContribs: S.toContribRefs, - coverArtistContribs: S.toContribRefs, - trackCoverArtistContribs: S.toContribRefs, - wallpaperArtistContribs: S.toContribRefs, - bannerArtistContribs: S.toContribRefs, - - coverArtFileExtension: S.id, - trackCoverArtFileExtension: S.id, - wallpaperStyle: S.id, - wallpaperFileExtension: S.id, - bannerStyle: S.id, - bannerFileExtension: S.id, - bannerDimensions: S.id, - - hasTrackArt: S.id, - isMajorRelease: S.id, - isListedOnHomepage: S.id, - - commentary: S.id, - additionalFiles: S.id, - - tracks: S.toRefs, - groups: S.toRefs, - artTags: S.toRefs, - commentatorArtists: S.toRefs, + name: S.id, + color: S.id, + directory: S.id, + urls: S.id, + + date: S.id, + coverArtDate: S.id, + trackArtDate: S.id, + dateAddedToWiki: S.id, + + artistContribs: S.toContribRefs, + coverArtistContribs: S.toContribRefs, + trackCoverArtistContribs: S.toContribRefs, + wallpaperArtistContribs: S.toContribRefs, + bannerArtistContribs: S.toContribRefs, + + coverArtFileExtension: S.id, + trackCoverArtFileExtension: S.id, + wallpaperStyle: S.id, + wallpaperFileExtension: S.id, + bannerStyle: S.id, + bannerFileExtension: S.id, + bannerDimensions: S.id, + + hasTrackArt: S.id, + isMajorRelease: S.id, + isListedOnHomepage: S.id, + + commentary: S.id, + additionalFiles: S.id, + + tracks: S.toRefs, + groups: S.toRefs, + artTags: S.toRefs, + commentatorArtists: S.toRefs, }; TrackGroup.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track Group'), + name: Thing.common.name('Unnamed Track Group'), - color: { - flags: {update: true, expose: true}, + color: { + flags: {update: true, expose: true}, - update: {validate: isColor}, + update: {validate: isColor}, - expose: { - dependencies: ['album'], + expose: { + dependencies: ['album'], - transform(color, { album }) { - return color ?? album?.color ?? null; - } - } + transform(color, {album}) { + return color ?? album?.color ?? null; + }, }, + }, - dateOriginallyReleased: Thing.common.simpleDate(), + dateOriginallyReleased: Thing.common.simpleDate(), - tracksByRef: Thing.common.referenceList(Track), + tracksByRef: Thing.common.referenceList(Track), - isDefaultTrackGroup: Thing.common.flag(false), + isDefaultTrackGroup: Thing.common.flag(false), - // Update only + // Update only - album: { - flags: {update: true}, - update: {validate: validateInstanceOf(Album)} - }, + album: { + flags: {update: true}, + update: {validate: validateInstanceOf(Album)}, + }, - trackData: Thing.common.wikiData(Track), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - tracks: { - flags: {expose: true}, + tracks: { + flags: {expose: true}, - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({ tracksByRef, trackData }) => ( - (tracksByRef && trackData - ? (tracksByRef - .map(ref => find.track(ref, trackData)) - .filter(Boolean)) - : []) - ) - } + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({tracksByRef, trackData}) => + tracksByRef && trackData + ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) + : [], }, + }, - startIndex: { - flags: {expose: true}, + startIndex: { + flags: {expose: true}, - expose: { - dependencies: ['album'], - compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups - .slice(0, album.trackGroups.indexOf(trackGroup)) - .reduce((acc, tg) => acc + tg.tracks.length, 0)) - } + expose: { + dependencies: ['album'], + compute: ({album, [TrackGroup.instance]: trackGroup}) => + album.trackGroups + .slice(0, album.trackGroups.indexOf(trackGroup)) + .reduce((acc, tg) => acc + tg.tracks.length, 0), }, + }, }; // -> Track @@ -665,1059 +693,1193 @@ TrackGroup.propertyDescriptors = { // several places. Ideally it wouldn't be - we'd just reuse the `album` property // - but support for that hasn't been coded yet :P Track.findAlbum = (track, albumData) => { - return albumData?.find(album => album.tracks.includes(track)); + return albumData?.find((album) => album.tracks.includes(track)); }; // Another reused utility function. This one's logic is a bit more complicated. -Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => { - return ( - hasCoverArt ?? - (coverArtistContribsByRef?.length > 0 || null) ?? - Track.findAlbum(track, albumData)?.hasTrackArt ?? - true); +Track.hasCoverArt = ( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt +) => { + return ( + hasCoverArt ?? + (coverArtistContribsByRef?.length > 0 || null) ?? + Track.findAlbum(track, albumData)?.hasTrackArt ?? + true + ); }; Track.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Track'), + directory: Thing.common.directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration} - }, + duration: { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }, - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), + urls: Thing.common.urls(), + dateFirstReleased: Thing.common.simpleDate(), - hasURLs: Thing.common.flag(true), + hasURLs: Thing.common.flag(true), - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), - referencedTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), + referencedTracksByRef: Thing.common.referenceList(Track), + artTagsByRef: Thing.common.referenceList(ArtTag), - hasCoverArt: { - flags: {update: true, expose: true}, + hasCoverArt: { + flags: {update: true, expose: true}, - update: {validate: isBoolean}, + update: {validate: isBoolean}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => ( - Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)) - } + expose: { + dependencies: ['albumData', 'coverArtistContribsByRef'], + transform: ( + hasCoverArt, + {albumData, coverArtistContribsByRef, [Track.instance]: track} + ) => + Track.hasCoverArt( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ), }, + }, - coverArtFileExtension: { - flags: {update: true, expose: true}, + coverArtFileExtension: { + flags: {update: true, expose: true}, - update: {validate: isFileExtension}, + update: {validate: isFileExtension}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { albumData, coverArtistContribsByRef, hasCoverArt, [Track.instance]: track }) => ( - coverArtFileExtension ?? - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg') + expose: { + dependencies: ['albumData', 'coverArtistContribsByRef'], + transform: ( + coverArtFileExtension, + { + albumData, + coverArtistContribsByRef, + hasCoverArt, + [Track.instance]: track, } + ) => + coverArtFileExtension ?? + (Track.hasCoverArt( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ) + ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension + : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? + 'jpg', }, + }, - // Previously known as: (track).aka - originalReleaseTrackByRef: Thing.common.singleReference(Track), + // Previously known as: (track).aka + originalReleaseTrackByRef: Thing.common.singleReference(Track), - dataSourceAlbumByRef: Thing.common.singleReference(Album), + dataSourceAlbumByRef: Thing.common.singleReference(Album), - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), + commentary: Thing.common.commentary(), + lyrics: Thing.common.simpleString(), + additionalFiles: Thing.common.additionalFiles(), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: Thing.common.commentatorArtists(), - album: { - flags: {expose: true}, + album: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ [Track.instance]: track, albumData }) => ( - albumData?.find(album => album.tracks.includes(track)) ?? null) - } - }, + expose: { + dependencies: ['albumData'], + compute: ({[Track.instance]: track, albumData}) => + albumData?.find((album) => album.tracks.includes(track)) ?? null, + }, + }, + + // Note - this is an internal property used only to help identify a track. + // It should not be assumed in general that the album and dataSourceAlbum match + // (i.e. a track may dynamically be moved from one album to another, at + // which point dataSourceAlbum refers to where it was originally from, and is + // not generally relevant information). It's also not guaranteed that + // dataSourceAlbum is available (depending on the Track creator to optionally + // provide dataSourceAlbumByRef). + dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( + 'dataSourceAlbumByRef', + 'albumData', + find.album + ), + + date: { + flags: {expose: true}, - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album), - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => ( - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.date ?? - null - ) - } + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => + dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Track.instance]: track }) => ( - (Track.findAlbum(track, albumData)?.trackGroups - .find(tg => tg.tracks.includes(track))?.color) - ?? null - ) - } + compute: ({albumData, [Track.instance]: track}) => + Track.findAlbum(track, albumData)?.trackGroups.find((tg) => + tg.tracks.includes(track) + )?.color ?? null, }, + }, - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => ( - coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - ) - } - }, - - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference('originalReleaseTrackByRef', 'trackData', find.track), - - otherReleases: { - flags: {expose: true}, - - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], - - compute: ({ originalReleaseTrackByRef: t1origRef, trackData, [Track.instance]: t1 }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter(t2 => { - const { originalReleaseTrack: t2orig } = t2; - return ( - t2 !== t1 && - t2orig && - (t2orig === t1orig || t2orig === t1) - ); - }) - ].filter(Boolean); - } - } - }, + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + transform: ( + coverArtDate, + {albumData, dateFirstReleased, [Track.instance]: track} + ) => + coverArtDate ?? + dateFirstReleased ?? + Track.findAlbum(track, albumData)?.trackArtDate ?? + Track.findAlbum(track, albumData)?.date ?? + null, + }, + }, + + originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( + 'originalReleaseTrackByRef', + 'trackData', + find.track + ), + + otherReleases: { + flags: {expose: true}, - // Previously known as: (track).artists - artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).contributors - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - - // Previously known as: (track).coverArtists - coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).references - referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({ trackData, [Track.instance]: track }) => (trackData - ? (trackData - .filter(t => !t.originalReleaseTrack) - .filter(t => t.referencedTracks?.includes(track))) - : []) + expose: { + dependencies: ['originalReleaseTrackByRef', 'trackData'], + + compute: ({ + originalReleaseTrackByRef: t1origRef, + trackData, + [Track.instance]: t1, + }) => { + if (!trackData) { + return []; } - }, - // Previously known as: (track).flashes - featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'), + const t1orig = find.track(t1origRef, trackData); + + return [ + t1orig, + ...trackData.filter((t2) => { + const {originalReleaseTrack: t2orig} = t2; + return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); + }), + ].filter(Boolean); + }, + }, + }, + + // Previously known as: (track).artists + artistContribs: Thing.common.dynamicInheritContribs( + 'artistContribsByRef', + 'artistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).contributors + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + + // Previously known as: (track).coverArtists + coverArtistContribs: Thing.common.dynamicInheritContribs( + 'coverArtistContribsByRef', + 'trackCoverArtistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).references + referencedTracks: Thing.common.dynamicThingsFromReferenceList( + 'referencedTracksByRef', + 'trackData', + find.track + ), + + // Specifically exclude re-releases from this list - while it's useful to + // get from a re-release to the tracks it references, re-releases aren't + // generally relevant from the perspective of the tracks being referenced. + // Filtering them from data here hides them from the corresponding field + // on the site (obviously), and has the bonus of not counting them when + // counting the number of times a track has been referenced, for use in + // the "Tracks - by Times Referenced" listing page (or other data + // processing). + referencedByTracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Track.instance]: track}) => + trackData + ? trackData + .filter((t) => !t.originalReleaseTrack) + .filter((t) => t.referencedTracks?.includes(track)) + : [], + }, + }, + + // Previously known as: (track).flashes + featuredInFlashes: Thing.common.reverseReferenceList( + 'flashData', + 'featuredTracks' + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), }; -Track.prototype[inspect.custom] = function() { - const base = Thing.prototype[inspect.custom].apply(this); +Track.prototype[inspect.custom] = function () { + const base = Thing.prototype[inspect.custom].apply(this); - const { album, dataSourceAlbum } = this; - const albumName = (album ? album.name : dataSourceAlbum?.name); - const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); - const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + const {album, dataSourceAlbum} = this; + const albumName = album ? album.name : dataSourceAlbum?.name; + const albumIndex = + albumName && + (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); + const trackNum = albumIndex === -1 ? '#?' : `#${albumIndex + 1}`; - return (albumName - ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : base); + return albumName + ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` + : base; }; // -> Artist Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({ - flags: {expose: true}, + flags: {expose: true}, - expose: { - dependencies: [thingDataProperty], + expose: { + dependencies: [thingDataProperty], - compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => ( - thingData?.filter(({ [contribsProperty]: contribs }) => ( - contribs?.some(contrib => contrib.who === artist)))) - } + compute: ({[thingDataProperty]: thingData, [Artist.instance]: artist}) => + thingData?.filter(({[contribsProperty]: contribs}) => + contribs?.some((contrib) => contrib.who === artist) + ), + }, }); Artist.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: Thing.common.name('Unnamed Artist'), + directory: Thing.common.directory(), + urls: Thing.common.urls(), + contextNotes: Thing.common.simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: Thing.common.flag(false), + avatarFileExtension: Thing.common.fileExtension('jpg'), - aliasNames: { - flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(isName) - } + aliasNames: { + flags: {update: true, expose: true}, + update: { + validate: validateArrayItems(isName), }, + }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), - - // Update only - - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), - - // Expose only - - aliasedArtist: { - flags: {expose: true}, + isAlias: Thing.common.flag(), + aliasedArtistRef: Thing.common.singleReference(Artist), - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({ artistData, aliasedArtistRef }) => ( - (aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null) - ) - } - }, - - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), - tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'), - tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'), + // Update only - tracksAsAny: { - flags: {expose: true}, + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - expose: { - dependencies: ['trackData'], + // Expose only - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData?.filter(track => ( - [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs - ].some(({ who }) => who === artist)))) - } - }, + aliasedArtist: { + flags: {expose: true}, - tracksAsCommentator: { - flags: {expose: true}, + expose: { + dependencies: ['artistData', 'aliasedArtistRef'], + compute: ({artistData, aliasedArtistRef}) => + aliasedArtistRef && artistData + ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) + : null, + }, + }, + + tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), + tracksAsContributor: Artist.filterByContrib( + 'trackData', + 'contributorContribs' + ), + tracksAsCoverArtist: Artist.filterByContrib( + 'trackData', + 'coverArtistContribs' + ), + + tracksAsAny: { + flags: {expose: true}, - expose: { - dependencies: ['trackData'], + expose: { + dependencies: ['trackData'], - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({trackData, [Artist.instance]: artist}) => + trackData?.filter((track) => + [ + ...track.artistContribs, + ...track.contributorContribs, + ...track.coverArtistContribs, + ].some(({who}) => who === artist) + ), }, + }, - albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), - albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'), - albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'), - albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'), + tracksAsCommentator: { + flags: {expose: true}, - albumsAsCommentator: { - flags: {expose: true}, + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Artist.instance]: artist}) => + trackData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), + }, + }, + + albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), + albumsAsCoverArtist: Artist.filterByContrib( + 'albumData', + 'coverArtistContribs' + ), + albumsAsWallpaperArtist: Artist.filterByContrib( + 'albumData', + 'wallpaperArtistContribs' + ), + albumsAsBannerArtist: Artist.filterByContrib( + 'albumData', + 'bannerArtistContribs' + ), + + albumsAsCommentator: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Artist.instance]: artist }) => ( - albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({albumData, [Artist.instance]: artist}) => + albumData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), }, + }, - flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'), + flashesAsContributor: Artist.filterByContrib( + 'flashData', + 'contributorContribs' + ), }; Artist[S.serializeDescriptors] = { - name: S.id, - directory: S.id, - urls: S.id, - contextNotes: S.id, + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, - hasAvatar: S.id, - avatarFileExtension: S.id, + hasAvatar: S.id, + avatarFileExtension: S.id, - aliasNames: S.id, + aliasNames: S.id, - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, - tracksAsCommentator: S.toRefs, + tracksAsArtist: S.toRefs, + tracksAsContributor: S.toRefs, + tracksAsCoverArtist: S.toRefs, + tracksAsCommentator: S.toRefs, - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, - albumsAsCommentator: S.toRefs, + albumsAsAlbumArtist: S.toRefs, + albumsAsCoverArtist: S.toRefs, + albumsAsWallpaperArtist: S.toRefs, + albumsAsBannerArtist: S.toRefs, + albumsAsCommentator: S.toRefs, - flashesAsContributor: S.toRefs, + flashesAsContributor: S.toRefs, }; // -> Group Group.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Group'), + directory: Thing.common.directory(), - description: Thing.common.simpleString(), + description: Thing.common.simpleString(), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: Thing.common.wikiData(Album), + groupCategoryData: Thing.common.wikiData(GroupCategory), - // Expose only + // Expose only - descriptionShort: { - flags: {expose: true}, + descriptionShort: { + flags: {expose: true}, - expose: { - dependencies: ['description'], - compute: ({ description }) => description.split('<hr class="split">')[0] - } + expose: { + dependencies: ['description'], + compute: ({description}) => description.split('<hr class="split">')[0], }, + }, - albums: { - flags: {expose: true}, + albums: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ albumData, [Group.instance]: group }) => ( - albumData?.filter(album => album.groups.includes(group)) ?? []) - } + expose: { + dependencies: ['albumData'], + compute: ({albumData, [Group.instance]: group}) => + albumData?.filter((album) => album.groups.includes(group)) ?? [], }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], + expose: { + dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group))?.color ?? null) - } + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) + ?.color ?? null, }, + }, - category: { - flags: {expose: true}, + category: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group)) ?? null) - } + expose: { + dependencies: ['groupCategoryData'], + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) ?? + null, }, + }, }; GroupCategory.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: Thing.common.name('Unnamed Group Category'), + color: Thing.common.color(), - groupsByRef: Thing.common.referenceList(Group), + groupsByRef: Thing.common.referenceList(Group), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), }; // -> ArtTag ArtTag.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: Thing.common.name('Unnamed Art Tag'), + directory: Thing.common.directory(), + color: Thing.common.color(), + isContentWarning: Thing.common.flag(false), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: Thing.common.wikiData(Album), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - // Previously known as: (tag).things - taggedInThings: { - flags: {expose: true}, + // Previously known as: (tag).things + taggedInThings: { + flags: {expose: true}, - expose: { - dependencies: ['albumData', 'trackData'], - compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => ( - sortAlbumsTracksChronologically( - ([...albumData, ...trackData] - .filter(thing => thing.artTags?.includes(artTag))), - {getDate: o => o.coverArtDate})) - } - } + expose: { + dependencies: ['albumData', 'trackData'], + compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + sortAlbumsTracksChronologically( + [...albumData, ...trackData].filter((thing) => + thing.artTags?.includes(artTag) + ), + {getDate: (o) => o.coverArtDate} + ), + }, + }, }; // -> NewsEntry NewsEntry.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: Thing.common.name('Unnamed News Entry'), + directory: Thing.common.directory(), + date: Thing.common.simpleDate(), - content: Thing.common.simpleString(), + content: Thing.common.simpleString(), - // Expose only + // Expose only - contentShort: { - flags: {expose: true}, + contentShort: { + flags: {expose: true}, - expose: { - dependencies: ['content'], + expose: { + dependencies: ['content'], - compute: ({ content }) => content.split('<hr class="split">')[0] - } + compute: ({content}) => content.split('<hr class="split">')[0], }, + }, }; // -> StaticPage StaticPage.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: Thing.common.name('Unnamed Static Page'), - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), - showInNavigationBar: Thing.common.flag(true), + directory: Thing.common.directory(), + content: Thing.common.simpleString(), + stylesheet: Thing.common.simpleString(), + showInNavigationBar: Thing.common.flag(true), }; // -> HomepageLayout HomepageLayout.propertyDescriptors = { - // Update & expose + // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: Thing.common.simpleString(), - rows: { - flags: {update: true, expose: true}, + rows: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)) - } + update: { + validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), }, + }, }; HomepageLayoutRow.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: Thing.common.name('Unnamed Homepage Row'), - type: { - flags: {update: true, expose: true}, + type: { + flags: {update: true, expose: true}, - update: { - validate(value) { - throw new Error(`'type' property validator must be overridden`); - } - } + update: { + validate() { + throw new Error(`'type' property validator must be overridden`); + }, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // Update only + // Update only - // These aren't necessarily used by every HomepageLayoutRow subclass, but - // for convenience of providing this data, every row accepts all wiki data - // arrays depended upon by any subclass's behavior. - albumData: Thing.common.wikiData(Album), - groupData: Thing.common.wikiData(Group), + // These aren't necessarily used by every HomepageLayoutRow subclass, but + // for convenience of providing this data, every row accepts all wiki data + // arrays depended upon by any subclass's behavior. + albumData: Thing.common.wikiData(Album), + groupData: Thing.common.wikiData(Group), }; HomepageLayoutAlbumsRow.propertyDescriptors = { - ...HomepageLayoutRow.propertyDescriptors, + ...HomepageLayoutRow.propertyDescriptors, - // Update & expose + // Update & expose - type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } - - return true; - } + type: { + flags: {update: true, expose: true}, + update: { + validate(value) { + if (value !== 'albums') { + throw new TypeError(`Expected 'albums'`); } + + return true; + }, }, + }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroupByRef: Thing.common.singleReference(Group), + sourceAlbumsByRef: Thing.common.referenceList(Album), - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber} - }, + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)} - }, + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, - // Expose only + // Expose only - sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group), - sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album), + sourceGroup: Thing.common.dynamicThingFromSingleReference( + 'sourceGroupByRef', + 'groupData', + find.group + ), + sourceAlbums: Thing.common.dynamicThingsFromReferenceList( + 'sourceAlbumsByRef', + 'albumData', + find.album + ), }; // -> Flash Flash.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Flash'), - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - - // Flashes expose directory differently from other Things! Their - // default directory is dependent on the page number (or ID), not - // the name. - expose: { - dependencies: ['page'], - transform(directory, { page }) { - if (directory === null && page === null) - return null; - else if (directory === null) - return page; - else - return directory; - } - } + // Update & expose + + name: Thing.common.name('Unnamed Flash'), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, }, + }, - page: { - flags: {update: true, expose: true}, - update: {validate: oneOf(isString, isNumber)}, + page: { + flags: {update: true, expose: true}, + update: {validate: oneOf(isString, isNumber)}, - expose: { - transform: value => (value === null ? null : value.toString()) - } + expose: { + transform: (value) => (value === null ? null : value.toString()), }, + }, - date: Thing.common.simpleDate(), + date: Thing.common.simpleDate(), - coverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), - contributorContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), - featuredTracksByRef: Thing.common.referenceList(Track), + featuredTracksByRef: Thing.common.referenceList(Track), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + artistData: Thing.common.wikiData(Artist), + trackData: Thing.common.wikiData(Track), + flashActData: Thing.common.wikiData(FlashAct), - // Expose only + // Expose only - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track), + featuredTracks: Thing.common.dynamicThingsFromReferenceList( + 'featuredTracksByRef', + 'trackData', + find.track + ), - act: { - flags: {expose: true}, + act: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash)) ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash))?.color ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, + }, }; Flash[S.serializeDescriptors] = { - name: S.id, - page: S.id, - directory: S.id, - date: S.id, - contributors: S.toContribRefs, - tracks: S.toRefs, - urls: S.id, - color: S.id, + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, }; FlashAct.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), - jumpColor: Thing.common.color(), + name: Thing.common.name('Unnamed Flash Act'), + color: Thing.common.color(), + anchor: Thing.common.simpleString(), + jump: Thing.common.simpleString(), + jumpColor: Thing.common.color(), - flashesByRef: Thing.common.referenceList(Flash), + flashesByRef: Thing.common.referenceList(Flash), - // Update only + // Update only - flashData: Thing.common.wikiData(Flash), + flashData: Thing.common.wikiData(Flash), - // Expose only + // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), + flashes: Thing.common.dynamicThingsFromReferenceList( + 'flashesByRef', + 'flashData', + find.flash + ), }; // -> WikiInfo WikiInfo.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: Thing.common.name('Unnamed Wiki'), - // Displayed in nav bar. - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), + // One-line description used for <meta rel="description"> tag. + description: Thing.common.simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: Thing.common.simpleString(), - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL} - }, + canonicalBase: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), - // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + // Feature toggles + enableFlashesAndGames: Thing.common.flag(false), + enableListings: Thing.common.flag(false), + enableNews: Thing.common.flag(false), + enableArtTagUI: Thing.common.flag(false), + enableGroupUI: Thing.common.flag(false), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( + 'divideTrackListsByGroupsByRef', + 'groupData', + find.group + ), }; // -> Language const intlHelper = (constructor, opts) => ({ - flags: {expose: true}, - expose: { - dependencies: ['code', 'intlCode'], - compute: ({ code, intlCode }) => { - const constructCode = intlCode ?? code; - if (!constructCode) return null; - return Reflect.construct(constructor, [constructCode, opts]); - } - } + flags: {expose: true}, + expose: { + dependencies: ['code', 'intlCode'], + compute: ({code, intlCode}) => { + const constructCode = intlCode ?? code; + if (!constructCode) return null; + return Reflect.construct(constructor, [constructCode, opts]); + }, + }, }); Language.propertyDescriptors = { - // Update & expose - - // General language code. This is used to identify the language distinctly - // from other languages (similar to how "Directory" operates in many data - // objects). - code: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, - - // Human-readable name. This should be the language's own native name, not - // localized to any other language. - name: Thing.common.simpleString(), - - // Language code specific to JavaScript's Internationalization (Intl) API. - // Usually this will be the same as the language's general code, but it - // may be overridden to provide Intl constructors an alternative value. - intlCode: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode}, - expose: { - dependencies: ['code'], - transform: (intlCode, { code }) => intlCode ?? code - } - }, - - // Flag which represents whether or not to hide a language from general - // access. If a language is hidden, its portion of the website will still - // be built (with all strings localized to the language), but it won't be - // included in controls for switching languages or the <link rel=alternate> - // tags used for search engine optimization. This flag is intended for use - // with languages that are currently in development and not ready for - // formal release, or which are just kept hidden as "experimental zones" - // for wiki development or content testing. - hidden: Thing.common.flag(false), - - // Mapping of translation keys to values (strings). Generally, don't - // access this object directly - use methods instead. - strings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'}, - expose: { - dependencies: ['inheritedStrings'], - transform(strings, { inheritedStrings }) { - if (strings || inheritedStrings) { - return {...inheritedStrings ?? {}, ...strings ?? {}}; - } else { - return null; - } - } + // Update & expose + + // General language code. This is used to identify the language distinctly + // from other languages (similar to how "Directory" operates in many data + // objects). + code: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + // Human-readable name. This should be the language's own native name, not + // localized to any other language. + name: Thing.common.simpleString(), + + // Language code specific to JavaScript's Internationalization (Intl) API. + // Usually this will be the same as the language's general code, but it + // may be overridden to provide Intl constructors an alternative value. + intlCode: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + expose: { + dependencies: ['code'], + transform: (intlCode, {code}) => intlCode ?? code, + }, + }, + + // Flag which represents whether or not to hide a language from general + // access. If a language is hidden, its portion of the website will still + // be built (with all strings localized to the language), but it won't be + // included in controls for switching languages or the <link rel=alternate> + // tags used for search engine optimization. This flag is intended for use + // with languages that are currently in development and not ready for + // formal release, or which are just kept hidden as "experimental zones" + // for wiki development or content testing. + hidden: Thing.common.flag(false), + + // Mapping of translation keys to values (strings). Generally, don't + // access this object directly - use methods instead. + strings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + expose: { + dependencies: ['inheritedStrings'], + transform(strings, {inheritedStrings}) { + if (strings || inheritedStrings) { + return {...(inheritedStrings ?? {}), ...(strings ?? {})}; + } else { + return null; } + }, }, + }, - // May be provided to specify "default" strings, generally (but not - // necessarily) inherited from another Language object. - inheritedStrings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'} - }, + // May be provided to specify "default" strings, generally (but not + // necessarily) inherited from another Language object. + inheritedStrings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + }, - // Update only + // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: Thing.common.externalFunction(), - // Expose only + // Expose only - intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), - intl_number: intlHelper(Intl.NumberFormat), - intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), - intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), - intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), - intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), - intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), + intl_number: intlHelper(Intl.NumberFormat), + intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), + intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), + intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), + intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), + intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), - validKeys: { - flags: {expose: true}, - - expose: { - dependencies: ['strings', 'inheritedStrings'], - compute: ({ strings, inheritedStrings }) => Array.from(new Set([ - ...Object.keys(inheritedStrings ?? {}), - ...Object.keys(strings ?? {}) - ])) - } - }, + validKeys: { + flags: {expose: true}, - strings_htmlEscaped: { - flags: {expose: true}, - expose: { - dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], - compute({ strings, inheritedStrings, escapeHTML }) { - if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...inheritedStrings ?? {}, ...strings ?? {}}; - return Object.fromEntries(Object.entries(allStrings) - .map(([ k, v ]) => [k, escapeHTML(v)])); - } - } - }, + expose: { + dependencies: ['strings', 'inheritedStrings'], + compute: ({strings, inheritedStrings}) => + Array.from( + new Set([ + ...Object.keys(inheritedStrings ?? {}), + ...Object.keys(strings ?? {}), + ]) + ), + }, + }, + + strings_htmlEscaped: { + flags: {expose: true}, + expose: { + dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], + compute({strings, inheritedStrings, escapeHTML}) { + if (!(strings || inheritedStrings) || !escapeHTML) return null; + const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})}; + return Object.fromEntries( + Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) + ); + }, + }, + }, }; -const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) { +const countHelper = (stringKey, argName = stringKey) => + function (value, {unit = false} = {}) { return this.$( - (unit - ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) - : `count.${stringKey}`), - {[argName]: this.formatNumber(value)}); -}; + unit + ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) + : `count.${stringKey}`, + {[argName]: this.formatNumber(value)} + ); + }; Object.assign(Language.prototype, { - $(key, args = {}) { - return this.formatString(key, args); - }, - - assertIntlAvailable(property) { - if (!this[property]) { - throw new Error(`Intl API ${property} unavailable`); - } - }, - - getUnitForm(value) { - this.assertIntlAvailable('intl_pluralCardinal'); - return this.intl_pluralCardinal.select(value); - }, - - formatString(key, args = {}) { - if (this.strings && !this.strings_htmlEscaped) { - throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`); - } - - return this.formatStringHelper(this.strings_htmlEscaped, key, args); - }, - - formatStringNoHTMLEscape(key, args = {}) { - return this.formatStringHelper(this.strings, key, args); - }, - - formatStringHelper(strings, key, args = {}) { - if (!strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - - const template = strings[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_]+\}/)) { - throw new Error(`Args in ${key} were missing - output: ${output}`); - } - - return output; - }, + $(key, args = {}) { + return this.formatString(key, args); + }, - formatDate(date) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.format(date); - }, - - formatDateRange(startDate, endDate) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.formatRange(startDate, endDate); - }, - - formatDuration(secTotal, {approximate = false, unit = false} = {}) { - if (secTotal === 0) { - return this.formatString('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 - ? this.formatString('count.duration.hours' + stringSubkey, { - hours: hour, - minutes: pad(min), - seconds: pad(sec) - }) - : this.formatString('count.duration.minutes' + stringSubkey, { - minutes: min, - seconds: pad(sec) - })); - - return (approximate - ? this.formatString('count.duration.approximate', {duration}) - : duration); - }, - - formatIndex(value) { - this.assertIntlAvailable('intl_pluralOrdinal'); - return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); - }, - - formatNumber(value) { - this.assertIntlAvailable('intl_number'); - return this.intl_number.format(value); - }, - - formatWordCount(value) { - const num = this.formatNumber(value > 1000 - ? Math.floor(value / 100) / 10 - : value); + assertIntlAvailable(property) { + if (!this[property]) { + throw new Error(`Intl API ${property} unavailable`); + } + }, + + getUnitForm(value) { + this.assertIntlAvailable('intl_pluralCardinal'); + return this.intl_pluralCardinal.select(value); + }, + + formatString(key, args = {}) { + if (this.strings && !this.strings_htmlEscaped) { + throw new Error( + `HTML-escaped strings unavailable - please ensure escapeHTML function is provided` + ); + } - const words = (value > 1000 - ? this.formatString('count.words.thousand', {words: num}) - : this.formatString('count.words', {words: num})); + return this.formatStringHelper(this.strings_htmlEscaped, key, args); + }, - return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words}); - }, + formatStringNoHTMLEscape(key, args = {}) { + return this.formatStringHelper(this.strings, key, args); + }, - // Conjunction list: A, B, and C - formatConjunctionList(array) { - this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array); - }, + formatStringHelper(strings, key, args = {}) { + if (!strings) { + throw new Error(`Strings unavailable`); + } - // Disjunction lists: A, B, or C - formatDisjunctionList(array) { - this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array); - }, + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } - // Unit lists: A, B, C - formatUnitList(array) { - this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array); - }, + const template = strings[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_]+\}/)) { + throw new Error(`Args in ${key} were missing - output: ${output}`); + } - // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB - formatFileSize(bytes) { - if (!bytes) return ''; + return output; + }, - bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + formatDate(date) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.format(date); + }, - const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10; + formatDateRange(startDate, endDate) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.formatRange(startDate, endDate); + }, - if (bytes >= 10 ** 12) { - return this.formatString('count.fileSize.terabytes', {terabytes: round(12)}); - } else if (bytes >= 10 ** 9) { - return this.formatString('count.fileSize.gigabytes', {gigabytes: round(9)}); - } else if (bytes >= 10 ** 6) { - return this.formatString('count.fileSize.megabytes', {megabytes: round(6)}); - } else if (bytes >= 10 ** 3) { - return this.formatString('count.fileSize.kilobytes', {kilobytes: round(3)}); - } else { - return this.formatString('count.fileSize.bytes', {bytes}); - } - }, + formatDuration(secTotal, {approximate = false, unit = false} = {}) { + if (secTotal === 0) { + return this.formatString('count.duration.missing'); + } - // TODO: These are hard-coded. Is there a better way? - countAdditionalFiles: countHelper('additionalFiles', 'files'), - countAlbums: countHelper('albums'), - countCommentaryEntries: countHelper('commentaryEntries', 'entries'), - countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), - countTimesReferenced: countHelper('timesReferenced'), - countTimesUsed: countHelper('timesUsed'), - countTracks: countHelper('tracks'), + 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 + ? this.formatString('count.duration.hours' + stringSubkey, { + hours: hour, + minutes: pad(min), + seconds: pad(sec), + }) + : this.formatString('count.duration.minutes' + stringSubkey, { + minutes: min, + seconds: pad(sec), + }); + + return approximate + ? this.formatString('count.duration.approximate', {duration}) + : duration; + }, + + formatIndex(value) { + this.assertIntlAvailable('intl_pluralOrdinal'); + return this.formatString( + 'count.index.' + this.intl_pluralOrdinal.select(value), + { + index: value, + } + ); + }, + + formatNumber(value) { + this.assertIntlAvailable('intl_number'); + return this.intl_number.format(value); + }, + + formatWordCount(value) { + const num = this.formatNumber( + value > 1000 ? Math.floor(value / 100) / 10 : value + ); + + const words = + value > 1000 + ? this.formatString('count.words.thousand', {words: num}) + : this.formatString('count.words', {words: num}); + + return this.formatString( + 'count.words.withUnit.' + this.getUnitForm(value), + {words} + ); + }, + + // Conjunction list: A, B, and C + formatConjunctionList(array) { + this.assertIntlAvailable('intl_listConjunction'); + return this.intl_listConjunction.format(array); + }, + + // Disjunction lists: A, B, or C + formatDisjunctionList(array) { + this.assertIntlAvailable('intl_listDisjunction'); + return this.intl_listDisjunction.format(array); + }, + + // Unit lists: A, B, C + formatUnitList(array) { + this.assertIntlAvailable('intl_listUnit'); + return this.intl_listUnit.format(array); + }, + + // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB + formatFileSize(bytes) { + if (!bytes) return ''; + + bytes = parseInt(bytes); + if (isNaN(bytes)) return ''; + + const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; + + if (bytes >= 10 ** 12) { + return this.formatString('count.fileSize.terabytes', { + terabytes: round(12), + }); + } else if (bytes >= 10 ** 9) { + return this.formatString('count.fileSize.gigabytes', { + gigabytes: round(9), + }); + } else if (bytes >= 10 ** 6) { + return this.formatString('count.fileSize.megabytes', { + megabytes: round(6), + }); + } else if (bytes >= 10 ** 3) { + return this.formatString('count.fileSize.kilobytes', { + kilobytes: round(3), + }); + } else { + return this.formatString('count.fileSize.bytes', {bytes}); + } + }, + + // TODO: These are hard-coded. Is there a better way? + countAdditionalFiles: countHelper('additionalFiles', 'files'), + countAlbums: countHelper('albums'), + countCommentaryEntries: countHelper('commentaryEntries', 'entries'), + countContributions: countHelper('contributions'), + countCoverArts: countHelper('coverArts'), + countTimesReferenced: countHelper('timesReferenced'), + countTimesUsed: countHelper('timesUsed'), + countTracks: countHelper('tracks'), }); diff --git a/src/data/validators.js b/src/data/validators.js index 0d325aed..8d922399 100644 --- a/src/data/validators.js +++ b/src/data/validators.js @@ -1,367 +1,389 @@ -import { withAggregate } from '../util/sugar.js'; +/** @format */ -import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js'; +import {withAggregate} from '../util/sugar.js'; -import { inspect as nodeInspect } from 'util'; +import {color, ENABLE_COLOR} from '../util/cli.js'; + +import {inspect as nodeInspect} from 'util'; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } // Basic types (primitives) function a(noun) { - return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`); + return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; } function isType(value, type) { - if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); + if (typeof value !== type) + throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); - return true; + return true; } export function isBoolean(value) { - return isType(value, 'boolean'); + return isType(value, 'boolean'); } export function isNumber(value) { - return isType(value, 'number'); + return isType(value, 'number'); } export function isPositive(number) { - isNumber(number); + isNumber(number); - if (number <= 0) - throw new TypeError(`Expected positive number`); + if (number <= 0) throw new TypeError(`Expected positive number`); - return true; + return true; } export function isNegative(number) { - isNumber(number); + isNumber(number); - if (number >= 0) - throw new TypeError(`Expected negative number`); + if (number >= 0) throw new TypeError(`Expected negative number`); - return true; + return true; } export function isPositiveOrZero(number) { - isNumber(number); + isNumber(number); - if (number < 0) - throw new TypeError(`Expected positive number or zero`); + if (number < 0) throw new TypeError(`Expected positive number or zero`); - return true; + return true; } export function isNegativeOrZero(number) { - isNumber(number); + isNumber(number); - if (number > 0) - throw new TypeError(`Expected negative number or zero`); + if (number > 0) throw new TypeError(`Expected negative number or zero`); - return true; + return true; } export function isInteger(number) { - isNumber(number); + isNumber(number); - if (number % 1 !== 0) - throw new TypeError(`Expected integer`); + if (number % 1 !== 0) throw new TypeError(`Expected integer`); - return true; + return true; } export function isCountingNumber(number) { - isInteger(number); - isPositive(number); + isInteger(number); + isPositive(number); - return true; + return true; } export function isWholeNumber(number) { - isInteger(number); - isPositiveOrZero(number); + isInteger(number); + isPositiveOrZero(number); - return true; + return true; } export function isString(value) { - return isType(value, 'string'); + return isType(value, 'string'); } export function isStringNonEmpty(value) { - isString(value); + isString(value); - if (value.trim().length === 0) - throw new TypeError(`Expected non-empty string`); + if (value.trim().length === 0) + throw new TypeError(`Expected non-empty string`); - return true; + return true; } // Complex types (non-primitives) export function isInstance(value, constructor) { - isObject(value); + isObject(value); - if (!(value instanceof constructor)) - throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); + if (!(value instanceof constructor)) + throw new TypeError( + `Expected ${constructor.name}, got ${value.constructor.name}` + ); - return true; + return true; } export function isDate(value) { - return isInstance(value, Date); + return isInstance(value, Date); } export function isObject(value) { - isType(value, 'object'); + isType(value, 'object'); - // Note: Please remember that null is always a valid value for properties - // held by a CacheableObject. This assertion is exclusively for use in other - // contexts. - if (value === null) - throw new TypeError(`Expected an object, got null`); + // Note: Please remember that null is always a valid value for properties + // held by a CacheableObject. This assertion is exclusively for use in other + // contexts. + if (value === null) throw new TypeError(`Expected an object, got null`); - return true; + return true; } export function isArray(value) { - if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${value}`); + if (typeof value !== 'object' || value === null || !Array.isArray(value)) + throw new TypeError(`Expected an array, got ${value}`); - return true; + return true; } function validateArrayItemsHelper(itemValidator) { - return (item, index) => { - try { - const value = itemValidator(item); - - if (value !== true) { - throw new Error(`Expected validator to return true`); - } - } catch (error) { - error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; - throw error; - } - }; + return (item, index) => { + try { + const value = itemValidator(item); + + if (value !== true) { + throw new Error(`Expected validator to return true`); + } + } catch (error) { + error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${ + error.message + }`; + throw error; + } + }; } export function validateArrayItems(itemValidator) { - const fn = validateArrayItemsHelper(itemValidator); + const fn = validateArrayItemsHelper(itemValidator); - return array => { - isArray(array); + return (array) => { + isArray(array); - withAggregate({message: 'Errors validating array items'}, ({ wrap }) => { - array.forEach(wrap(fn)); - }); + withAggregate({message: 'Errors validating array items'}, ({wrap}) => { + array.forEach(wrap(fn)); + }); - return true; - }; + return true; + }; } export function validateInstanceOf(constructor) { - return object => isInstance(object, constructor); + return (object) => isInstance(object, constructor); } // Wiki data (primitives & non-primitives) export function isColor(color) { - isStringNonEmpty(color); + isStringNonEmpty(color); - if (color.startsWith('#')) { - if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length)) - throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); + if (color.startsWith('#')) { + if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length)) + throw new TypeError( + `Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}` + ); - if (/[^0-9a-fA-F]/.test(color.slice(1))) - throw new TypeError(`Expected hexadecimal digits`); + if (/[^0-9a-fA-F]/.test(color.slice(1))) + throw new TypeError(`Expected hexadecimal digits`); - return true; - } + return true; + } - throw new TypeError(`Unknown color format`); + throw new TypeError(`Unknown color format`); } export function isCommentary(commentary) { - return isString(commentary); + return isString(commentary); } const isArtistRef = validateReference('artist'); export function validateProperties(spec) { - const specEntries = Object.entries(spec); - const specKeys = Object.keys(spec); - - return object => { - isObject(object); - - if (Array.isArray(object)) - throw new TypeError(`Expected an object, got array`); - - withAggregate({message: `Errors validating object properties`}, ({ call }) => { - for (const [ specKey, specValidator ] of specEntries) { - call(() => { - const value = object[specKey]; - try { - specValidator(value); - } catch (error) { - error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; - throw error; - } - }); - } + const specEntries = Object.entries(spec); + const specKeys = Object.keys(spec); + + return (object) => { + isObject(object); + + if (Array.isArray(object)) + throw new TypeError(`Expected an object, got array`); - const unknownKeys = Object.keys(object).filter(key => !specKeys.includes(key)); - if (unknownKeys.length > 0) { - call(() => { - throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`); - }); + withAggregate( + {message: `Errors validating object properties`}, + ({call}) => { + for (const [specKey, specValidator] of specEntries) { + call(() => { + const value = object[specKey]; + try { + specValidator(value); + } catch (error) { + error.message = `(key: ${color.green(specKey)}, value: ${inspect( + value + )}) ${error.message}`; + throw error; } - }); + }); + } - return true; - }; -} + const unknownKeys = Object.keys(object).filter( + (key) => !specKeys.includes(key) + ); + if (unknownKeys.length > 0) { + call(() => { + throw new Error( + `Unknown keys present (${ + unknownKeys.length + }): [${unknownKeys.join(', ')}]` + ); + }); + } + } + ); + return true; + }; +} export const isContribution = validateProperties({ - who: isArtistRef, - what: value => value === undefined || value === null || isStringNonEmpty(value), + who: isArtistRef, + what: (value) => + value === undefined || value === null || isStringNonEmpty(value), }); export const isContributionList = validateArrayItems(isContribution); export const isAdditionalFile = validateProperties({ - title: isString, - description: value => (value === undefined || value === null || isString(value)), - files: validateArrayItems(isString) + title: isString, + description: (value) => + value === undefined || value === null || isString(value), + files: validateArrayItems(isString), }); export const isAdditionalFileList = validateArrayItems(isAdditionalFile); export function isDimensions(dimensions) { - isArray(dimensions); + isArray(dimensions); - if (dimensions.length !== 2) - throw new TypeError(`Expected 2 item array`); + if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); - isPositive(dimensions[0]); - isInteger(dimensions[0]); - isPositive(dimensions[1]); - isInteger(dimensions[1]); + isPositive(dimensions[0]); + isInteger(dimensions[0]); + isPositive(dimensions[1]); + isInteger(dimensions[1]); - return true; + return true; } export function isDirectory(directory) { - isStringNonEmpty(directory); + isStringNonEmpty(directory); - if (directory.match(/[^a-zA-Z0-9_\-]/)) - throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + if (directory.match(/[^a-zA-Z0-9_-]/)) + throw new TypeError( + `Expected only letters, numbers, dash, and underscore, got "${directory}"` + ); - return true; + return true; } export function isDuration(duration) { - isNumber(duration); - isPositiveOrZero(duration); + isNumber(duration); + isPositiveOrZero(duration); - return true; + return true; } export function isFileExtension(string) { - isStringNonEmpty(string); + isStringNonEmpty(string); - if (string[0] === '.') - throw new TypeError(`Expected no dot (.) at the start of file extension`); + if (string[0] === '.') + throw new TypeError(`Expected no dot (.) at the start of file extension`); - if (string.match(/[^a-zA-Z0-9_]/)) - throw new TypeError(`Expected only alphanumeric and underscore`); + if (string.match(/[^a-zA-Z0-9_]/)) + throw new TypeError(`Expected only alphanumeric and underscore`); - return true; + return true; } export function isLanguageCode(string) { - // TODO: This is a stub function because really we don't need a detailed - // is-language-code parser right now. + // TODO: This is a stub function because really we don't need a detailed + // is-language-code parser right now. - isString(string); + isString(string); - return true; + return true; } export function isName(name) { - return isString(name); + return isString(name); } export function isURL(string) { - isStringNonEmpty(string); + isStringNonEmpty(string); - new URL(string); + new URL(string); - return true; + return true; } export function validateReference(type = 'track') { - return ref => { - isStringNonEmpty(ref); + return (ref) => { + isStringNonEmpty(ref); - const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); + const match = ref + .trim() + .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); - if (!match) - throw new TypeError(`Malformed reference`); + if (!match) throw new TypeError(`Malformed reference`); - const { groups: { typePart, directoryPart } } = match; + const { + groups: {typePart, directoryPart}, + } = match; - if (typePart && typePart !== type) - throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`); + if (typePart && typePart !== type) + throw new TypeError( + `Expected ref to begin with "${type}:", got "${typePart}:"` + ); - if (typePart) - isDirectory(directoryPart); + if (typePart) isDirectory(directoryPart); - isName(ref); + isName(ref); - return true; - }; + return true; + }; } export function validateReferenceList(type = '') { - return validateArrayItems(validateReference(type)); + return validateArrayItems(validateReference(type)); } // Compositional utilities export function oneOf(...checks) { - return value => { - const errorMeta = []; + return (value) => { + const errorMeta = []; - for (let i = 0, check; check = checks[i]; i++) { - try { - const result = check(value); - - if (result !== true) { - throw new Error(`Check returned false`); - } + for (let i = 0, check; (check = checks[i]); i++) { + try { + const result = check(value); - return true; - } catch (error) { - errorMeta.push([check, i, error]); - } + if (result !== true) { + throw new Error(`Check returned false`); } - // Don't process error messages until every check has failed. - const errors = []; - for (const [ check, i, error ] of errorMeta) { - error.message = (check.name - ? `(#${i} "${check.name}") ${error.message}` - : `(#${i}) ${error.message}`); - error.check = check; - errors.push(error); - } - throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`); - }; + return true; + } catch (error) { + errorMeta.push([check, i, error]); + } + } + + // Don't process error messages until every check has failed. + const errors = []; + for (const [check, i, error] of errorMeta) { + error.message = check.name + ? `(#${i} "${check.name}") ${error.message}` + : `(#${i}) ${error.message}`; + error.check = check; + errors.push(error); + } + throw new AggregateError( + errors, + `Expected one of ${checks.length} possible checks, but none were true` + ); + }; } diff --git a/src/data/yaml.js b/src/data/yaml.js index 763dfd28..367876be 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1,59 +1,55 @@ +/** @format */ + // yaml.js - specification for HSMusic YAML data file format and utilities for // loading and processing YAML files and documents import * as path from 'path'; import yaml from 'js-yaml'; -import { readFile } from 'fs/promises'; -import { inspect as nodeInspect } from 'util'; +import {readFile} from 'fs/promises'; +import {inspect as nodeInspect} from 'util'; import { - Album, - Artist, - ArtTag, - Flash, - FlashAct, - Group, - GroupCategory, - HomepageLayout, - HomepageLayoutAlbumsRow, - HomepageLayoutRow, - NewsEntry, - StaticPage, - Thing, - Track, - TrackGroup, - WikiInfo, + Album, + Artist, + ArtTag, + Flash, + FlashAct, + Group, + GroupCategory, + HomepageLayout, + HomepageLayoutAlbumsRow, + NewsEntry, + StaticPage, + Thing, + Track, + TrackGroup, + WikiInfo, } from './things.js'; -import { - color, - ENABLE_COLOR, - logInfo, - logWarn, -} from '../util/cli.js'; +import {color, ENABLE_COLOR, logInfo, logWarn} from '../util/cli.js'; import { - decorateErrorWithIndex, - mapAggregate, - openAggregate, - showAggregate, - withAggregate, + decorateErrorWithIndex, + mapAggregate, + openAggregate, + showAggregate, + withAggregate, } from '../util/sugar.js'; import { - sortAlbumsTracksChronologically, - sortAlphabetically, - sortChronologically, + sortAlbumsTracksChronologically, + sortAlphabetically, + sortChronologically, } from '../util/wiki-data.js'; -import find, { bindFind } from '../util/find.js'; -import { findFiles } from '../util/io.js'; +import find, {bindFind} from '../util/find.js'; +import {findFiles} from '../util/io.js'; // --> General supporting stuff function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } // --> YAML data repository structure constants @@ -78,7 +74,9 @@ export const DATA_ALBUM_DIRECTORY = 'album'; // makeProcessDocument is a factory function: the returned function will take a // document and apply the configuration passed to makeProcessDocument in order // to construct a Thing subclass. -function makeProcessDocument(thingClass, { +function makeProcessDocument( + thingClass, + { // Optional early step for transforming field values before providing them // to the Thing's update() method. This is useful when the input format // (i.e. values in the document) differ from the format the actual Thing @@ -101,454 +99,479 @@ function makeProcessDocument(thingClass, { // they're present in a document, but they won't be used for Thing property // generation, either. Useful for stuff that's present in data files but not // yet implemented as part of a Thing's data model! - ignoredFields = [] -}) { - if (!propertyFieldMapping) { - throw new Error(`Expected propertyFieldMapping to be provided`); - } - - const knownFields = Object.values(propertyFieldMapping); - - // Invert the property-field mapping, since it'll come in handy for - // assigning update() source values later. - const fieldPropertyMapping = Object.fromEntries( - (Object.entries(propertyFieldMapping) - .map(([ property, field ]) => [field, property]))); - - const decorateErrorWithName = fn => { - const nameField = propertyFieldMapping['name']; - if (!nameField) return fn; - - return document => { - try { - return fn(document); - } catch (error) { - const name = document[nameField]; - error.message = (name - ? `(name: ${inspect(name)}) ${error.message}` - : `(${color.dim(`no name found`)}) ${error.message}`); - throw error; - } - }; + ignoredFields = [], + } +) { + if (!propertyFieldMapping) { + throw new Error(`Expected propertyFieldMapping to be provided`); + } + + const knownFields = Object.values(propertyFieldMapping); + + // Invert the property-field mapping, since it'll come in handy for + // assigning update() source values later. + const fieldPropertyMapping = Object.fromEntries( + Object.entries(propertyFieldMapping).map(([property, field]) => [ + field, + property, + ]) + ); + + const decorateErrorWithName = (fn) => { + const nameField = propertyFieldMapping['name']; + if (!nameField) return fn; + + return (document) => { + try { + return fn(document); + } catch (error) { + const name = document[nameField]; + error.message = name + ? `(name: ${inspect(name)}) ${error.message}` + : `(${color.dim(`no name found`)}) ${error.message}`; + throw error; + } }; + }; - return decorateErrorWithName(document => { - const documentEntries = Object.entries(document) - .filter(([ field ]) => !ignoredFields.includes(field)); + return decorateErrorWithName((document) => { + const documentEntries = Object.entries(document).filter( + ([field]) => !ignoredFields.includes(field) + ); - const unknownFields = documentEntries - .map(([ field ]) => field) - .filter(field => !knownFields.includes(field)); + const unknownFields = documentEntries + .map(([field]) => field) + .filter((field) => !knownFields.includes(field)); - if (unknownFields.length) { - throw new makeProcessDocument.UnknownFieldsError(unknownFields); - } + if (unknownFields.length) { + throw new makeProcessDocument.UnknownFieldsError(unknownFields); + } - const fieldValues = {}; + const fieldValues = {}; - for (const [ field, value ] of documentEntries) { - if (Object.hasOwn(fieldTransformations, field)) { - fieldValues[field] = fieldTransformations[field](value); - } else { - fieldValues[field] = value; - } - } + for (const [field, value] of documentEntries) { + if (Object.hasOwn(fieldTransformations, field)) { + fieldValues[field] = fieldTransformations[field](value); + } else { + fieldValues[field] = value; + } + } - const sourceProperties = {}; + const sourceProperties = {}; - for (const [ field, value ] of Object.entries(fieldValues)) { - const property = fieldPropertyMapping[field]; - sourceProperties[property] = value; - } + for (const [field, value] of Object.entries(fieldValues)) { + const property = fieldPropertyMapping[field]; + sourceProperties[property] = value; + } - const thing = Reflect.construct(thingClass, []); + const thing = Reflect.construct(thingClass, []); - withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => { - for (const [ property, value ] of Object.entries(sourceProperties)) { - call(() => (thing[property] = value)); - } - }); + withAggregate( + {message: `Errors applying ${color.green(thingClass.name)} properties`}, + ({call}) => { + for (const [property, value] of Object.entries(sourceProperties)) { + call(() => (thing[property] = value)); + } + } + ); - return thing; - }); + return thing; + }); } -makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error { - constructor(fields) { - super(`Unknown fields present: ${fields.join(', ')}`); - this.fields = fields; - } +makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends ( + Error +) { + constructor(fields) { + super(`Unknown fields present: ${fields.join(', ')}`); + this.fields = fields; + } }; export const processAlbumDocument = makeProcessDocument(Album, { - fieldTransformations: { - 'Artists': parseContributors, - 'Cover Artists': parseContributors, - 'Default Track Cover Artists': parseContributors, - 'Wallpaper Artists': parseContributors, - 'Banner Artists': parseContributors, - - 'Date': value => new Date(value), - 'Date Added': value => new Date(value), - 'Cover Art Date': value => new Date(value), - 'Default Track Cover Art Date': value => new Date(value), - - 'Banner Dimensions': parseDimensions, - - 'Additional Files': parseAdditionalFiles, - }, - - propertyFieldMapping: { - name: 'Album', - - color: 'Color', - directory: 'Directory', - urls: 'URLs', - - artistContribsByRef: 'Artists', - coverArtistContribsByRef: 'Cover Artists', - trackCoverArtistContribsByRef: 'Default Track Cover Artists', - - coverArtFileExtension: 'Cover Art File Extension', - trackCoverArtFileExtension: 'Track Art File Extension', - - wallpaperArtistContribsByRef: 'Wallpaper Artists', - wallpaperStyle: 'Wallpaper Style', - wallpaperFileExtension: 'Wallpaper File Extension', - - bannerArtistContribsByRef: 'Banner Artists', - bannerStyle: 'Banner Style', - bannerFileExtension: 'Banner File Extension', - bannerDimensions: 'Banner Dimensions', - - date: 'Date', - trackArtDate: 'Default Track Cover Art Date', - coverArtDate: 'Cover Art Date', - dateAddedToWiki: 'Date Added', - - hasCoverArt: 'Has Cover Art', - hasTrackArt: 'Has Track Art', - hasTrackNumbers: 'Has Track Numbers', - isMajorRelease: 'Major Release', - isListedOnHomepage: 'Listed on Homepage', - - groupsByRef: 'Groups', - artTagsByRef: 'Art Tags', - commentary: 'Commentary', - - additionalFiles: 'Additional Files', - } + fieldTransformations: { + Artists: parseContributors, + 'Cover Artists': parseContributors, + 'Default Track Cover Artists': parseContributors, + 'Wallpaper Artists': parseContributors, + 'Banner Artists': parseContributors, + + Date: (value) => new Date(value), + 'Date Added': (value) => new Date(value), + 'Cover Art Date': (value) => new Date(value), + 'Default Track Cover Art Date': (value) => new Date(value), + + 'Banner Dimensions': parseDimensions, + + 'Additional Files': parseAdditionalFiles, + }, + + propertyFieldMapping: { + name: 'Album', + + color: 'Color', + directory: 'Directory', + urls: 'URLs', + + artistContribsByRef: 'Artists', + coverArtistContribsByRef: 'Cover Artists', + trackCoverArtistContribsByRef: 'Default Track Cover Artists', + + coverArtFileExtension: 'Cover Art File Extension', + trackCoverArtFileExtension: 'Track Art File Extension', + + wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperStyle: 'Wallpaper Style', + wallpaperFileExtension: 'Wallpaper File Extension', + + bannerArtistContribsByRef: 'Banner Artists', + bannerStyle: 'Banner Style', + bannerFileExtension: 'Banner File Extension', + bannerDimensions: 'Banner Dimensions', + + date: 'Date', + trackArtDate: 'Default Track Cover Art Date', + coverArtDate: 'Cover Art Date', + dateAddedToWiki: 'Date Added', + + hasCoverArt: 'Has Cover Art', + hasTrackArt: 'Has Track Art', + hasTrackNumbers: 'Has Track Numbers', + isMajorRelease: 'Major Release', + isListedOnHomepage: 'Listed on Homepage', + + groupsByRef: 'Groups', + artTagsByRef: 'Art Tags', + commentary: 'Commentary', + + additionalFiles: 'Additional Files', + }, }); export const processTrackGroupDocument = makeProcessDocument(TrackGroup, { - fieldTransformations: { - 'Date Originally Released': value => new Date(value), - }, - - propertyFieldMapping: { - name: 'Group', - color: 'Color', - dateOriginallyReleased: 'Date Originally Released', - } + fieldTransformations: { + 'Date Originally Released': (value) => new Date(value), + }, + + propertyFieldMapping: { + name: 'Group', + color: 'Color', + dateOriginallyReleased: 'Date Originally Released', + }, }); export const processTrackDocument = makeProcessDocument(Track, { - fieldTransformations: { - 'Duration': getDurationInSeconds, + fieldTransformations: { + Duration: getDurationInSeconds, - 'Date First Released': value => new Date(value), - 'Cover Art Date': value => new Date(value), + 'Date First Released': (value) => new Date(value), + 'Cover Art Date': (value) => new Date(value), - 'Artists': parseContributors, - 'Contributors': parseContributors, - 'Cover Artists': parseContributors, + Artists: parseContributors, + Contributors: parseContributors, + 'Cover Artists': parseContributors, - 'Additional Files': parseAdditionalFiles, - }, + 'Additional Files': parseAdditionalFiles, + }, - propertyFieldMapping: { - name: 'Track', + propertyFieldMapping: { + name: 'Track', - directory: 'Directory', - duration: 'Duration', - urls: 'URLs', + directory: 'Directory', + duration: 'Duration', + urls: 'URLs', - coverArtDate: 'Cover Art Date', - coverArtFileExtension: 'Cover Art File Extension', - dateFirstReleased: 'Date First Released', - hasCoverArt: 'Has Cover Art', - hasURLs: 'Has URLs', + coverArtDate: 'Cover Art Date', + coverArtFileExtension: 'Cover Art File Extension', + dateFirstReleased: 'Date First Released', + hasCoverArt: 'Has Cover Art', + hasURLs: 'Has URLs', - referencedTracksByRef: 'Referenced Tracks', - artistContribsByRef: 'Artists', - contributorContribsByRef: 'Contributors', - coverArtistContribsByRef: 'Cover Artists', - artTagsByRef: 'Art Tags', - originalReleaseTrackByRef: 'Originally Released As', + referencedTracksByRef: 'Referenced Tracks', + artistContribsByRef: 'Artists', + contributorContribsByRef: 'Contributors', + coverArtistContribsByRef: 'Cover Artists', + artTagsByRef: 'Art Tags', + originalReleaseTrackByRef: 'Originally Released As', - commentary: 'Commentary', - lyrics: 'Lyrics', + commentary: 'Commentary', + lyrics: 'Lyrics', - additionalFiles: 'Additional Files', - }, + additionalFiles: 'Additional Files', + }, - ignoredFields: ['Sampled Tracks'] + ignoredFields: ['Sampled Tracks'], }); export const processArtistDocument = makeProcessDocument(Artist, { - propertyFieldMapping: { - name: 'Artist', + propertyFieldMapping: { + name: 'Artist', - directory: 'Directory', - urls: 'URLs', - hasAvatar: 'Has Avatar', - avatarFileExtension: 'Avatar File Extension', + directory: 'Directory', + urls: 'URLs', + hasAvatar: 'Has Avatar', + avatarFileExtension: 'Avatar File Extension', - aliasNames: 'Aliases', + aliasNames: 'Aliases', - contextNotes: 'Context Notes' - }, + contextNotes: 'Context Notes', + }, - ignoredFields: ['Dead URLs'] + ignoredFields: ['Dead URLs'], }); export const processFlashDocument = makeProcessDocument(Flash, { - fieldTransformations: { - 'Date': value => new Date(value), + fieldTransformations: { + Date: (value) => new Date(value), - 'Contributors': parseContributors, - }, + Contributors: parseContributors, + }, - propertyFieldMapping: { - name: 'Flash', + propertyFieldMapping: { + name: 'Flash', - directory: 'Directory', - page: 'Page', - date: 'Date', - coverArtFileExtension: 'Cover Art File Extension', + directory: 'Directory', + page: 'Page', + date: 'Date', + coverArtFileExtension: 'Cover Art File Extension', - featuredTracksByRef: 'Featured Tracks', - contributorContribsByRef: 'Contributors', - urls: 'URLs' - }, + featuredTracksByRef: 'Featured Tracks', + contributorContribsByRef: 'Contributors', + urls: 'URLs', + }, }); export const processFlashActDocument = makeProcessDocument(FlashAct, { - propertyFieldMapping: { - name: 'Act', - color: 'Color', - anchor: 'Anchor', - jump: 'Jump', - jumpColor: 'Jump Color' - } + propertyFieldMapping: { + name: 'Act', + color: 'Color', + anchor: 'Anchor', + jump: 'Jump', + jumpColor: 'Jump Color', + }, }); export const processNewsEntryDocument = makeProcessDocument(NewsEntry, { - fieldTransformations: { - 'Date': value => new Date(value) - }, - - propertyFieldMapping: { - name: 'Name', - directory: 'Directory', - date: 'Date', - content: 'Content', - } + fieldTransformations: { + Date: (value) => new Date(value), + }, + + propertyFieldMapping: { + name: 'Name', + directory: 'Directory', + date: 'Date', + content: 'Content', + }, }); export const processArtTagDocument = makeProcessDocument(ArtTag, { - propertyFieldMapping: { - name: 'Tag', - directory: 'Directory', - color: 'Color', - isContentWarning: 'Is CW' - } + propertyFieldMapping: { + name: 'Tag', + directory: 'Directory', + color: 'Color', + isContentWarning: 'Is CW', + }, }); export const processGroupDocument = makeProcessDocument(Group, { - propertyFieldMapping: { - name: 'Group', - directory: 'Directory', - description: 'Description', - urls: 'URLs', - } + propertyFieldMapping: { + name: 'Group', + directory: 'Directory', + description: 'Description', + urls: 'URLs', + }, }); export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, { - propertyFieldMapping: { - name: 'Category', - color: 'Color', - } + propertyFieldMapping: { + name: 'Category', + color: 'Color', + }, }); export const processStaticPageDocument = makeProcessDocument(StaticPage, { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - directory: 'Directory', + propertyFieldMapping: { + name: 'Name', + nameShort: 'Short Name', + directory: 'Directory', - content: 'Content', - stylesheet: 'Style', + content: 'Content', + stylesheet: 'Style', - showInNavigationBar: 'Show in Navigation Bar' - } + showInNavigationBar: 'Show in Navigation Bar', + }, }); export const processWikiInfoDocument = makeProcessDocument(WikiInfo, { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - color: 'Color', - description: 'Description', - footerContent: 'Footer Content', - defaultLanguage: 'Default Language', - canonicalBase: 'Canonical Base', - divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', - enableFlashesAndGames: 'Enable Flashes & Games', - enableListings: 'Enable Listings', - enableNews: 'Enable News', - enableArtTagUI: 'Enable Art Tag UI', - enableGroupUI: 'Enable Group UI', - } + propertyFieldMapping: { + name: 'Name', + nameShort: 'Short Name', + color: 'Color', + description: 'Description', + footerContent: 'Footer Content', + defaultLanguage: 'Default Language', + canonicalBase: 'Canonical Base', + divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', + enableFlashesAndGames: 'Enable Flashes & Games', + enableListings: 'Enable Listings', + enableNews: 'Enable News', + enableArtTagUI: 'Enable Art Tag UI', + enableGroupUI: 'Enable Group UI', + }, }); -export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, { +export const processHomepageLayoutDocument = makeProcessDocument( + HomepageLayout, + { propertyFieldMapping: { - sidebarContent: 'Sidebar Content' + sidebarContent: 'Sidebar Content', }, - ignoredFields: ['Homepage'] -}); + ignoredFields: ['Homepage'], + } +); export function makeProcessHomepageLayoutRowDocument(rowClass, spec) { - return makeProcessDocument(rowClass, { - ...spec, - - propertyFieldMapping: { - name: 'Row', - color: 'Color', - type: 'Type', - ...spec.propertyFieldMapping, - } - }); + return makeProcessDocument(rowClass, { + ...spec, + + propertyFieldMapping: { + name: 'Row', + color: 'Color', + type: 'Type', + ...spec.propertyFieldMapping, + }, + }); } export const homepageLayoutRowTypeProcessMapping = { - albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { - propertyFieldMapping: { - sourceGroupByRef: 'Group', - countAlbumsFromGroup: 'Count', - sourceAlbumsByRef: 'Albums', - actionLinks: 'Actions' - } - }) + albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { + propertyFieldMapping: { + sourceGroupByRef: 'Group', + countAlbumsFromGroup: 'Count', + sourceAlbumsByRef: 'Albums', + actionLinks: 'Actions', + }, + }), }; export function processHomepageLayoutRowDocument(document) { - const type = document['Type']; + const type = document['Type']; - const match = Object.entries(homepageLayoutRowTypeProcessMapping) - .find(([ key ]) => key === type); + const match = Object.entries(homepageLayoutRowTypeProcessMapping).find( + ([key]) => key === type + ); - if (!match) { - throw new TypeError(`No processDocument function for row type ${type}!`); - } + if (!match) { + throw new TypeError(`No processDocument function for row type ${type}!`); + } - return match[1](document); + return match[1](document); } // --> Utilities shared across document parsing functions export function getDurationInSeconds(string) { - if (typeof string === 'number') { - return string; - } - - if (typeof string !== 'string') { - throw new TypeError(`Expected a string or number, got ${string}`); - } - - const parts = string.split(':').map(n => parseInt(n)) - if (parts.length === 3) { - return parts[0] * 3600 + parts[1] * 60 + parts[2] - } else if (parts.length === 2) { - return parts[0] * 60 + parts[1] - } else { - return 0 - } + if (typeof string === 'number') { + return string; + } + + if (typeof string !== 'string') { + throw new TypeError(`Expected a string or number, got ${string}`); + } + + const parts = string.split(':').map((n) => parseInt(n)); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } else { + return 0; + } } export function parseAdditionalFiles(array) { - if (!array) return null; - if (!Array.isArray(array)) { - // Error will be caught when validating against whatever this value is - return array; - } - - return array.map(item => ({ - title: item['Title'], - description: item['Description'] ?? null, - files: item['Files'] - })); + if (!array) return null; + if (!Array.isArray(array)) { + // Error will be caught when validating against whatever this value is + return array; + } + + return array.map((item) => ({ + title: item['Title'], + description: item['Description'] ?? null, + files: item['Files'], + })); } export function parseCommentary(text) { - if (text) { - const lines = String(text).split('\n'); - if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { - return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`}; - } - return text; - } else { - return null; + if (text) { + const lines = String(text).split('\n'); + if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { + return { + error: `An entry is missing commentary citation: "${lines[0].slice( + 0, + 40 + )}..."`, + }; } + return text; + } else { + return null; + } } export function parseContributors(contributors) { - if (!contributors) { - return null; - } - - if (contributors.length === 1 && contributors[0].startsWith('<i>')) { - const arr = []; - arr.textContent = contributors[0]; - return arr; + if (!contributors) { + return null; + } + + if (contributors.length === 1 && contributors[0].startsWith('<i>')) { + const arr = []; + arr.textContent = contributors[0]; + return arr; + } + + contributors = contributors.map((contrib) => { + // 8asically, the format is "Who (What)", or just "Who". 8e sure to + // keep in mind that "what" doesn't necessarily have a value! + const match = contrib.match(/^(.*?)( \((.*)\))?$/); + if (!match) { + return contrib; } + const who = match[1]; + const what = match[3] || null; + return {who, what}; + }); - contributors = contributors.map(contrib => { - // 8asically, the format is "Who (What)", or just "Who". 8e sure to - // keep in mind that "what" doesn't necessarily have a value! - const match = contrib.match(/^(.*?)( \((.*)\))?$/); - if (!match) { - return contrib; - } - const who = match[1]; - const what = match[3] || null; - return {who, what}; - }); - - const badContributor = contributors.find(val => typeof val === 'string'); - if (badContributor) { - return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`}; - } + const badContributor = contributors.find((val) => typeof val === 'string'); + if (badContributor) { + return { + error: `An entry has an incorrectly formatted contributor, "${badContributor}".`, + }; + } - if (contributors.length === 1 && contributors[0].who === 'none') { - return null; - } + if (contributors.length === 1 && contributors[0].who === 'none') { + return null; + } - return contributors; + return contributors; } function parseDimensions(string) { - if (!string) { - return null; - } - - const parts = string.split(/[x,* ]+/g); - if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`); - const nums = parts.map(part => Number(part.trim())); - if (nums.includes(NaN)) throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`); - return nums; + if (!string) { + return null; + } + + const parts = string.split(/[x,* ]+/g); + if (parts.length !== 2) + throw new Error(`Invalid dimensions: ${string} (expected width & height)`); + const nums = parts.map((part) => Number(part.trim())); + if (nums.includes(NaN)) + throw new Error( + `Invalid dimensions: ${string} (couldn't parse as numbers)` + ); + return nums; } // --> Data repository loading functions and descriptors @@ -556,41 +579,41 @@ function parseDimensions(string) { // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { - // onePerFile: One document per file. Expects files array (or function) and - // processDocument function. Obviously, each specified data file should only - // contain one YAML document (an error will be thrown otherwise). Calls save - // with an array of processed documents (wiki objects). - onePerFile: Symbol('Document mode: onePerFile'), - - // headerAndEntries: One or more documents per file; the first document is - // treated as a "header" and represents data which pertains to all following - // "entry" documents. Expects files array (or function) and - // processHeaderDocument and processEntryDocument functions. Calls save with - // an array of {header, entries} objects. - // - // Please note that the final results loaded from each file may be "missing" - // data objects corresponding to entry documents if the processEntryDocument - // function throws on any entries, resulting in partial data provided to - // save() - errors will be caught and thrown in the final buildSteps - // aggregate. However, if the processHeaderDocument function fails, all - // following documents in the same file will be ignored as well (i.e. an - // entire file will be excempt from the save() function's input). - headerAndEntries: Symbol('Document mode: headerAndEntries'), - - // allInOne: One or more documents, all contained in one file. Expects file - // string (or function) and processDocument function. Calls save with an - // array of processed documents (wiki objects). - allInOne: Symbol('Document mode: allInOne'), - - // oneDocumentTotal: Just a single document, represented in one file. - // Expects file string (or function) and processDocument function. Calls - // save with the single processed wiki document (data object). - // - // Please note that if the single document fails to process, the save() - // function won't be called at all, generally resulting in an altogether - // missing property from the global wikiData object. This should be caught - // and handled externally. - oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'), + // onePerFile: One document per file. Expects files array (or function) and + // processDocument function. Obviously, each specified data file should only + // contain one YAML document (an error will be thrown otherwise). Calls save + // with an array of processed documents (wiki objects). + onePerFile: Symbol('Document mode: onePerFile'), + + // headerAndEntries: One or more documents per file; the first document is + // treated as a "header" and represents data which pertains to all following + // "entry" documents. Expects files array (or function) and + // processHeaderDocument and processEntryDocument functions. Calls save with + // an array of {header, entries} objects. + // + // Please note that the final results loaded from each file may be "missing" + // data objects corresponding to entry documents if the processEntryDocument + // function throws on any entries, resulting in partial data provided to + // save() - errors will be caught and thrown in the final buildSteps + // aggregate. However, if the processHeaderDocument function fails, all + // following documents in the same file will be ignored as well (i.e. an + // entire file will be excempt from the save() function's input). + headerAndEntries: Symbol('Document mode: headerAndEntries'), + + // allInOne: One or more documents, all contained in one file. Expects file + // string (or function) and processDocument function. Calls save with an + // array of processed documents (wiki objects). + allInOne: Symbol('Document mode: allInOne'), + + // oneDocumentTotal: Just a single document, represented in one file. + // Expects file string (or function) and processDocument function. Calls + // save with the single processed wiki document (data object). + // + // Please note that if the single document fails to process, the save() + // function won't be called at all, generally resulting in an altogether + // missing property from the global wikiData object. This should be caught + // and handled externally. + oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'), }; // dataSteps: Top-level array of "steps" for loading YAML document files. @@ -626,499 +649,559 @@ export const documentModes = { // format depends on documentMode. // export const dataSteps = [ - { - title: `Process wiki info file`, - file: WIKI_INFO_FILE, + { + title: `Process wiki info file`, + file: WIKI_INFO_FILE, - documentMode: documentModes.oneDocumentTotal, - processDocument: processWikiInfoDocument, + documentMode: documentModes.oneDocumentTotal, + processDocument: processWikiInfoDocument, - save(wikiInfo) { - if (!wikiInfo) { - return; - } + save(wikiInfo) { + if (!wikiInfo) { + return; + } - return {wikiInfo}; - } + return {wikiInfo}; + }, + }, + + { + title: `Process album files`, + files: async (dataPath) => + ( + await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), { + filter: (f) => path.extname(f) === '.yaml', + joinParentDirectory: false, + }) + ).map((file) => path.join(DATA_ALBUM_DIRECTORY, file)), + + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processAlbumDocument, + processEntryDocument(document) { + return 'Group' in document + ? processTrackGroupDocument(document) + : processTrackDocument(document); }, - { - title: `Process album files`, - files: async dataPath => ( - (await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), { - filter: f => path.extname(f) === '.yaml', - joinParentDirectory: false - })).map(file => path.join(DATA_ALBUM_DIRECTORY, file))), - - documentMode: documentModes.headerAndEntries, - processHeaderDocument: processAlbumDocument, - processEntryDocument(document) { - return ('Group' in document - ? processTrackGroupDocument(document) - : processTrackDocument(document)); - }, - - save(results) { - const albumData = []; - const trackData = []; - - for (const { header: album, entries } of results) { - // We can't mutate an array once it's set as a property - // value, so prepare the tracks and track groups that will - // show up in a track list all the way before actually - // applying them. - const trackGroups = []; - let currentTracksByRef = null; - let currentTrackGroup = null; - - const albumRef = Thing.getReference(album); - - function closeCurrentTrackGroup() { - if (currentTracksByRef) { - let trackGroup; - - if (currentTrackGroup) { - trackGroup = currentTrackGroup; - } else { - trackGroup = new TrackGroup(); - trackGroup.name = `Default Track Group`; - trackGroup.isDefaultTrackGroup = true; - } - - trackGroup.album = album; - trackGroup.tracksByRef = currentTracksByRef; - trackGroups.push(trackGroup); - } - } + save(results) { + const albumData = []; + const trackData = []; - for (const entry of entries) { - if (entry instanceof TrackGroup) { - closeCurrentTrackGroup(); - currentTracksByRef = []; - currentTrackGroup = entry; - continue; - } + for (const {header: album, entries} of results) { + // We can't mutate an array once it's set as a property + // value, so prepare the tracks and track groups that will + // show up in a track list all the way before actually + // applying them. + const trackGroups = []; + let currentTracksByRef = null; + let currentTrackGroup = null; - trackData.push(entry); + const albumRef = Thing.getReference(album); - entry.dataSourceAlbumByRef = albumRef; + const closeCurrentTrackGroup = () => { + if (currentTracksByRef) { + let trackGroup; - const trackRef = Thing.getReference(entry); - if (currentTracksByRef) { - currentTracksByRef.push(trackRef); - } else { - currentTracksByRef = [trackRef]; - } - } + if (currentTrackGroup) { + trackGroup = currentTrackGroup; + } else { + trackGroup = new TrackGroup(); + trackGroup.name = `Default Track Group`; + trackGroup.isDefaultTrackGroup = true; + } - closeCurrentTrackGroup(); + trackGroup.album = album; + trackGroup.tracksByRef = currentTracksByRef; + trackGroups.push(trackGroup); + } + }; - album.trackGroups = trackGroups; - albumData.push(album); - } + for (const entry of entries) { + if (entry instanceof TrackGroup) { + closeCurrentTrackGroup(); + currentTracksByRef = []; + currentTrackGroup = entry; + continue; + } - return {albumData, trackData}; - } - }, + trackData.push(entry); - { - title: `Process artists file`, - file: ARTIST_DATA_FILE, - - documentMode: documentModes.allInOne, - processDocument: processArtistDocument, - - save(results) { - const artistData = results; - - const artistAliasData = results.flatMap(artist => { - const origRef = Thing.getReference(artist); - return (artist.aliasNames?.map(name => { - const alias = new Artist(); - alias.name = name; - alias.isAlias = true; - alias.aliasedArtistRef = origRef; - alias.artistData = artistData; - return alias; - }) ?? []); - }); + entry.dataSourceAlbumByRef = albumRef; - return {artistData, artistAliasData}; + const trackRef = Thing.getReference(entry); + if (currentTracksByRef) { + currentTracksByRef.push(trackRef); + } else { + currentTracksByRef = [trackRef]; + } } - }, - // TODO: WD.wikiInfo.enableFlashesAndGames && - { - title: `Process flashes file`, - file: FLASH_DATA_FILE, + closeCurrentTrackGroup(); - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Act' in document - ? processFlashActDocument(document) - : processFlashDocument(document)); - }, + album.trackGroups = trackGroups; + albumData.push(album); + } - save(results) { - let flashAct; - let flashesByRef = []; + return {albumData, trackData}; + }, + }, + + { + title: `Process artists file`, + file: ARTIST_DATA_FILE, + + documentMode: documentModes.allInOne, + processDocument: processArtistDocument, + + save(results) { + const artistData = results; + + const artistAliasData = results.flatMap((artist) => { + const origRef = Thing.getReference(artist); + return ( + artist.aliasNames?.map((name) => { + const alias = new Artist(); + alias.name = name; + alias.isAlias = true; + alias.aliasedArtistRef = origRef; + alias.artistData = artistData; + return alias; + }) ?? [] + ); + }); + + return {artistData, artistAliasData}; + }, + }, + + // TODO: WD.wikiInfo.enableFlashesAndGames && + { + title: `Process flashes file`, + file: FLASH_DATA_FILE, + + documentMode: documentModes.allInOne, + processDocument(document) { + return 'Act' in document + ? processFlashActDocument(document) + : processFlashDocument(document); + }, - if (results[0] && !(results[0] instanceof FlashAct)) { - throw new Error(`Expected an act at top of flash data file`); - } + save(results) { + let flashAct; + let flashesByRef = []; - for (const thing of results) { - if (thing instanceof FlashAct) { - if (flashAct) { - Object.assign(flashAct, {flashesByRef}); - } + if (results[0] && !(results[0] instanceof FlashAct)) { + throw new Error(`Expected an act at top of flash data file`); + } - flashAct = thing; - flashesByRef = []; - } else { - flashesByRef.push(Thing.getReference(thing)); - } - } + for (const thing of results) { + if (thing instanceof FlashAct) { + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - if (flashAct) { - Object.assign(flashAct, {flashesByRef}); - } + flashAct = thing; + flashesByRef = []; + } else { + flashesByRef.push(Thing.getReference(thing)); + } + } - const flashData = results.filter(x => x instanceof Flash); - const flashActData = results.filter(x => x instanceof FlashAct); + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - return {flashData, flashActData}; - } + const flashData = results.filter((x) => x instanceof Flash); + const flashActData = results.filter((x) => x instanceof FlashAct); + + return {flashData, flashActData}; }, + }, - { - title: `Process groups file`, - file: GROUP_DATA_FILE, + { + title: `Process groups file`, + file: GROUP_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Category' in document - ? processGroupCategoryDocument(document) - : processGroupDocument(document)); - }, + documentMode: documentModes.allInOne, + processDocument(document) { + return 'Category' in document + ? processGroupCategoryDocument(document) + : processGroupDocument(document); + }, - save(results) { - let groupCategory; - let groupsByRef = []; + save(results) { + let groupCategory; + let groupsByRef = []; - if (results[0] && !(results[0] instanceof GroupCategory)) { - throw new Error(`Expected a category at top of group data file`); - } + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } - for (const thing of results) { - if (thing instanceof GroupCategory) { - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } - groupCategory = thing; - groupsByRef = []; - } else { - groupsByRef.push(Thing.getReference(thing)); - } - } + groupCategory = thing; + groupsByRef = []; + } else { + groupsByRef.push(Thing.getReference(thing)); + } + } - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } - const groupData = results.filter(x => x instanceof Group); - const groupCategoryData = results.filter(x => x instanceof GroupCategory); + const groupData = results.filter((x) => x instanceof Group); + const groupCategoryData = results.filter( + (x) => x instanceof GroupCategory + ); - return {groupData, groupCategoryData}; - } + return {groupData, groupCategoryData}; }, + }, - { - title: `Process homepage layout file`, - files: [HOMEPAGE_LAYOUT_DATA_FILE], + { + title: `Process homepage layout file`, + files: [HOMEPAGE_LAYOUT_DATA_FILE], - documentMode: documentModes.headerAndEntries, - processHeaderDocument: processHomepageLayoutDocument, - processEntryDocument: processHomepageLayoutRowDocument, + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processHomepageLayoutDocument, + processEntryDocument: processHomepageLayoutRowDocument, - save(results) { - if (!results[0]) { - return; - } + save(results) { + if (!results[0]) { + return; + } - const { header: homepageLayout, entries: rows } = results[0]; - Object.assign(homepageLayout, {rows}); - return {homepageLayout}; - } + const {header: homepageLayout, entries: rows} = results[0]; + Object.assign(homepageLayout, {rows}); + return {homepageLayout}; }, + }, - // TODO: WD.wikiInfo.enableNews && - { - title: `Process news data file`, - file: NEWS_DATA_FILE, + // TODO: WD.wikiInfo.enableNews && + { + title: `Process news data file`, + file: NEWS_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processNewsEntryDocument, + documentMode: documentModes.allInOne, + processDocument: processNewsEntryDocument, - save(newsData) { - sortChronologically(newsData); - newsData.reverse(); + save(newsData) { + sortChronologically(newsData); + newsData.reverse(); - return {newsData}; - } + return {newsData}; }, + }, - { - title: `Process art tags file`, - file: ART_TAG_DATA_FILE, + { + title: `Process art tags file`, + file: ART_TAG_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processArtTagDocument, + documentMode: documentModes.allInOne, + processDocument: processArtTagDocument, - save(artTagData) { - sortAlphabetically(artTagData); + save(artTagData) { + sortAlphabetically(artTagData); - return {artTagData}; - } + return {artTagData}; }, + }, - { - title: `Process static pages file`, - file: STATIC_PAGE_DATA_FILE, + { + title: `Process static pages file`, + file: STATIC_PAGE_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processStaticPageDocument, + documentMode: documentModes.allInOne, + processDocument: processStaticPageDocument, - save(staticPageData) { - return {staticPageData}; - } + save(staticPageData) { + return {staticPageData}; }, + }, ]; -export async function loadAndProcessDataDocuments({ - dataPath, -}) { - const processDataAggregate = openAggregate({message: `Errors processing data files`}); - const wikiDataResult = {}; - - function decorateErrorWithFile(fn) { - return (x, index, array) => { - try { - return fn(x, index, array); - } catch (error) { - error.message += ( - (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})` - ); - throw error; - } - }; - } +export async function loadAndProcessDataDocuments({dataPath}) { + const processDataAggregate = openAggregate({ + message: `Errors processing data files`, + }); + const wikiDataResult = {}; + + function decorateErrorWithFile(fn) { + return (x, index, array) => { + try { + return fn(x, index, array); + } catch (error) { + error.message += + (error.message.includes('\n') ? '\n' : ' ') + + `(file: ${color.bright( + color.blue(path.relative(dataPath, x.file)) + )})`; + throw error; + } + }; + } - for (const dataStep of dataSteps) { - await processDataAggregate.nestAsync( - {message: `Errors during data step: ${dataStep.title}`}, - async ({call, callAsync, map, mapAsync, nest}) => { - const { documentMode } = dataStep; + for (const dataStep of dataSteps) { + await processDataAggregate.nestAsync( + {message: `Errors during data step: ${dataStep.title}`}, + async ({call, callAsync, map, mapAsync, nest}) => { + const {documentMode} = dataStep; - if (!(Object.values(documentModes).includes(documentMode))) { - throw new Error(`Invalid documentMode: ${documentMode.toString()}`); - } + if (!Object.values(documentModes).includes(documentMode)) { + throw new Error(`Invalid documentMode: ${documentMode.toString()}`); + } - if (documentMode === documentModes.allInOne || documentMode === documentModes.oneDocumentTotal) { - if (!dataStep.file) { - throw new Error(`Expected 'file' property for ${documentMode.toString()}`); - } + if ( + documentMode === documentModes.allInOne || + documentMode === documentModes.oneDocumentTotal + ) { + if (!dataStep.file) { + throw new Error( + `Expected 'file' property for ${documentMode.toString()}` + ); + } + + const file = path.join( + dataPath, + typeof dataStep.file === 'function' + ? await callAsync(dataStep.file, dataPath) + : dataStep.file + ); - const file = path.join(dataPath, - (typeof dataStep.file === 'function' - ? await callAsync(dataStep.file, dataPath) - : dataStep.file)); + const readResult = await callAsync(readFile, file, 'utf-8'); - const readResult = await callAsync(readFile, file, 'utf-8'); + if (!readResult) { + return; + } - if (!readResult) { - return; - } + const yamlResult = + documentMode === documentModes.oneDocumentTotal + ? call(yaml.load, readResult) + : call(yaml.loadAll, readResult); - const yamlResult = (documentMode === documentModes.oneDocumentTotal - ? call(yaml.load, readResult) - : call(yaml.loadAll, readResult)); + if (!yamlResult) { + return; + } - if (!yamlResult) { - return; - } + let processResults; - let processResults; - - if (documentMode === documentModes.oneDocumentTotal) { - nest({message: `Errors processing document`}, ({ call }) => { - processResults = call(dataStep.processDocument, yamlResult); - }); - } else { - const { result, aggregate } = mapAggregate( - yamlResult, - decorateErrorWithIndex(dataStep.processDocument), - {message: `Errors processing documents`} - ); - processResults = result; - call(aggregate.close); - } + if (documentMode === documentModes.oneDocumentTotal) { + nest({message: `Errors processing document`}, ({call}) => { + processResults = call(dataStep.processDocument, yamlResult); + }); + } else { + const {result, aggregate} = mapAggregate( + yamlResult, + decorateErrorWithIndex(dataStep.processDocument), + {message: `Errors processing documents`} + ); + processResults = result; + call(aggregate.close); + } - if (!processResults) return; + if (!processResults) return; - const saveResult = call(dataStep.save, processResults); + const saveResult = call(dataStep.save, processResults); - if (!saveResult) return; + if (!saveResult) return; - Object.assign(wikiDataResult, saveResult); + Object.assign(wikiDataResult, saveResult); - return; - } + return; + } + + if (!dataStep.files) { + throw new Error( + `Expected 'files' property for ${documentMode.toString()}` + ); + } - if (!dataStep.files) { - throw new Error(`Expected 'files' property for ${documentMode.toString()}`); + const files = ( + typeof dataStep.files === 'function' + ? await callAsync(dataStep.files, dataPath) + : dataStep.files + ).map((file) => path.join(dataPath, file)); + + const readResults = await mapAsync( + files, + (file) => + readFile(file, 'utf-8').then((contents) => ({file, contents})), + {message: `Errors reading data files`} + ); + + const yamlResults = map( + readResults, + decorateErrorWithFile(({file, contents}) => ({ + file, + documents: yaml.loadAll(contents), + })), + {message: `Errors parsing data files as valid YAML`} + ); + + let processResults; + + if (documentMode === documentModes.headerAndEntries) { + nest( + {message: `Errors processing data files as valid documents`}, + ({call, map}) => { + processResults = []; + + yamlResults.forEach(({file, documents}) => { + const [headerDocument, ...entryDocuments] = documents; + + const header = call( + decorateErrorWithFile(({document}) => + dataStep.processHeaderDocument(document) + ), + {file, document: headerDocument} + ); + + // Don't continue processing files whose header + // document is invalid - the entire file is excempt + // from data in this case. + if (!header) { + return; } - const files = ( - (typeof dataStep.files === 'function' - ? await callAsync(dataStep.files, dataPath) - : dataStep.files) - .map(file => path.join(dataPath, file))); - - const readResults = await mapAsync( - files, - file => (readFile(file, 'utf-8') - .then(contents => ({file, contents}))), - {message: `Errors reading data files`}); - - const yamlResults = map( - readResults, - decorateErrorWithFile( - ({ file, contents }) => ({file, documents: yaml.loadAll(contents)})), - {message: `Errors parsing data files as valid YAML`}); - - let processResults; - - if (documentMode === documentModes.headerAndEntries) { - nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => { - processResults = []; - - yamlResults.forEach(({ file, documents }) => { - const [ headerDocument, ...entryDocuments ] = documents; - - const header = call( - decorateErrorWithFile( - ({ document }) => dataStep.processHeaderDocument(document)), - {file, document: headerDocument}); - - // Don't continue processing files whose header - // document is invalid - the entire file is excempt - // from data in this case. - if (!header) { - return; - } - - const entries = map( - entryDocuments.map(document => ({file, document})), - decorateErrorWithFile( - decorateErrorWithIndex( - ({ document }) => dataStep.processEntryDocument(document))), - {message: `Errors processing entry documents`}); - - // Entries may be incomplete (i.e. any errored - // documents won't have a processed output - // represented here) - this is intentional! By - // principle, partial output is preferred over - // erroring an entire file. - processResults.push({header, entries}); - }); - }); + const entries = map( + entryDocuments.map((document) => ({file, document})), + decorateErrorWithFile( + decorateErrorWithIndex(({document}) => + dataStep.processEntryDocument(document) + ) + ), + {message: `Errors processing entry documents`} + ); + + // Entries may be incomplete (i.e. any errored + // documents won't have a processed output + // represented here) - this is intentional! By + // principle, partial output is preferred over + // erroring an entire file. + processResults.push({header, entries}); + }); + } + ); + } + + if (documentMode === documentModes.onePerFile) { + nest( + {message: `Errors processing data files as valid documents`}, + ({call}) => { + processResults = []; + + yamlResults.forEach(({file, documents}) => { + if (documents.length > 1) { + call( + decorateErrorWithFile(() => { + throw new Error( + `Only expected one document to be present per file` + ); + }) + ); + return; } - if (documentMode === documentModes.onePerFile) { - nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => { - processResults = []; - - yamlResults.forEach(({ file, documents }) => { - if (documents.length > 1) { - call(decorateErrorWithFile(() => { - throw new Error(`Only expected one document to be present per file`); - })); - return; - } - - const result = call( - decorateErrorWithFile( - ({ document }) => dataStep.processDocument(document)), - {file, document: documents[0]}); - - if (!result) { - return; - } - - processResults.push(result); - }); - }); + const result = call( + decorateErrorWithFile(({document}) => + dataStep.processDocument(document) + ), + {file, document: documents[0]} + ); + + if (!result) { + return; } - const saveResult = call(dataStep.save, processResults); + processResults.push(result); + }); + } + ); + } - if (!saveResult) return; + const saveResult = call(dataStep.save, processResults); - Object.assign(wikiDataResult, saveResult); - }); - } + if (!saveResult) return; - return { - aggregate: processDataAggregate, - result: wikiDataResult - }; + Object.assign(wikiDataResult, saveResult); + } + ); + } + + return { + aggregate: processDataAggregate, + result: wikiDataResult, + }; } // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many // of which are required for page HTML generation). export function linkWikiDataArrays(wikiData) { - function assignWikiData(things, ...keys) { - for (let i = 0; i < things.length; i++) { - for (let j = 0; j < keys.length; j++) { - const key = keys[j]; - things[i][key] = wikiData[key]; - } - } + function assignWikiData(things, ...keys) { + for (let i = 0; i < things.length; i++) { + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + things[i][key] = wikiData[key]; + } } - - const WD = wikiData; - - assignWikiData([WD.wikiInfo], 'groupData'); - - assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData'); - WD.albumData.forEach(album => assignWikiData(album.trackGroups, 'trackData')); - - assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData'); - assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData'); - assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); - assignWikiData(WD.groupCategoryData, 'groupData'); - assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); - assignWikiData(WD.flashActData, 'flashData'); - assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + } + + const WD = wikiData; + + assignWikiData([WD.wikiInfo], 'groupData'); + + assignWikiData( + WD.albumData, + 'artistData', + 'artTagData', + 'groupData', + 'trackData' + ); + WD.albumData.forEach((album) => + assignWikiData(album.trackGroups, 'trackData') + ); + + assignWikiData( + WD.trackData, + 'albumData', + 'artistData', + 'artTagData', + 'flashData', + 'trackData' + ); + assignWikiData( + WD.artistData, + 'albumData', + 'artistData', + 'flashData', + 'trackData' + ); + assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); + assignWikiData(WD.groupCategoryData, 'groupData'); + assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); + assignWikiData(WD.flashActData, 'flashData'); + assignWikiData(WD.artTagData, 'albumData', 'trackData'); + assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { - Object.assign(wikiData, { - albumData: sortChronologically(wikiData.albumData.slice()), - trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()), - }); - - // Re-link data arrays, so that every object has the new, sorted versions. - // Note that the sorting step deliberately creates new arrays (mutating - // slices instead of the original arrays) - this is so that the object - // caching system understands that it's working with a new ordering. - // We still need to actually provide those updated arrays over again! - linkWikiDataArrays(wikiData); + Object.assign(wikiData, { + albumData: sortChronologically(wikiData.albumData.slice()), + trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()), + }); + + // Re-link data arrays, so that every object has the new, sorted versions. + // Note that the sorting step deliberately creates new arrays (mutating + // slices instead of the original arrays) - this is so that the object + // caching system understands that it's working with a new ordering. + // We still need to actually provide those updated arrays over again! + linkWikiDataArrays(wikiData); } // Warn about directories which are reused across more than one of the same type @@ -1128,63 +1211,76 @@ export function sortWikiDataArrays(wikiData) { // two tracks share the directory "megalovania", they'll both be skipped for the // build, for example). export function filterDuplicateDirectories(wikiData) { - const deduplicateSpec = [ - 'albumData', - 'artTagData', - 'flashData', - 'groupData', - 'newsData', - 'trackData', - ]; - - const aggregate = openAggregate({message: `Duplicate directories found`}); - for (const thingDataProp of deduplicateSpec) { - const thingData = wikiData[thingDataProp]; - aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({ call }) => { - const directoryPlaces = Object.create(null); - const duplicateDirectories = []; - for (const thing of thingData) { - const { directory } = thing; - if (directory in directoryPlaces) { - directoryPlaces[directory].push(thing); - duplicateDirectories.push(directory); - } else { - directoryPlaces[directory] = [thing]; - } - } - if (!duplicateDirectories.length) return; - duplicateDirectories.sort((a, b) => { - const aL = a.toLowerCase(); - const bL = b.toLowerCase(); - return aL < bL ? -1 : aL > bL ? 1 : 0; - }); - for (const directory of duplicateDirectories) { - const places = directoryPlaces[directory]; - call(() => { - throw new Error(`Duplicate directory ${color.green(directory)}:\n` + - places.map(thing => ` - ` + inspect(thing)).join('\n')); - }); - } - const allDuplicatedThings = Object.values(directoryPlaces).filter(arr => arr.length > 1).flat(); - const filteredThings = thingData.filter(thing => !allDuplicatedThings.includes(thing)); - wikiData[thingDataProp] = filteredThings; + const deduplicateSpec = [ + 'albumData', + 'artTagData', + 'flashData', + 'groupData', + 'newsData', + 'trackData', + ]; + + const aggregate = openAggregate({message: `Duplicate directories found`}); + for (const thingDataProp of deduplicateSpec) { + const thingData = wikiData[thingDataProp]; + aggregate.nest( + { + message: `Duplicate directories found in ${color.green( + 'wikiData.' + thingDataProp + )}`, + }, + ({call}) => { + const directoryPlaces = Object.create(null); + const duplicateDirectories = []; + for (const thing of thingData) { + const {directory} = thing; + if (directory in directoryPlaces) { + directoryPlaces[directory].push(thing); + duplicateDirectories.push(directory); + } else { + directoryPlaces[directory] = [thing]; + } + } + if (!duplicateDirectories.length) return; + duplicateDirectories.sort((a, b) => { + const aL = a.toLowerCase(); + const bL = b.toLowerCase(); + return aL < bL ? -1 : aL > bL ? 1 : 0; }); - } - - // TODO: This code closes the aggregate but it generally gets closed again - // by the caller. This works but it might be weird to assume closing an - // aggregate twice is okay, maybe there's a better solution? Expose a new - // function on aggregates for checking if it *would* error? - // (i.e: errors.length > 0) - try { - aggregate.close(); - } catch (error) { - // Duplicate entries were found and filtered out, resulting in altered - // wikiData arrays. These must be re-linked so objects receive the new - // data. - linkWikiDataArrays(wikiData); - } - return aggregate; + for (const directory of duplicateDirectories) { + const places = directoryPlaces[directory]; + call(() => { + throw new Error( + `Duplicate directory ${color.green(directory)}:\n` + + places.map((thing) => ` - ` + inspect(thing)).join('\n') + ); + }); + } + const allDuplicatedThings = Object.values(directoryPlaces) + .filter((arr) => arr.length > 1) + .flat(); + const filteredThings = thingData.filter( + (thing) => !allDuplicatedThings.includes(thing) + ); + wikiData[thingDataProp] = filteredThings; + } + ); + } + + // TODO: This code closes the aggregate but it generally gets closed again + // by the caller. This works but it might be weird to assume closing an + // aggregate twice is okay, maybe there's a better solution? Expose a new + // function on aggregates for checking if it *would* error? + // (i.e: errors.length > 0) + try { + aggregate.close(); + } catch (error) { + // Duplicate entries were found and filtered out, resulting in altered + // wikiData arrays. These must be re-linked so objects receive the new + // data. + linkWikiDataArrays(wikiData); + } + return aggregate; } // Warn about references across data which don't match anything. This involves @@ -1193,102 +1289,168 @@ export function filterDuplicateDirectories(wikiData) { // any errors). At the same time, we remove errored references from the thing's // data array. export function filterReferenceErrors(wikiData) { - const referenceSpec = [ - ['wikiInfo', { - divideTrackListsByGroupsByRef: 'group', - }], - - ['albumData', { - artistContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - trackCoverArtistContribsByRef: '_contrib', - wallpaperArtistContribsByRef: '_contrib', - bannerArtistContribsByRef: '_contrib', - groupsByRef: 'group', - artTagsByRef: 'artTag', - }], - - ['trackData', { - artistContribsByRef: '_contrib', - contributorContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - referencedTracksByRef: 'track', - artTagsByRef: 'artTag', - originalReleaseTrackByRef: 'track', - }], - - ['groupCategoryData', { - groupsByRef: 'group', - }], - - ['homepageLayout.rows', { - sourceGroupsByRef: 'group', - sourceAlbumsByRef: 'album', - }], - - ['flashData', { - contributorContribsByRef: '_contrib', - featuredTracksByRef: 'track', - }], - - ['flashActData', { - flashesByRef: 'flash', - }], - ]; - - function getNestedProp(obj, key) { - const recursive = (o, k) => (k.length === 1 - ? o[k[0]] - : recursive(o[k[0]], k.slice(1))); - const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./); - return recursive(obj, keys); - } - - const aggregate = openAggregate({message: `Errors validating between-thing references in data`}); - const boundFind = bindFind(wikiData, {mode: 'error'}); - for (const [ thingDataProp, propSpec ] of referenceSpec) { - const thingData = getNestedProp(wikiData, thingDataProp); - aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({ nest }) => { - const things = Array.isArray(thingData) ? thingData : [thingData]; - for (const thing of things) { - nest({message: `Reference errors in ${inspect(thing)}`}, ({ filter }) => { - for (const [ property, findFnKey ] of Object.entries(propSpec)) { - if (!thing[property]) continue; - if (findFnKey === '_contrib') { - thing[property] = filter(thing[property], - decorateErrorWithIndex(({ who }) => { - const alias = find.artist(who, wikiData.artistAliasData, {mode: 'quiet'}); - if (alias) { - const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); - throw new Error(`Reference ${color.red(who)} is to an alias, should be ${color.green(original.name)}`); - } - return boundFind.artist(who); - }), - {message: `Reference errors in contributions ${color.green(property)} (${color.green('find.artist')})`}); - continue; - } - const findFn = boundFind[findFnKey]; - const value = thing[property]; - if (Array.isArray(value)) { - thing[property] = filter(value, decorateErrorWithIndex(findFn), - {message: `Reference errors in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}); - } else { - nest({message: `Reference error in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}, ({ call }) => { - try { - call(findFn, value); - } catch (error) { - thing[property] = null; - throw error; - } - }); - } + const referenceSpec = [ + [ + 'wikiInfo', + { + divideTrackListsByGroupsByRef: 'group', + }, + ], + + [ + 'albumData', + { + artistContribsByRef: '_contrib', + coverArtistContribsByRef: '_contrib', + trackCoverArtistContribsByRef: '_contrib', + wallpaperArtistContribsByRef: '_contrib', + bannerArtistContribsByRef: '_contrib', + groupsByRef: 'group', + artTagsByRef: 'artTag', + }, + ], + + [ + 'trackData', + { + artistContribsByRef: '_contrib', + contributorContribsByRef: '_contrib', + coverArtistContribsByRef: '_contrib', + referencedTracksByRef: 'track', + artTagsByRef: 'artTag', + originalReleaseTrackByRef: 'track', + }, + ], + + [ + 'groupCategoryData', + { + groupsByRef: 'group', + }, + ], + + [ + 'homepageLayout.rows', + { + sourceGroupsByRef: 'group', + sourceAlbumsByRef: 'album', + }, + ], + + [ + 'flashData', + { + contributorContribsByRef: '_contrib', + featuredTracksByRef: 'track', + }, + ], + + [ + 'flashActData', + { + flashesByRef: 'flash', + }, + ], + ]; + + function getNestedProp(obj, key) { + const recursive = (o, k) => + k.length === 1 ? o[k[0]] : recursive(o[k[0]], k.slice(1)); + const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./); + return recursive(obj, keys); + } + + const aggregate = openAggregate({ + message: `Errors validating between-thing references in data`, + }); + const boundFind = bindFind(wikiData, {mode: 'error'}); + for (const [thingDataProp, propSpec] of referenceSpec) { + const thingData = getNestedProp(wikiData, thingDataProp); + aggregate.nest( + { + message: `Reference errors in ${color.green( + 'wikiData.' + thingDataProp + )}`, + }, + ({nest}) => { + const things = Array.isArray(thingData) ? thingData : [thingData]; + for (const thing of things) { + nest( + {message: `Reference errors in ${inspect(thing)}`}, + ({filter}) => { + for (const [property, findFnKey] of Object.entries(propSpec)) { + if (!thing[property]) continue; + if (findFnKey === '_contrib') { + thing[property] = filter( + thing[property], + decorateErrorWithIndex(({who}) => { + const alias = find.artist(who, wikiData.artistAliasData, { + mode: 'quiet', + }); + if (alias) { + const original = find.artist( + alias.aliasedArtistRef, + wikiData.artistData, + { + mode: 'quiet', + } + ); + throw new Error( + `Reference ${color.red( + who + )} is to an alias, should be ${color.green( + original.name + )}` + ); + } + return boundFind.artist(who); + }), + { + message: `Reference errors in contributions ${color.green( + property + )} (${color.green('find.artist')})`, + } + ); + continue; + } + const findFn = boundFind[findFnKey]; + const value = thing[property]; + if (Array.isArray(value)) { + thing[property] = filter( + value, + decorateErrorWithIndex(findFn), + { + message: `Reference errors in property ${color.green( + property + )} (${color.green('find.' + findFnKey)})`, + } + ); + } else { + nest( + { + message: `Reference error in property ${color.green( + property + )} (${color.green('find.' + findFnKey)})`, + }, + ({call}) => { + try { + call(findFn, value); + } catch (error) { + thing[property] = null; + throw error; + } } - }); + ); + } + } } - }); - } + ); + } + } + ); + } - return aggregate; + return aggregate; } // Utility function for loading all wiki data from the provided YAML data @@ -1297,48 +1459,49 @@ export function filterReferenceErrors(wikiData) { // a boilerplate for more specialized output, or as a quick start in utilities // where reporting info about data loading isn't as relevant as during the // main wiki build process. -export async function quickLoadAllFromYAML(dataPath, { - showAggregate: customShowAggregate = showAggregate, -} = {}) { - const showAggregate = customShowAggregate; +export async function quickLoadAllFromYAML( + dataPath, + {showAggregate: customShowAggregate = showAggregate} = {} +) { + const showAggregate = customShowAggregate; - let wikiData; + let wikiData; - { - const { aggregate, result } = await loadAndProcessDataDocuments({ - dataPath, - }); + { + const {aggregate, result} = await loadAndProcessDataDocuments({ + dataPath, + }); - wikiData = result; - - try { - aggregate.close(); - logInfo`Loaded data without errors. (complete data)`; - } catch (error) { - showAggregate(error); - logWarn`Loaded data with errors. (partial data)`; - } - } - - linkWikiDataArrays(wikiData); + wikiData = result; try { - filterDuplicateDirectories(wikiData).close(); - logInfo`No duplicate directories found. (complete data)`; + aggregate.close(); + logInfo`Loaded data without errors. (complete data)`; } catch (error) { - showAggregate(error); - logWarn`Duplicate directories found. (partial data)`; + showAggregate(error); + logWarn`Loaded data with errors. (partial data)`; } + } - try { - filterReferenceErrors(wikiData).close(); - logInfo`No reference errors found. (complete data)`; - } catch (error) { - showAggregate(error); - logWarn`Duplicate directories found. (partial data)`; - } + linkWikiDataArrays(wikiData); + + try { + filterDuplicateDirectories(wikiData).close(); + logInfo`No duplicate directories found. (complete data)`; + } catch (error) { + showAggregate(error); + logWarn`Duplicate directories found. (partial data)`; + } + + try { + filterReferenceErrors(wikiData).close(); + logInfo`No reference errors found. (complete data)`; + } catch (error) { + showAggregate(error); + logWarn`Duplicate directories found. (partial data)`; + } - sortWikiDataArrays(wikiData); + sortWikiDataArrays(wikiData); - return wikiData; + return wikiData; } diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js index d0807cc3..363fb4c0 100644 --- a/src/file-size-preloader.js +++ b/src/file-size-preloader.js @@ -1,3 +1,5 @@ +/** @format */ + // Very simple, bare-bones file size loader which takes a bunch of file // paths, gets their filesizes, and resolves a promise when it's done. // @@ -17,84 +19,84 @@ // This only processes files one at a time because I'm lazy and stat calls // are very, very fast. -import { stat } from 'fs/promises'; -import { logWarn } from './util/cli.js'; +import {stat} from 'fs/promises'; +import {logWarn} from './util/cli.js'; export default class FileSizePreloader { - #paths = []; - #sizes = []; - #loadedPathIndex = -1; - - #loadingPromise = null; - #resolveLoadingPromise = null; - - loadPaths(...paths) { - this.#paths.push(...paths.filter(p => !this.#paths.includes(p))); - return this.#startLoadingPaths(); - } - - waitUntilDoneLoading() { - return this.#loadingPromise ?? Promise.resolve(); - } + #paths = []; + #sizes = []; + #loadedPathIndex = -1; - #startLoadingPaths() { - if (this.#loadingPromise) { - return this.#loadingPromise; - } + #loadingPromise = null; + #resolveLoadingPromise = null; - this.#loadingPromise = new Promise((resolve => { - this.#resolveLoadingPromise = resolve; - })); + loadPaths(...paths) { + this.#paths.push(...paths.filter((p) => !this.#paths.includes(p))); + return this.#startLoadingPaths(); + } - this.#loadNextPath(); + waitUntilDoneLoading() { + return this.#loadingPromise ?? Promise.resolve(); + } - return this.#loadingPromise; + #startLoadingPaths() { + if (this.#loadingPromise) { + return this.#loadingPromise; } - async #loadNextPath() { - if (this.#loadedPathIndex === this.#paths.length - 1) { - return this.#doneLoadingPaths(); - } - - let size; + this.#loadingPromise = new Promise((resolve) => { + this.#resolveLoadingPromise = resolve; + }); - const path = this.#paths[this.#loadedPathIndex + 1]; + this.#loadNextPath(); - try { - size = await this.readFileSize(path); - } catch (error) { - // Oops! Discard that path, and don't increment the index before - // moving on, since the next path will now be in its place. - this.#paths.splice(this.#loadedPathIndex + 1, 1); - logWarn`Failed to process file size for ${path}: ${error.message}`; - return this.#loadNextPath(); - } + return this.#loadingPromise; + } - this.#sizes.push(size); - this.#loadedPathIndex++; - return this.#loadNextPath(); + async #loadNextPath() { + if (this.#loadedPathIndex === this.#paths.length - 1) { + return this.#doneLoadingPaths(); } - #doneLoadingPaths() { - this.#resolveLoadingPromise(); - this.#loadingPromise = null; - this.#resolveLoadingPromise = null; - } + let size; - // Override me if you want? - // The rest of the code here is literally just a queue system, so you could - // pretty much repurpose it for anything... but there are probably cleaner - // ways than making an instance or subclass of this and overriding this one - // method! - async readFileSize(path) { - const stats = await stat(path); - return stats.size; - } + const path = this.#paths[this.#loadedPathIndex + 1]; - getSizeOfPath(path) { - const index = this.#paths.indexOf(path); - if (index === -1) return null; - if (index > this.#loadedPathIndex) return null; - return this.#sizes[index]; + try { + size = await this.readFileSize(path); + } catch (error) { + // Oops! Discard that path, and don't increment the index before + // moving on, since the next path will now be in its place. + this.#paths.splice(this.#loadedPathIndex + 1, 1); + logWarn`Failed to process file size for ${path}: ${error.message}`; + return this.#loadNextPath(); } + + this.#sizes.push(size); + this.#loadedPathIndex++; + return this.#loadNextPath(); + } + + #doneLoadingPaths() { + this.#resolveLoadingPromise(); + this.#loadingPromise = null; + this.#resolveLoadingPromise = null; + } + + // Override me if you want? + // The rest of the code here is literally just a queue system, so you could + // pretty much repurpose it for anything... but there are probably cleaner + // ways than making an instance or subclass of this and overriding this one + // method! + async readFileSize(path) { + const stats = await stat(path); + return stats.size; + } + + getSizeOfPath(path) { + const index = this.#paths.indexOf(path); + if (index === -1) return null; + if (index > this.#loadedPathIndex) return null; + return this.#sizes[index]; + } } diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 839c1d42..b5b918fd 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +/** @format */ // Ok, so the d8te is 3 March 2021, and the music wiki was initially released // on 15 November 2019. That is 474 days or 11376 hours. In my opinion, and @@ -77,316 +78,321 @@ const CACHE_FILE = 'thumbnail-cache.json'; const WARNING_DELAY_TIME = 10000; -import { spawn } from 'child_process'; -import { createHash } from 'crypto'; +import {spawn} from 'child_process'; +import {createHash} from 'crypto'; import * as path from 'path'; -import { - readdir, - readFile, - writeFile -} from 'fs/promises'; // Whatcha know! Nice. +import {readdir, readFile, writeFile} from 'fs/promises'; // Whatcha know! Nice. -import { - createReadStream -} from 'fs'; // Still gotta import from 8oth tho, for createReadStream. +import {createReadStream} from 'fs'; // Still gotta import from 8oth tho, for createReadStream. import { - logError, - logInfo, - logWarn, - parseOptions, - progressPromiseAll + logError, + logInfo, + logWarn, + parseOptions, + progressPromiseAll, } from './util/cli.js'; -import { - commandExists, - isMain, - promisifyProcess, -} from './util/node-utils.js'; +import {commandExists, isMain, promisifyProcess} from './util/node-utils.js'; + +import {delay, queue} from './util/sugar.js'; + +function traverse( + startDirPath, + {filterFile = () => true, filterDir = () => true} = {} +) { + const recursive = (names, subDirPath) => + Promise.all( + names.map((name) => + readdir(path.join(startDirPath, subDirPath, name)).then( + (names) => + filterDir(name) + ? recursive(names, path.join(subDirPath, name)) + : [], + () => (filterFile(name) ? [path.join(subDirPath, name)] : []) + ) + ) + ).then((pathArrays) => pathArrays.flatMap((x) => x)); -import { - delay, - queue, -} from './util/sugar.js'; - -function traverse(startDirPath, { - filterFile = () => true, - filterDir = () => true -} = {}) { - const recursive = (names, subDirPath) => Promise - .all(names.map(name => readdir(path.join(startDirPath, subDirPath, name)).then( - names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [], - err => filterFile(name) ? [path.join(subDirPath, name)] : []))) - .then(pathArrays => pathArrays.flatMap(x => x)); - - return readdir(startDirPath) - .then(names => recursive(names, '')); + return readdir(startDirPath).then((names) => recursive(names, '')); } function readFileMD5(filePath) { - return new Promise((resolve, reject) => { - const md5 = createHash('md5'); - const stream = createReadStream(filePath); - stream.on('data', data => md5.update(data)); - stream.on('end', data => resolve(md5.digest('hex'))); - stream.on('error', err => reject(err)); - }); + return new Promise((resolve, reject) => { + const md5 = createHash('md5'); + const stream = createReadStream(filePath); + stream.on('data', (data) => md5.update(data)); + stream.on('end', () => resolve(md5.digest('hex'))); + stream.on('error', (err) => reject(err)); + }); } async function getImageMagickVersion(spawnConvert) { - const proc = spawnConvert(['--version'], false); + const proc = spawnConvert(['--version'], false); - let allData = ''; - proc.stdout.on('data', data => { - allData += data.toString(); - }); + let allData = ''; + proc.stdout.on('data', (data) => { + allData += data.toString(); + }); - await promisifyProcess(proc, false); + await promisifyProcess(proc, false); - if (!allData.match(/ImageMagick/i)) { - return null; - } + if (!allData.match(/ImageMagick/i)) { + return null; + } - const match = allData.match(/Version: (.*)/i); - if (!match) { - return 'unknown version'; - } + const match = allData.match(/Version: (.*)/i); + if (!match) { + return 'unknown version'; + } - return match[1]; + return match[1]; } async function getSpawnConvert() { - let fn, description, version; - if (await commandExists('convert')) { - fn = args => spawn('convert', args); - description = 'convert'; - } else if (await commandExists('magick')) { - fn = (args, prefix = true) => spawn('magick', - (prefix ? ['convert', ...args] : args)); - description = 'magick convert'; - } else { - return [`no convert or magick binary`, null]; - } - - version = await getImageMagickVersion(fn); - - if (version === null) { - return [`binary --version output didn't indicate it's ImageMagick`]; - } - - return [`${description} (${version})`, fn]; + let fn, description, version; + if (await commandExists('convert')) { + fn = (args) => spawn('convert', args); + description = 'convert'; + } else if (await commandExists('magick')) { + fn = (args, prefix = true) => + spawn('magick', prefix ? ['convert', ...args] : args); + description = 'magick convert'; + } else { + return [`no convert or magick binary`, null]; + } + + version = await getImageMagickVersion(fn); + + if (version === null) { + return [`binary --version output didn't indicate it's ImageMagick`]; + } + + return [`${description} (${version})`, fn]; } function generateImageThumbnails(filePath, {spawnConvert}) { - const dirname = path.dirname(filePath); - const extname = path.extname(filePath); - const basename = path.basename(filePath, extname); - const output = name => path.join(dirname, basename + name + '.jpg'); - - const convert = (name, {size, quality}) => spawnConvert([ - filePath, - '-strip', - '-resize', `${size}x${size}>`, - '-interlace', 'Plane', - '-quality', `${quality}%`, - output(name) - ]); - - return Promise.all([ - promisifyProcess(convert('.medium', {size: 400, quality: 95}), false), - promisifyProcess(convert('.small', {size: 250, quality: 85}), false) + const dirname = path.dirname(filePath); + const extname = path.extname(filePath); + const basename = path.basename(filePath, extname); + const output = (name) => path.join(dirname, basename + name + '.jpg'); + + const convert = (name, {size, quality}) => + spawnConvert([ + filePath, + '-strip', + '-resize', + `${size}x${size}>`, + '-interlace', + 'Plane', + '-quality', + `${quality}%`, + output(name), ]); - return new Promise((resolve, reject) => { - if (Math.random() < 0.2) { - reject(new Error(`Them's the 8r8ks, kiddo!`)); - } else { - resolve(); - } - }); + return Promise.all([ + promisifyProcess(convert('.medium', {size: 400, quality: 95}), false), + promisifyProcess(convert('.small', {size: 250, quality: 85}), false), + ]); } -export default async function genThumbs(mediaPath, { - queueSize = 0, - quiet = false -} = {}) { - if (!mediaPath) { - throw new Error('Expected mediaPath to be passed'); - } +export default async function genThumbs( + mediaPath, + {queueSize = 0, quiet = false} = {} +) { + if (!mediaPath) { + throw new Error('Expected mediaPath to be passed'); + } - const quietInfo = (quiet - ? () => null - : logInfo); - - const filterFile = name => { - // TODO: Why is this not working???????? - // thumbnail-cache.json is 8eing passed through, for some reason. - - const ext = path.extname(name); - if (ext !== '.jpg' && ext !== '.png') return false; - - const rest = path.basename(name, ext); - if (rest.endsWith('.medium') || rest.endsWith('.small')) return false; - - return true; - }; - - const filterDir = name => { - if (name === '.git') return false; - return true; - }; - - const [convertInfo, spawnConvert] = await getSpawnConvert() ?? []; - if (!spawnConvert) { - logError`${`It looks like you don't have ImageMagick installed.`}`; - logError`ImageMagick is required to generate thumbnails for display on the wiki.`; - logError`(Error message: ${convertInfo})`; - logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`; - logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`; - logInfo`If you have trouble working ImageMagick and would like some help, feel free`; - logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`; - return false; - } else { - logInfo`Found ImageMagick binary: ${convertInfo}`; - } + const quietInfo = quiet ? () => null : logInfo; - let cache, firstRun = false, failedReadingCache = false; - try { - cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE))); - quietInfo`Cache file successfully read.`; - } catch (error) { - cache = {}; - if (error.code === 'ENOENT') { - firstRun = true; - } else { - failedReadingCache = true; - logWarn`Malformed or unreadable cache file: ${error}`; - logWarn`You may want to cancel and investigate this!`; - logWarn`All-new thumbnails and cache will be generated for this run.`; - await delay(WARNING_DELAY_TIME); - } - } + const filterFile = (name) => { + // TODO: Why is this not working???????? + // thumbnail-cache.json is 8eing passed through, for some reason. - try { - await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache)); - quietInfo`Writing to cache file appears to be working.`; - } catch (error) { - logWarn`Test of cache file writing failed: ${error}`; - if (cache) { - logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`; - } else if (firstRun) { - logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`; - } else { - logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`; - } - logWarn`You may want to cancel and investigate this!`; - await delay(WARNING_DELAY_TIME); - } - - const imagePaths = await traverse(mediaPath, {filterFile, filterDir}); - - const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue( - imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then( - md5 => [imagePath, md5], - error => [imagePath, {error}] - )), - queueSize - )); - - { - let error = false; - for (const entry of imageToMD5Entries) { - if (entry[1].error) { - logError`Failed to read ${entry[0]}: ${entry[1].error}`; - error = true; - } - } - if (error) { - logError`Failed to read at least one image file!`; - logError`This implies a thumbnail probably won't be generatable.`; - logError`So, exiting early.`; - return false; - } else { - quietInfo`All image files successfully read.`; - } - } + const ext = path.extname(name); + if (ext !== '.jpg' && ext !== '.png') return false; - // Technically we could pro8a8ly mut8te the cache varia8le in-place? - // 8ut that seems kinda iffy. - const updatedCache = Object.assign({}, cache); + const rest = path.basename(name, ext); + if (rest.endsWith('.medium') || rest.endsWith('.small')) return false; - const entriesToGenerate = imageToMD5Entries - .filter(([filePath, md5]) => md5 !== cache[filePath]); + return true; + }; - if (entriesToGenerate.length === 0) { - logInfo`All image thumbnails are already up-to-date - nice!`; - return true; + const filterDir = (name) => { + if (name === '.git') return false; + return true; + }; + + const [convertInfo, spawnConvert] = (await getSpawnConvert()) ?? []; + if (!spawnConvert) { + logError`${`It looks like you don't have ImageMagick installed.`}`; + logError`ImageMagick is required to generate thumbnails for display on the wiki.`; + logError`(Error message: ${convertInfo})`; + logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`; + logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`; + logInfo`If you have trouble working ImageMagick and would like some help, feel free`; + logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`; + return false; + } else { + logInfo`Found ImageMagick binary: ${convertInfo}`; + } + + let cache, + firstRun = false; + try { + cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE))); + quietInfo`Cache file successfully read.`; + } catch (error) { + cache = {}; + if (error.code === 'ENOENT') { + firstRun = true; + } else { + logWarn`Malformed or unreadable cache file: ${error}`; + logWarn`You may want to cancel and investigate this!`; + logWarn`All-new thumbnails and cache will be generated for this run.`; + await delay(WARNING_DELAY_TIME); + } + } + + try { + await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache)); + quietInfo`Writing to cache file appears to be working.`; + } catch (error) { + logWarn`Test of cache file writing failed: ${error}`; + if (cache) { + logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`; + } else if (firstRun) { + logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`; + } else { + logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`; + } + logWarn`You may want to cancel and investigate this!`; + await delay(WARNING_DELAY_TIME); + } + + const imagePaths = await traverse(mediaPath, {filterFile, filterDir}); + + const imageToMD5Entries = await progressPromiseAll( + `Generating MD5s of image files`, + queue( + imagePaths.map( + (imagePath) => () => + readFileMD5(path.join(mediaPath, imagePath)).then( + (md5) => [imagePath, md5], + (error) => [imagePath, {error}] + ) + ), + queueSize + ) + ); + + { + let error = false; + for (const entry of imageToMD5Entries) { + if (entry[1].error) { + logError`Failed to read ${entry[0]}: ${entry[1].error}`; + error = true; + } + } + if (error) { + logError`Failed to read at least one image file!`; + logError`This implies a thumbnail probably won't be generatable.`; + logError`So, exiting early.`; + return false; + } else { + quietInfo`All image files successfully read.`; } + } - const failed = []; - const succeeded = []; - const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`; + // Technically we could pro8a8ly mut8te the cache varia8le in-place? + // 8ut that seems kinda iffy. + const updatedCache = Object.assign({}, cache); - // This is actually sort of a lie, 8ecause we aren't doing synchronicity. - // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll, - // 'cuz the progress indic8tor is very cool and good. - await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) => - () => generateImageThumbnails(path.join(mediaPath, filePath)).then( - () => { + const entriesToGenerate = imageToMD5Entries.filter( + ([filePath, md5]) => md5 !== cache[filePath] + ); + + if (entriesToGenerate.length === 0) { + logInfo`All image thumbnails are already up-to-date - nice!`; + return true; + } + + const failed = []; + const succeeded = []; + const writeMessageFn = () => + `Writing image thumbnails. [failed: ${failed.length}]`; + + // This is actually sort of a lie, 8ecause we aren't doing synchronicity. + // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll, + // 'cuz the progress indic8tor is very cool and good. + await progressPromiseAll( + writeMessageFn, + queue( + entriesToGenerate.map( + ([filePath, md5]) => + () => + generateImageThumbnails(path.join(mediaPath, filePath)).then( + () => { updatedCache[filePath] = md5; succeeded.push(filePath); - }, - error => { + }, + (error) => { failed.push([filePath, error]); - } - ) - ))); - - if (failed.length > 0) { - for (const [path, error] of failed) { - logError`Thumbnails failed to generate for ${path} - ${error}`; - } - logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`; - logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`; - } else { - logInfo`Generated all (updated) thumbnails successfully!`; - } - - try { - await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache)); - quietInfo`Updated cache file successfully written!`; - } catch (error) { - logWarn`Failed to write updated cache file: ${error}`; - logWarn`Any newly (re)generated thumbnails will be regenerated next run.`; - logWarn`Sorry about that!`; + } + ) + ) + ) + ); + + if (failed.length > 0) { + for (const [path, error] of failed) { + logError`Thumbnails failed to generate for ${path} - ${error}`; } - - return true; + logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`; + logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`; + } else { + logInfo`Generated all (updated) thumbnails successfully!`; + } + + try { + await writeFile( + path.join(mediaPath, CACHE_FILE), + JSON.stringify(updatedCache) + ); + quietInfo`Updated cache file successfully written!`; + } catch (error) { + logWarn`Failed to write updated cache file: ${error}`; + logWarn`Any newly (re)generated thumbnails will be regenerated next run.`; + logWarn`Sorry about that!`; + } + + return true; } if (isMain(import.meta.url)) { - (async function() { - const miscOptions = await parseOptions(process.argv.slice(2), { - 'media-path': { - type: 'value' - }, - 'queue-size': { - type: 'value', - validate(size) { - if (parseInt(size) !== parseFloat(size)) return 'an integer'; - if (parseInt(size) < 0) return 'a counting number or zero'; - return true; - } - }, - queue: {alias: 'queue-size'}, - }); - - const mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; - const queueSize = +(miscOptions['queue-size'] ?? 0); - - await genThumbs(mediaPath, {queueSize}); - })().catch(err => { - console.error(err); + (async function () { + const miscOptions = await parseOptions(process.argv.slice(2), { + 'media-path': { + type: 'value', + }, + 'queue-size': { + type: 'value', + validate(size) { + if (parseInt(size) !== parseFloat(size)) return 'an integer'; + if (parseInt(size) < 0) return 'a counting number or zero'; + return true; + }, + }, + queue: {alias: 'queue-size'}, }); + + const mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; + const queueSize = +(miscOptions['queue-size'] ?? 0); + + await genThumbs(mediaPath, {queueSize}); + })().catch((err) => { + console.error(err); + }); } diff --git a/src/listing-spec.js b/src/listing-spec.js index df2b038e..28f4b1ae 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,771 +1,974 @@ +/** @format */ + import fixWS from 'fix-whitespace'; import { - chunkByProperties, - getArtistNumContributions, - getTotalDuration, - sortAlphabetically, - sortChronologically, + chunkByProperties, + getArtistNumContributions, + getTotalDuration, + sortAlphabetically, + sortChronologically, } from './util/wiki-data.js'; const listingSpec = [ - { - directory: 'albums/by-name', - stringsKey: 'listAlbums.byName', - - data({wikiData}) { - return sortAlphabetically(wikiData.albumData.slice()); - }, - - row(album, {link, language}) { - return language.$('listingPage.listAlbums.byName.item', { - album: link.album(album), - tracks: language.countTracks(album.tracks.length, {unit: true}) - }); - } - }, - - { - directory: 'albums/by-tracks', - stringsKey: 'listAlbums.byTracks', - - data({wikiData}) { - return wikiData.albumData.slice() - .sort((a, b) => b.tracks.length - a.tracks.length); - }, - - row(album, {link, language}) { - return language.$('listingPage.listAlbums.byTracks.item', { - album: link.album(album), - tracks: language.countTracks(album.tracks.length, {unit: true}) - }); - } - }, - - { - directory: 'albums/by-duration', - stringsKey: 'listAlbums.byDuration', - - data({wikiData}) { - return wikiData.albumData - .map(album => ({album, duration: getTotalDuration(album.tracks)})) - .sort((a, b) => b.duration - a.duration); - }, - - row({album, duration}, {link, language}) { - return language.$('listingPage.listAlbums.byDuration.item', { - album: link.album(album), - duration: language.formatDuration(duration) - }); - } - }, - - { - directory: 'albums/by-date', - stringsKey: 'listAlbums.byDate', - - data({wikiData}) { - return sortChronologically(wikiData.albumData.filter(album => album.date)); - }, - - row(album, {link, language}) { - return language.$('listingPage.listAlbums.byDate.item', { - album: link.album(album), - date: language.formatDate(album.date) - }); - } - }, - - { - directory: 'albums/by-date-added', - stringsKey: 'listAlbums.byDateAdded', - - data({wikiData}) { - return chunkByProperties(wikiData.albumData.filter(a => a.dateAddedToWiki).sort((a, b) => { - if (a.dateAddedToWiki < b.dateAddedToWiki) return -1; - if (a.dateAddedToWiki > b.dateAddedToWiki) return 1; - }), ['dateAddedToWiki']); - }, - - html(chunks, {link, language}) { - return fixWS` + { + directory: 'albums/by-name', + stringsKey: 'listAlbums.byName', + + data({wikiData}) { + return sortAlphabetically(wikiData.albumData.slice()); + }, + + row(album, {link, language}) { + return language.$('listingPage.listAlbums.byName.item', { + album: link.album(album), + tracks: language.countTracks(album.tracks.length, {unit: true}), + }); + }, + }, + + { + directory: 'albums/by-tracks', + stringsKey: 'listAlbums.byTracks', + + data({wikiData}) { + return wikiData.albumData + .slice() + .sort((a, b) => b.tracks.length - a.tracks.length); + }, + + row(album, {link, language}) { + return language.$('listingPage.listAlbums.byTracks.item', { + album: link.album(album), + tracks: language.countTracks(album.tracks.length, {unit: true}), + }); + }, + }, + + { + directory: 'albums/by-duration', + stringsKey: 'listAlbums.byDuration', + + data({wikiData}) { + return wikiData.albumData + .map((album) => ({album, duration: getTotalDuration(album.tracks)})) + .sort((a, b) => b.duration - a.duration); + }, + + row({album, duration}, {link, language}) { + return language.$('listingPage.listAlbums.byDuration.item', { + album: link.album(album), + duration: language.formatDuration(duration), + }); + }, + }, + + { + directory: 'albums/by-date', + stringsKey: 'listAlbums.byDate', + + data({wikiData}) { + return sortChronologically( + wikiData.albumData.filter((album) => album.date) + ); + }, + + row(album, {link, language}) { + return language.$('listingPage.listAlbums.byDate.item', { + album: link.album(album), + date: language.formatDate(album.date), + }); + }, + }, + + { + directory: 'albums/by-date-added', + stringsKey: 'listAlbums.byDateAdded', + + data({wikiData}) { + return chunkByProperties( + wikiData.albumData + .filter((a) => a.dateAddedToWiki) + .sort((a, b) => { + if (a.dateAddedToWiki < b.dateAddedToWiki) return -1; + if (a.dateAddedToWiki > b.dateAddedToWiki) return 1; + }), + ['dateAddedToWiki'] + ); + }, + + html(chunks, {link, language}) { + return fixWS` <dl> - ${chunks.map(({dateAddedToWiki, chunk: albums}) => fixWS` - <dt>${language.$('listingPage.listAlbums.byDateAdded.date', { - date: language.formatDate(dateAddedToWiki) - })}</dt> + ${chunks + .map( + ({dateAddedToWiki, chunk: albums}) => fixWS` + <dt>${language.$( + 'listingPage.listAlbums.byDateAdded.date', + { + date: language.formatDate(dateAddedToWiki), + } + )}</dt> <dd><ul> - ${(albums - .map(album => language.$('listingPage.listAlbums.byDateAdded.album', { - album: link.album(album) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${albums + .map((album) => + language.$( + 'listingPage.listAlbums.byDateAdded.album', + { + album: link.album(album), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } - }, - - { - directory: 'artists/by-name', - stringsKey: 'listArtists.byName', - - data({wikiData}) { - return sortAlphabetically(wikiData.artistData.slice()) - .map(artist => ({artist, contributions: getArtistNumContributions(artist)})); - }, - - row({artist, contributions}, {link, language}) { - return language.$('listingPage.listArtists.byName.item', { - artist: link.artist(artist), - contributions: language.countContributions(contributions, {unit: true}) - }); - } - }, - - { - directory: 'artists/by-contribs', - stringsKey: 'listArtists.byContribs', - - data({wikiData}) { - return { - toTracks: (wikiData.artistData - .map(artist => ({ - artist, - contributions: ( - (artist.tracksAsContributor?.length ?? 0) + - (artist.tracksAsArtist?.length ?? 0) - ) - })) - .sort((a, b) => b.contributions - a.contributions) - .filter(({ contributions }) => contributions)), - - toArtAndFlashes: (wikiData.artistData - .map(artist => ({ - artist, - contributions: ( - (artist.tracksAsCoverArtist?.length ?? 0) + - (artist.albumsAsCoverArtist?.length ?? 0) + - (artist.albumsAsWallpaperArtist?.length ?? 0) + - (artist.albumsAsBannerArtist?.length ?? 0) + - (wikiData.wikiInfo.enableFlashesAndGames - ? (artist.flashesAsContributor?.length ?? 0) - : 0) - ) - })) - .sort((a, b) => b.contributions - a.contributions) - .filter(({ contributions }) => contributions)), - - // This is a kinda naughty hack, 8ut like, it's the only place - // we'd 8e passing wikiData to html() otherwise, so like.... - // (Ok we do do this again once later.) - showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames - }; - }, - - html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) { - return fixWS` + }, + }, + + { + directory: 'artists/by-name', + stringsKey: 'listArtists.byName', + + data({wikiData}) { + return sortAlphabetically(wikiData.artistData.slice()).map((artist) => ({ + artist, + contributions: getArtistNumContributions(artist), + })); + }, + + row({artist, contributions}, {link, language}) { + return language.$('listingPage.listArtists.byName.item', { + artist: link.artist(artist), + contributions: language.countContributions(contributions, { + unit: true, + }), + }); + }, + }, + + { + directory: 'artists/by-contribs', + stringsKey: 'listArtists.byContribs', + + data({wikiData}) { + return { + toTracks: wikiData.artistData + .map((artist) => ({ + artist, + contributions: + (artist.tracksAsContributor?.length ?? 0) + + (artist.tracksAsArtist?.length ?? 0), + })) + .sort((a, b) => b.contributions - a.contributions) + .filter(({contributions}) => contributions), + + toArtAndFlashes: wikiData.artistData + .map((artist) => ({ + artist, + contributions: + (artist.tracksAsCoverArtist?.length ?? 0) + + (artist.albumsAsCoverArtist?.length ?? 0) + + (artist.albumsAsWallpaperArtist?.length ?? 0) + + (artist.albumsAsBannerArtist?.length ?? 0) + + (wikiData.wikiInfo.enableFlashesAndGames + ? artist.flashesAsContributor?.length ?? 0 + : 0), + })) + .sort((a, b) => b.contributions - a.contributions) + .filter(({contributions}) => contributions), + + // This is a kinda naughty hack, 8ut like, it's the only place + // we'd 8e passing wikiData to html() otherwise, so like.... + // (Ok we do do this again once later.) + showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames, + }; + }, + + html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) { + return fixWS` <div class="content-columns"> <div class="column"> - <h2>${language.$('listingPage.misc.trackContributors')}</h2> + <h2>${language.$( + 'listingPage.misc.trackContributors' + )}</h2> <ul> - ${(toTracks - .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', { + ${toTracks + .map(({artist, contributions}) => + language.$( + 'listingPage.listArtists.byContribs.item', + { artist: link.artist(artist), - contributions: language.countContributions(contributions, {unit: true}) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + contributions: language.countContributions( + contributions, + { + unit: true, + } + ), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> </div> <div class="column"> - <h2>${language.$('listingPage.misc' + + <h2>${language.$( + 'listingPage.misc' + (showAsFlashes - ? '.artAndFlashContributors' - : '.artContributors'))}</h2> + ? '.artAndFlashContributors' + : '.artContributors') + )}</h2> <ul> - ${(toArtAndFlashes - .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', { + ${toArtAndFlashes + .map(({artist, contributions}) => + language.$( + 'listingPage.listArtists.byContribs.item', + { artist: link.artist(artist), - contributions: language.countContributions(contributions, {unit: true}) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + contributions: language.countContributions( + contributions, + { + unit: true, + } + ), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> </div> </div> `; - } - }, - - { - directory: 'artists/by-commentary', - stringsKey: 'listArtists.byCommentary', - - data({wikiData}) { - return wikiData.artistData - .map(artist => ({artist, entries: ( - (artist.tracksAsCommentator?.length ?? 0) + - (artist.albumsAsCommentator?.length ?? 0) - )})) - .filter(({ entries }) => entries) - .sort((a, b) => b.entries - a.entries); - }, - - row({artist, entries}, {link, language}) { - return language.$('listingPage.listArtists.byCommentary.item', { - artist: link.artist(artist), - entries: language.countCommentaryEntries(entries, {unit: true}) - }); - } - }, - - { - directory: 'artists/by-duration', - stringsKey: 'listArtists.byDuration', - - data({wikiData}) { - return wikiData.artistData - .map(artist => ({ - artist, - duration: getTotalDuration([ - ...artist.tracksAsArtist ?? [], - ...artist.tracksAsContributor ?? [] - ]) - })) - .filter(({ duration }) => duration > 0) - .sort((a, b) => b.duration - a.duration); - }, - - row({artist, duration}, {link, language}) { - return language.$('listingPage.listArtists.byDuration.item', { - artist: link.artist(artist), - duration: language.formatDuration(duration) - }); - } - }, - - { - directory: 'artists/by-latest', - stringsKey: 'listArtists.byLatest', - - data({wikiData}) { - const reversedTracks = sortChronologically(wikiData.trackData.filter(t => t.date)).reverse(); - const reversedArtThings = sortChronologically([...wikiData.trackData, ...wikiData.albumData].filter(t => t.coverArtDate)).reverse(); - - return { - toTracks: sortChronologically(wikiData.artistData - .map(artist => ({ - artist, - directory: artist.directory, - name: artist.name, - date: reversedTracks.find(track => ([ - ...track.artistContribs ?? [], - ...track.contributorContribs ?? [] - ].some(({ who }) => who === artist)))?.date - })) - .filter(({ date }) => date)).reverse(), - - toArtAndFlashes: sortChronologically(wikiData.artistData - .map(artist => { - const thing = reversedArtThings.find(thing => ([ - ...thing.coverArtistContribs ?? [], - ...!thing.album && thing.contributorContribs || [] - ].some(({ who }) => who === artist))); - return thing && { - artist, - directory: artist.directory, - name: artist.name, - date: (thing.coverArtistContribs?.some(({ who }) => who === artist) - ? thing.coverArtDate - : thing.date) - }; - }) - .filter(Boolean) - .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0) - ).reverse(), - - // (Ok we did it again.) - // This is a kinda naughty hack, 8ut like, it's the only place - // we'd 8e passing wikiData to html() otherwise, so like.... - showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames - }; - }, - - html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) { - return fixWS` + }, + }, + + { + directory: 'artists/by-commentary', + stringsKey: 'listArtists.byCommentary', + + data({wikiData}) { + return wikiData.artistData + .map((artist) => ({ + artist, + entries: + (artist.tracksAsCommentator?.length ?? 0) + + (artist.albumsAsCommentator?.length ?? 0), + })) + .filter(({entries}) => entries) + .sort((a, b) => b.entries - a.entries); + }, + + row({artist, entries}, {link, language}) { + return language.$('listingPage.listArtists.byCommentary.item', { + artist: link.artist(artist), + entries: language.countCommentaryEntries(entries, {unit: true}), + }); + }, + }, + + { + directory: 'artists/by-duration', + stringsKey: 'listArtists.byDuration', + + data({wikiData}) { + return wikiData.artistData + .map((artist) => ({ + artist, + duration: getTotalDuration([ + ...(artist.tracksAsArtist ?? []), + ...(artist.tracksAsContributor ?? []), + ]), + })) + .filter(({duration}) => duration > 0) + .sort((a, b) => b.duration - a.duration); + }, + + row({artist, duration}, {link, language}) { + return language.$('listingPage.listArtists.byDuration.item', { + artist: link.artist(artist), + duration: language.formatDuration(duration), + }); + }, + }, + + { + directory: 'artists/by-latest', + stringsKey: 'listArtists.byLatest', + + data({wikiData}) { + const reversedTracks = sortChronologically( + wikiData.trackData.filter((t) => t.date) + ).reverse(); + const reversedArtThings = sortChronologically( + [...wikiData.trackData, ...wikiData.albumData].filter( + (t) => t.coverArtDate + ) + ).reverse(); + + return { + toTracks: sortChronologically( + wikiData.artistData + .map((artist) => ({ + artist, + directory: artist.directory, + name: artist.name, + date: reversedTracks.find((track) => + [ + ...(track.artistContribs ?? []), + ...(track.contributorContribs ?? []), + ].some(({who}) => who === artist) + )?.date, + })) + .filter(({date}) => date) + ).reverse(), + + toArtAndFlashes: sortChronologically( + wikiData.artistData + .map((artist) => { + const thing = reversedArtThings.find((thing) => + [ + ...(thing.coverArtistContribs ?? []), + ...((!thing.album && thing.contributorContribs) || []), + ].some(({who}) => who === artist) + ); + return ( + thing && { + artist, + directory: artist.directory, + name: artist.name, + date: thing.coverArtistContribs?.some( + ({who}) => who === artist + ) + ? thing.coverArtDate + : thing.date, + } + ); + }) + .filter(Boolean) + .sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0)) + ).reverse(), + + // (Ok we did it again.) + // This is a kinda naughty hack, 8ut like, it's the only place + // we'd 8e passing wikiData to html() otherwise, so like.... + showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames, + }; + }, + + html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) { + return fixWS` <div class="content-columns"> <div class="column"> - <h2>${language.$('listingPage.misc.trackContributors')}</h2> + <h2>${language.$( + 'listingPage.misc.trackContributors' + )}</h2> <ul> - ${(toTracks - .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', { + ${toTracks + .map(({artist, date}) => + language.$( + 'listingPage.listArtists.byLatest.item', + { artist: link.artist(artist), - date: language.formatDate(date) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + date: language.formatDate(date), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> </div> <div class="column"> - <h2>${language.$('listingPage.misc' + + <h2>${language.$( + 'listingPage.misc' + (showAsFlashes - ? '.artAndFlashContributors' - : '.artContributors'))}</h2> + ? '.artAndFlashContributors' + : '.artContributors') + )}</h2> <ul> - ${(toArtAndFlashes - .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', { + ${toArtAndFlashes + .map(({artist, date}) => + language.$( + 'listingPage.listArtists.byLatest.item', + { artist: link.artist(artist), - date: language.formatDate(date) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + date: language.formatDate(date), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> </div> </div> `; - } }, - - { - directory: 'groups/by-name', - stringsKey: 'listGroups.byName', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - data: ({wikiData}) => sortAlphabetically(wikiData.groupData.slice()), - - row(group, {link, language}) { - return language.$('listingPage.listGroups.byCategory.group', { - group: link.groupInfo(group), - gallery: link.groupGallery(group, { - text: language.$('listingPage.listGroups.byCategory.group.gallery') - }) - }); - } + }, + + { + directory: 'groups/by-name', + stringsKey: 'listGroups.byName', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + data: ({wikiData}) => sortAlphabetically(wikiData.groupData.slice()), + + row(group, {link, language}) { + return language.$('listingPage.listGroups.byCategory.group', { + group: link.groupInfo(group), + gallery: link.groupGallery(group, { + text: language.$('listingPage.listGroups.byCategory.group.gallery'), + }), + }); }, + }, - { - directory: 'groups/by-category', - stringsKey: 'listGroups.byCategory', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - data: ({wikiData}) => wikiData.groupCategoryData, + { + directory: 'groups/by-category', + stringsKey: 'listGroups.byCategory', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + data: ({wikiData}) => wikiData.groupCategoryData, - html(groupCategoryData, {link, language}) { - return fixWS` + html(groupCategoryData, {link, language}) { + return fixWS` <dl> - ${groupCategoryData.map(category => fixWS` - <dt>${language.$('listingPage.listGroups.byCategory.category', { - category: link.groupInfo(category.groups[0], {text: category.name}) - })}</dt> + ${groupCategoryData + .map( + (category) => fixWS` + <dt>${language.$( + 'listingPage.listGroups.byCategory.category', + { + category: link.groupInfo(category.groups[0], { + text: category.name, + }), + } + )}</dt> <dd><ul> - ${(category.groups - .map(group => language.$('listingPage.listGroups.byCategory.group', { + ${category.groups + .map((group) => + language.$( + 'listingPage.listGroups.byCategory.group', + { group: link.groupInfo(group), gallery: link.groupGallery(group, { - text: language.$('listingPage.listGroups.byCategory.group.gallery') - }) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + text: language.$( + 'listingPage.listGroups.byCategory.group.gallery' + ), + }), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } - }, - - { - directory: 'groups/by-albums', - stringsKey: 'listGroups.byAlbums', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - - data({wikiData}) { - return wikiData.groupData - .map(group => ({group, albums: group.albums.length})) - .sort((a, b) => b.albums - a.albums); - }, - - row({group, albums}, {link, language}) { - return language.$('listingPage.listGroups.byAlbums.item', { - group: link.groupInfo(group), - albums: language.countAlbums(albums, {unit: true}) - }); - } - }, - - { - directory: 'groups/by-tracks', - stringsKey: 'listGroups.byTracks', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - - data({wikiData}) { - return wikiData.groupData - .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)})) - .sort((a, b) => b.tracks - a.tracks); - }, - - row({group, tracks}, {link, language}) { - return language.$('listingPage.listGroups.byTracks.item', { - group: link.groupInfo(group), - tracks: language.countTracks(tracks, {unit: true}) - }); - } - }, - - { - directory: 'groups/by-duration', - stringsKey: 'listGroups.byDuration', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - - data({wikiData}) { - return wikiData.groupData - .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))})) - .sort((a, b) => b.duration - a.duration); - }, - - row({group, duration}, {link, language}) { - return language.$('listingPage.listGroups.byDuration.item', { - group: link.groupInfo(group), - duration: language.formatDuration(duration) - }); - } - }, - - { - directory: 'groups/by-latest-album', - stringsKey: 'listGroups.byLatest', - condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, - - data({wikiData}) { - return sortChronologically(wikiData.groupData - .map(group => { - const albums = group.albums.filter(a => a.date); - return albums.length && { - group, - directory: group.directory, - name: group.name, - date: albums[albums.length - 1].date - }; - }) - .filter(Boolean) - // So this is kinda tough to explain, 8ut 8asically, when we - // reverse the list after sorting it 8y d8te (so that the latest - // d8tes come first), it also flips the order of groups which - // share the same d8te. This happens mostly when a single al8um - // is the l8test in two groups. So, say one such al8um is in the - // groups "Fandom" and "UMSPAF". Per category order, Fandom is - // meant to show up 8efore UMSPAF, 8ut when we do the reverse - // l8ter, that flips them, and UMSPAF ends up displaying 8efore - // Fandom. So we do an extra reverse here, which will fix that - // and only affect groups that share the same d8te (8ecause - // groups that don't will 8e moved 8y the sortChronologically - // call surrounding this). - .reverse()).reverse() - }, - - row({group, date}, {link, language}) { - return language.$('listingPage.listGroups.byLatest.item', { - group: link.groupInfo(group), - date: language.formatDate(date) - }); - } - }, - - { - directory: 'tracks/by-name', - stringsKey: 'listTracks.byName', - - data({wikiData}) { - return sortAlphabetically(wikiData.trackData.slice()); - }, - - row(track, {link, language}) { - return language.$('listingPage.listTracks.byName.item', { - track: link.track(track) - }); - } - }, - - { - directory: 'tracks/by-album', - stringsKey: 'listTracks.byAlbum', - data: ({wikiData}) => wikiData.albumData, - - html(albumData, {link, language}) { - return fixWS` + }, + }, + + { + directory: 'groups/by-albums', + stringsKey: 'listGroups.byAlbums', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + + data({wikiData}) { + return wikiData.groupData + .map((group) => ({group, albums: group.albums.length})) + .sort((a, b) => b.albums - a.albums); + }, + + row({group, albums}, {link, language}) { + return language.$('listingPage.listGroups.byAlbums.item', { + group: link.groupInfo(group), + albums: language.countAlbums(albums, {unit: true}), + }); + }, + }, + + { + directory: 'groups/by-tracks', + stringsKey: 'listGroups.byTracks', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + + data({wikiData}) { + return wikiData.groupData + .map((group) => ({ + group, + tracks: group.albums.reduce( + (acc, album) => acc + album.tracks.length, + 0 + ), + })) + .sort((a, b) => b.tracks - a.tracks); + }, + + row({group, tracks}, {link, language}) { + return language.$('listingPage.listGroups.byTracks.item', { + group: link.groupInfo(group), + tracks: language.countTracks(tracks, {unit: true}), + }); + }, + }, + + { + directory: 'groups/by-duration', + stringsKey: 'listGroups.byDuration', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + + data({wikiData}) { + return wikiData.groupData + .map((group) => ({ + group, + duration: getTotalDuration( + group.albums.flatMap((album) => album.tracks) + ), + })) + .sort((a, b) => b.duration - a.duration); + }, + + row({group, duration}, {link, language}) { + return language.$('listingPage.listGroups.byDuration.item', { + group: link.groupInfo(group), + duration: language.formatDuration(duration), + }); + }, + }, + + { + directory: 'groups/by-latest-album', + stringsKey: 'listGroups.byLatest', + condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI, + + data({wikiData}) { + return sortChronologically( + wikiData.groupData + .map((group) => { + const albums = group.albums.filter((a) => a.date); + return ( + albums.length && { + group, + directory: group.directory, + name: group.name, + date: albums[albums.length - 1].date, + } + ); + }) + .filter(Boolean) + // So this is kinda tough to explain, 8ut 8asically, when we + // reverse the list after sorting it 8y d8te (so that the latest + // d8tes come first), it also flips the order of groups which + // share the same d8te. This happens mostly when a single al8um + // is the l8test in two groups. So, say one such al8um is in the + // groups "Fandom" and "UMSPAF". Per category order, Fandom is + // meant to show up 8efore UMSPAF, 8ut when we do the reverse + // l8ter, that flips them, and UMSPAF ends up displaying 8efore + // Fandom. So we do an extra reverse here, which will fix that + // and only affect groups that share the same d8te (8ecause + // groups that don't will 8e moved 8y the sortChronologically + // call surrounding this). + .reverse() + ).reverse(); + }, + + row({group, date}, {link, language}) { + return language.$('listingPage.listGroups.byLatest.item', { + group: link.groupInfo(group), + date: language.formatDate(date), + }); + }, + }, + + { + directory: 'tracks/by-name', + stringsKey: 'listTracks.byName', + + data({wikiData}) { + return sortAlphabetically(wikiData.trackData.slice()); + }, + + row(track, {link, language}) { + return language.$('listingPage.listTracks.byName.item', { + track: link.track(track), + }); + }, + }, + + { + directory: 'tracks/by-album', + stringsKey: 'listTracks.byAlbum', + data: ({wikiData}) => wikiData.albumData, + + html(albumData, {link, language}) { + return fixWS` <dl> - ${albumData.map(album => fixWS` - <dt>${language.$('listingPage.listTracks.byAlbum.album', { - album: link.album(album) - })}</dt> + ${albumData + .map( + (album) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.byAlbum.album', + { + album: link.album(album), + } + )}</dt> <dd><ol> - ${(album.tracks - .map(track => language.$('listingPage.listTracks.byAlbum.track', { - track: link.track(track) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${album.tracks + .map((track) => + language.$( + 'listingPage.listTracks.byAlbum.track', + { + track: link.track(track), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ol></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } }, + }, - { - directory: 'tracks/by-date', - stringsKey: 'listTracks.byDate', + { + directory: 'tracks/by-date', + stringsKey: 'listTracks.byDate', - data({wikiData}) { - return chunkByProperties( - sortChronologically(wikiData.trackData.filter(t => t.date)), - ['album', 'date'] - ); - }, + data({wikiData}) { + return chunkByProperties( + sortChronologically(wikiData.trackData.filter((t) => t.date)), + ['album', 'date'] + ); + }, - html(chunks, {link, language}) { - return fixWS` + html(chunks, {link, language}) { + return fixWS` <dl> - ${chunks.map(({album, date, chunk: tracks}) => fixWS` - <dt>${language.$('listingPage.listTracks.byDate.album', { + ${chunks + .map( + ({album, date, chunk: tracks}) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.byDate.album', + { album: link.album(album), - date: language.formatDate(date) - })}</dt> + date: language.formatDate(date), + } + )}</dt> <dd><ul> - ${(tracks - .map(track => track.aka - ? `<li class="rerelease">${language.$('listingPage.listTracks.byDate.track.rerelease', { - track: link.track(track) - })}</li>` - : `<li>${language.$('listingPage.listTracks.byDate.track', { - track: link.track(track) - })}</li>`) - .join('\n'))} + ${tracks + .map((track) => + track.aka + ? `<li class="rerelease">${language.$( + 'listingPage.listTracks.byDate.track.rerelease', + { + track: link.track(track), + } + )}</li>` + : `<li>${language.$( + 'listingPage.listTracks.byDate.track', + { + track: link.track(track), + } + )}</li>` + ) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } }, + }, - { - directory: 'tracks/by-duration', - stringsKey: 'listTracks.byDuration', + { + directory: 'tracks/by-duration', + stringsKey: 'listTracks.byDuration', - data({wikiData}) { - return wikiData.trackData - .map(track => ({track, duration: track.duration})) - .filter(({ duration }) => duration > 0) - .sort((a, b) => b.duration - a.duration); - }, - - row({track, duration}, {link, language}) { - return language.$('listingPage.listTracks.byDuration.item', { - track: link.track(track), - duration: language.formatDuration(duration) - }); - } + data({wikiData}) { + return wikiData.trackData + .map((track) => ({track, duration: track.duration})) + .filter(({duration}) => duration > 0) + .sort((a, b) => b.duration - a.duration); }, - { - directory: 'tracks/by-duration-in-album', - stringsKey: 'listTracks.byDurationInAlbum', - - data({wikiData}) { - return wikiData.albumData.map(album => ({ - album, - tracks: album.tracks.slice().sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)) - })); - }, + row({track, duration}, {link, language}) { + return language.$('listingPage.listTracks.byDuration.item', { + track: link.track(track), + duration: language.formatDuration(duration), + }); + }, + }, + + { + directory: 'tracks/by-duration-in-album', + stringsKey: 'listTracks.byDurationInAlbum', + + data({wikiData}) { + return wikiData.albumData.map((album) => ({ + album, + tracks: album.tracks + .slice() + .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)), + })); + }, - html(albums, {link, language}) { - return fixWS` + html(albums, {link, language}) { + return fixWS` <dl> - ${albums.map(({album, tracks}) => fixWS` - <dt>${language.$('listingPage.listTracks.byDurationInAlbum.album', { - album: link.album(album) - })}</dt> + ${albums + .map( + ({album, tracks}) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.byDurationInAlbum.album', + { + album: link.album(album), + } + )}</dt> <dd><ul> - ${(tracks - .map(track => language.$('listingPage.listTracks.byDurationInAlbum.track', { + ${tracks + .map((track) => + language.$( + 'listingPage.listTracks.byDurationInAlbum.track', + { track: link.track(track), - duration: language.formatDuration(track.duration ?? 0) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + duration: language.formatDuration( + track.duration ?? 0 + ), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </dd></ul> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } }, - - { - directory: 'tracks/by-times-referenced', - stringsKey: 'listTracks.byTimesReferenced', - - data({wikiData}) { - return wikiData.trackData - .map(track => ({track, timesReferenced: track.referencedByTracks.length})) - .filter(({ timesReferenced }) => timesReferenced > 0) - .sort((a, b) => b.timesReferenced - a.timesReferenced); - }, - - row({track, timesReferenced}, {link, language}) { - return language.$('listingPage.listTracks.byTimesReferenced.item', { - track: link.track(track), - timesReferenced: language.countTimesReferenced(timesReferenced, {unit: true}) - }); - } + }, + + { + directory: 'tracks/by-times-referenced', + stringsKey: 'listTracks.byTimesReferenced', + + data({wikiData}) { + return wikiData.trackData + .map((track) => ({ + track, + timesReferenced: track.referencedByTracks.length, + })) + .filter(({timesReferenced}) => timesReferenced > 0) + .sort((a, b) => b.timesReferenced - a.timesReferenced); }, - { - directory: 'tracks/in-flashes/by-album', - stringsKey: 'listTracks.inFlashes.byAlbum', - condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames, - - data({wikiData}) { - return chunkByProperties(wikiData.trackData - .filter(t => t.featuredInFlashes?.length > 0), ['album']); - }, + row({track, timesReferenced}, {link, language}) { + return language.$('listingPage.listTracks.byTimesReferenced.item', { + track: link.track(track), + timesReferenced: language.countTimesReferenced(timesReferenced, { + unit: true, + }), + }); + }, + }, + + { + directory: 'tracks/in-flashes/by-album', + stringsKey: 'listTracks.inFlashes.byAlbum', + condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames, + + data({wikiData}) { + return chunkByProperties( + wikiData.trackData.filter((t) => t.featuredInFlashes?.length > 0), + ['album'] + ); + }, - html(chunks, {link, language}) { - return fixWS` + html(chunks, {link, language}) { + return fixWS` <dl> - ${chunks.map(({album, chunk: tracks}) => fixWS` - <dt>${language.$('listingPage.listTracks.inFlashes.byAlbum.album', { + ${chunks + .map( + ({album, chunk: tracks}) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.inFlashes.byAlbum.album', + { album: link.album(album), - date: language.formatDate(album.date) - })}</dt> + date: language.formatDate(album.date), + } + )}</dt> <dd><ul> - ${(tracks - .map(track => language.$('listingPage.listTracks.inFlashes.byAlbum.track', { + ${tracks + .map((track) => + language.$( + 'listingPage.listTracks.inFlashes.byAlbum.track', + { track: link.track(track), - flashes: language.formatConjunctionList(track.featuredInFlashes.map(link.flash)) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + flashes: language.formatConjunctionList( + track.featuredInFlashes.map(link.flash) + ), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </dd></ul> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } }, + }, - { - directory: 'tracks/in-flashes/by-flash', - stringsKey: 'listTracks.inFlashes.byFlash', - condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames, - data: ({wikiData}) => wikiData.flashData, + { + directory: 'tracks/in-flashes/by-flash', + stringsKey: 'listTracks.inFlashes.byFlash', + condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames, + data: ({wikiData}) => wikiData.flashData, - html(flashData, {link, language}) { - return fixWS` + html(flashData, {link, language}) { + return fixWS` <dl> - ${sortChronologically(flashData.slice()).map(flash => fixWS` - <dt>${language.$('listingPage.listTracks.inFlashes.byFlash.flash', { + ${sortChronologically(flashData.slice()) + .map( + (flash) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.inFlashes.byFlash.flash', + { flash: link.flash(flash), - date: language.formatDate(flash.date) - })}</dt> + date: language.formatDate(flash.date), + } + )}</dt> <dd><ul> - ${(flash.featuredTracks - .map(track => language.$('listingPage.listTracks.inFlashes.byFlash.track', { + ${flash.featuredTracks + .map((track) => + language.$( + 'listingPage.listTracks.inFlashes.byFlash.track', + { track: link.track(track), - album: link.album(track.album) - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + album: link.album(track.album), + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } + }, + }, + + { + directory: 'tracks/with-lyrics', + stringsKey: 'listTracks.withLyrics', + + data({wikiData}) { + return wikiData.albumData + .map((album) => ({ + album, + tracks: album.tracks.filter((t) => t.lyrics), + })) + .filter(({tracks}) => tracks.length > 0); }, - { - directory: 'tracks/with-lyrics', - stringsKey: 'listTracks.withLyrics', - - data({wikiData}) { - return wikiData.albumData.map(album => ({ - album, - tracks: album.tracks.filter(t => t.lyrics) - })).filter(({ tracks }) => tracks.length > 0); - }, - - html(chunks, {link, language}) { - return fixWS` + html(chunks, {link, language}) { + return fixWS` <dl> - ${chunks.map(({album, tracks}) => fixWS` - <dt>${language.$('listingPage.listTracks.withLyrics.album', { + ${chunks + .map( + ({album, tracks}) => fixWS` + <dt>${language.$( + 'listingPage.listTracks.withLyrics.album', + { album: link.album(album), - date: language.formatDate(album.date) - })}</dt> + date: language.formatDate(album.date), + } + )}</dt> <dd><ul> - ${(tracks - .map(track => language.$('listingPage.listTracks.withLyrics.track', { + ${tracks + .map((track) => + language.$( + 'listingPage.listTracks.withLyrics.track', + { track: link.track(track), - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + } + ) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </dd></ul> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - } - }, - - { - directory: 'tags/by-name', - stringsKey: 'listTags.byName', - condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI, - - data({wikiData}) { - return sortAlphabetically(wikiData.artTagData.filter(tag => !tag.isContentWarning)) - .map(tag => ({tag, timesUsed: tag.taggedInThings?.length})); - }, - - row({tag, timesUsed}, {link, language}) { - return language.$('listingPage.listTags.byName.item', { - tag: link.tag(tag), - timesUsed: language.countTimesUsed(timesUsed, {unit: true}) - }); - } - }, - - { - directory: 'tags/by-uses', - stringsKey: 'listTags.byUses', - condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI, - - data({wikiData}) { - return wikiData.artTagData - .filter(tag => !tag.isContentWarning) - .map(tag => ({tag, timesUsed: tag.taggedInThings?.length})) - .sort((a, b) => b.timesUsed - a.timesUsed); - }, - - row({tag, timesUsed}, {link, language}) { - return language.$('listingPage.listTags.byUses.item', { - tag: link.tag(tag), - timesUsed: language.countTimesUsed(timesUsed, {unit: true}) - }); - } - }, - - { - directory: 'random', - stringsKey: 'other.randomPages', - - data: ({wikiData}) => ({ - officialAlbumData: wikiData.officialAlbumData, - fandomAlbumData: wikiData.fandomAlbumData - }), + }, + }, + + { + directory: 'tags/by-name', + stringsKey: 'listTags.byName', + condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI, + + data({wikiData}) { + return sortAlphabetically( + wikiData.artTagData.filter((tag) => !tag.isContentWarning) + ).map((tag) => ({tag, timesUsed: tag.taggedInThings?.length})); + }, + + row({tag, timesUsed}, {link, language}) { + return language.$('listingPage.listTags.byName.item', { + tag: link.tag(tag), + timesUsed: language.countTimesUsed(timesUsed, {unit: true}), + }); + }, + }, + + { + directory: 'tags/by-uses', + stringsKey: 'listTags.byUses', + condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI, + + data({wikiData}) { + return wikiData.artTagData + .filter((tag) => !tag.isContentWarning) + .map((tag) => ({tag, timesUsed: tag.taggedInThings?.length})) + .sort((a, b) => b.timesUsed - a.timesUsed); + }, + + row({tag, timesUsed}, {link, language}) { + return language.$('listingPage.listTags.byUses.item', { + tag: link.tag(tag), + timesUsed: language.countTimesUsed(timesUsed, {unit: true}), + }); + }, + }, + + { + directory: 'random', + stringsKey: 'other.randomPages', + + data: ({wikiData}) => ({ + officialAlbumData: wikiData.officialAlbumData, + fandomAlbumData: wikiData.fandomAlbumData, + }), - html: ({officialAlbumData, fandomAlbumData}, { - getLinkThemeString, - language - }) => fixWS` + html: ( + {officialAlbumData, fandomAlbumData}, + {getLinkThemeString} + ) => fixWS` <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p> <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p> <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p> @@ -780,49 +983,73 @@ const listingSpec = [ <li><a href="#" data-random="track">Random Track (whole site)</a></li> </ul></dd> ${[ - {name: 'Official', albumData: officialAlbumData, code: 'official'}, - {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'} - ].map(category => fixWS` - <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt> - <dd><ul>${category.albumData.map(album => fixWS` - <li><a style="${getLinkThemeString(album.color)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li> - `).join('\n')}</ul></dd> - `).join('\n')} + { + name: 'Official', + albumData: officialAlbumData, + code: 'official', + }, + { + name: 'Fandom', + albumData: fandomAlbumData, + code: 'fandom', + }, + ] + .map( + (category) => fixWS` + <dt>${category.name}: (<a href="#" data-random="album-in-${ + category.code + }">Random Album</a>, <a href="#" data-random="track-in-${ + category.code + }">Random Track</a>)</dt> + <dd><ul>${category.albumData + .map( + (album) => fixWS` + <li><a style="${getLinkThemeString( + album.color + )}; --album-directory: ${ + album.directory + }" href="#" data-random="track-in-album">${ + album.name + }</a></li> + ` + ) + .join('\n')}</ul></dd> + ` + ) + .join('\n')} </dl> - ` - } + `, + }, ]; -const filterListings = directoryPrefix => listingSpec - .filter(l => l.directory.startsWith(directoryPrefix)); +const filterListings = (directoryPrefix) => + listingSpec.filter((l) => l.directory.startsWith(directoryPrefix)); const listingTargetSpec = [ - { - title: ({language}) => language.$('listingPage.target.album'), - listings: filterListings('album') - }, - { - title: ({language}) => language.$('listingPage.target.artist'), - listings: filterListings('artist') - }, - { - title: ({language}) => language.$('listingPage.target.group'), - listings: filterListings('group') - }, - { - title: ({language}) => language.$('listingPage.target.track'), - listings: filterListings('track') - }, - { - title: ({language}) => language.$('listingPage.target.tag'), - listings: filterListings('tag') - }, - { - title: ({language}) => language.$('listingPage.target.other'), - listings: [ - listingSpec.find(l => l.directory === 'random') - ] - } + { + title: ({language}) => language.$('listingPage.target.album'), + listings: filterListings('album'), + }, + { + title: ({language}) => language.$('listingPage.target.artist'), + listings: filterListings('artist'), + }, + { + title: ({language}) => language.$('listingPage.target.group'), + listings: filterListings('group'), + }, + { + title: ({language}) => language.$('listingPage.target.track'), + listings: filterListings('track'), + }, + { + title: ({language}) => language.$('listingPage.target.tag'), + listings: filterListings('tag'), + }, + { + title: ({language}) => language.$('listingPage.target.other'), + listings: [listingSpec.find((l) => l.directory === 'random')], + }, ]; export {listingSpec, listingTargetSpec}; diff --git a/src/misc-templates.js b/src/misc-templates.js index 61afa710..53a05950 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -1,3 +1,5 @@ +/** @format */ + // Miscellaneous utility functions which are useful across page specifications. // These are made available right on a page spec's ({wikiData, language, ...}) // args object! @@ -6,99 +8,126 @@ import fixWS from 'fix-whitespace'; import * as html from './util/html.js'; -import { - Track, - Album, -} from './data/things.js'; +import {Track, Album} from './data/things.js'; -import { - getColors -} from './util/colors.js'; +import {getColors} from './util/colors.js'; -import { - unique -} from './util/sugar.js'; +import {unique} from './util/sugar.js'; import { - getTotalDuration, - sortAlbumsTracksChronologically, - sortChronologically, + getTotalDuration, + sortAlbumsTracksChronologically, + sortChronologically, } from './util/wiki-data.js'; -const BANDCAMP_DOMAINS = [ - 'bc.s3m.us', - 'music.solatrux.com', -]; +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; -const MASTODON_DOMAINS = [ - 'types.pl', -]; +const MASTODON_DOMAINS = ['types.pl']; // "Additional Files" listing export function generateAdditionalFilesShortcut(additionalFiles, {language}) { - if (!additionalFiles?.length) return ''; - - return language.$('releaseInfo.additionalFiles.shortcut', { - anchorLink: `<a href="#additional-files">${language.$('releaseInfo.additionalFiles.shortcut.anchorLink')}</a>`, - titles: language.formatUnitList(additionalFiles.map(g => g.title)) - }); + if (!additionalFiles?.length) return ''; + + return language.$('releaseInfo.additionalFiles.shortcut', { + anchorLink: `<a href="#additional-files">${language.$( + 'releaseInfo.additionalFiles.shortcut.anchorLink' + )}</a>`, + titles: language.formatUnitList(additionalFiles.map((g) => g.title)), + }); } -export function generateAdditionalFilesList(additionalFiles, {language, getFileSize, linkFile}) { - if (!additionalFiles?.length) return ''; +export function generateAdditionalFilesList( + additionalFiles, + {language, getFileSize, linkFile} +) { + if (!additionalFiles?.length) return ''; - const fileCount = additionalFiles.flatMap(g => g.files).length; + const fileCount = additionalFiles.flatMap((g) => g.files).length; - return fixWS` - <p id="additional-files">${language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: language.countAdditionalFiles(fileCount, {unit: true}) - })}</p> + return fixWS` + <p id="additional-files">${language.$( + 'releaseInfo.additionalFiles.heading', + { + additionalFiles: language.countAdditionalFiles(fileCount, { + unit: true, + }), + } + )}</p> <dl> - ${additionalFiles.map(({ title, description, files }) => fixWS` - <dt>${(description - ? language.$('releaseInfo.additionalFiles.entry.withDescription', {title, description}) - : language.$('releaseInfo.additionalFiles.entry', {title}))}</dt> + ${additionalFiles + .map( + ({title, description, files}) => fixWS` + <dt>${ + description + ? language.$( + 'releaseInfo.additionalFiles.entry.withDescription', + { + title, + description, + } + ) + : language.$('releaseInfo.additionalFiles.entry', {title}) + }</dt> <dd><ul> - ${files.map(file => { + ${files + .map((file) => { const size = getFileSize(file); - return (size - ? `<li>${language.$('releaseInfo.additionalFiles.file.withSize', { + return size + ? `<li>${language.$( + 'releaseInfo.additionalFiles.file.withSize', + { file: linkFile(file), - size: language.formatFileSize(getFileSize(file)) - })}</li>` - : `<li>${language.$('releaseInfo.additionalFiles.file', { - file: linkFile(file) - })}</li>`); - }).join('\n')} + size: language.formatFileSize( + getFileSize(file) + ), + } + )}</li>` + : `<li>${language.$( + 'releaseInfo.additionalFiles.file', + { + file: linkFile(file), + } + )}</li>`; + }) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; } // Artist strings -export function getArtistString(artists, { - iconifyURL, link, language, - showIcons = false, - showContrib = false -}) { - return language.formatConjunctionList(artists.map(({ who, what }) => { - const { urls, directory, name } = who; - return [ - link.artist(who), - showContrib && what && `(${what})`, - showIcons && urls?.length && `<span class="icons">(${ - language.formatUnitList(urls.map(url => iconifyURL(url, {language}))) - })</span>` - ].filter(Boolean).join(' '); - })); +export function getArtistString( + artists, + {iconifyURL, link, language, showIcons = false, showContrib = false} +) { + return language.formatConjunctionList( + artists.map(({who, what}) => { + const {urls} = who; + return [ + link.artist(who), + showContrib && what && `(${what})`, + showIcons && + urls?.length && + `<span class="icons">(${language.formatUnitList( + urls.map((url) => iconifyURL(url, {language})) + )})</span>`, + ] + .filter(Boolean) + .join(' '); + }) + ); } // Chronology links -export function generateChronologyLinks(currentThing, { +export function generateChronologyLinks( + currentThing, + { dateKey = 'date', contribKey, getThings, @@ -106,116 +135,152 @@ export function generateChronologyLinks(currentThing, { link, linkAnythingMan, language, - wikiData -}) { - const { albumData } = wikiData; + } +) { + const contributions = currentThing[contribKey]; + if (!contributions) { + return ''; + } + + if (contributions.length > 8) { + return `<div class="chronology">${language.$( + 'misc.chronology.seeArtistPages' + )}</div>`; + } + + return contributions + .map(({who: artist}) => { + const thingsUnsorted = unique(getThings(artist)).filter( + (t) => t[dateKey] + ); + + // Kinda a hack, but we automatically detect which is (probably) the + // right function to use here. + const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}]; + const things = thingsUnsorted.every( + (t) => t instanceof Album || t instanceof Track + ) + ? sortAlbumsTracksChronologically(...args) + : sortChronologically(...args); + + const index = things.indexOf(currentThing); + + if (index === -1) return ''; + + // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks? + // We'd need to make generatePreviousNextLinks use toAnythingMan tho. + const previous = things[index - 1]; + const next = things[index + 1]; + const parts = [ + previous && + linkAnythingMan(previous, { + color: false, + text: language.$('misc.nav.previous'), + }), + next && + linkAnythingMan(next, { + color: false, + text: language.$('misc.nav.next'), + }), + ].filter(Boolean); - const contributions = currentThing[contribKey]; - if (!contributions) { + if (!parts.length) { return ''; - } - - if (contributions.length > 8) { - return `<div class="chronology">${language.$('misc.chronology.seeArtistPages')}</div>`; - } - - return contributions.map(({ who: artist }) => { - const thingsUnsorted = unique(getThings(artist)).filter(t => t[dateKey]); - - // Kinda a hack, but we automatically detect which is (probably) the - // right function to use here. - const args = [thingsUnsorted, {getDate: t => t[dateKey]}]; - const things = (thingsUnsorted.every(t => t instanceof Album || t instanceof Track) - ? sortAlbumsTracksChronologically(...args) - : sortChronologically(...args)); + } - const index = things.indexOf(currentThing); + const stringOpts = { + index: language.formatIndex(index + 1, {language}), + artist: link.artist(artist), + }; - if (index === -1) return ''; - - // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks? - // We'd need to make generatePreviousNextLinks use toAnythingMan tho. - const previous = things[index - 1]; - const next = things[index + 1]; - const parts = [ - previous && linkAnythingMan(previous, { - color: false, - text: language.$('misc.nav.previous') - }), - next && linkAnythingMan(next, { - color: false, - text: language.$('misc.nav.next') - }) - ].filter(Boolean); - - if (!parts.length) { - return ''; - } - - const stringOpts = { - index: language.formatIndex(index + 1, {language}), - artist: link.artist(artist) - }; - - return fixWS` + return fixWS` <div class="chronology"> - <span class="heading">${language.$(headingString, stringOpts)}</span> - ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`} + <span class="heading">${language.$( + headingString, + stringOpts + )}</span> + ${ + parts.length && + `<span class="buttons">(${parts.join(', ')})</span>` + } </div> `; - }).filter(Boolean).join('\n'); + }) + .filter(Boolean) + .join('\n'); } // Content warning tags export function getRevealStringFromWarnings(warnings, {language}) { - return language.$('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${language.$('misc.contentWarnings.reveal')}</span>` + return ( + language.$('misc.contentWarnings', {warnings}) + + `<br><span class="reveal-interaction">${language.$( + 'misc.contentWarnings.reveal' + )}</span>` + ); } export function getRevealStringFromTags(tags, {language}) { - return tags && tags.some(tag => tag.isContentWarning) && ( - getRevealStringFromWarnings(language.formatUnitList(tags.filter(tag => tag.isContentWarning).map(tag => tag.name)), {language})); + return ( + tags && + tags.some((tag) => tag.isContentWarning) && + getRevealStringFromWarnings( + language.formatUnitList( + tags.filter((tag) => tag.isContentWarning).map((tag) => tag.name) + ), + {language} + ) + ); } // Cover art links export function generateCoverLink({ - img, link, language, to, wikiData, - src, - path, - alt, - tags = [] + img, + link, + language, + to, + wikiData, + src, + path, + alt, + tags = [], }) { - const { wikiInfo } = wikiData; + const {wikiInfo} = wikiData; - if (!src && path) { - src = to(...path); - } + if (!src && path) { + src = to(...path); + } - if (!src) { - throw new Error(`Expected src or path`); - } + if (!src) { + throw new Error(`Expected src or path`); + } - return fixWS` + return fixWS` <div id="cover-art-container"> ${img({ - src, - alt, - thumb: 'medium', - id: 'cover-art', - link: true, - square: true, - reveal: getRevealStringFromTags(tags, {language}) + src, + alt, + thumb: 'medium', + id: 'cover-art', + link: true, + square: true, + reveal: getRevealStringFromTags(tags, {language}), })} - ${wikiInfo.enableArtTagUI && tags.filter(tag => !tag.isContentWarning).length && fixWS` + ${ + wikiInfo.enableArtTagUI && + tags.filter((tag) => !tag.isContentWarning).length && + fixWS` <p class="tags"> ${language.$('releaseInfo.artTags')} - ${(tags - .filter(tag => !tag.isContentWarning) - .map(link.tag) - .join(',\n'))} + ${tags + .filter((tag) => !tag.isContentWarning) + .map(link.tag) + .join(',\n')} </p> - `} + ` + } </div> `; } @@ -223,288 +288,359 @@ export function generateCoverLink({ // CSS & color shenanigans export function getThemeString(color, additionalVariables = []) { - if (!color) return ''; + if (!color) return ''; - const { primary, dim, bg } = getColors(color); + const {primary, dim, bg} = getColors(color); - const variables = [ - `--primary-color: ${primary}`, - `--dim-color: ${dim}`, - `--bg-color: ${bg}`, - ...additionalVariables - ].filter(Boolean); + const variables = [ + `--primary-color: ${primary}`, + `--dim-color: ${dim}`, + `--bg-color: ${bg}`, + ...additionalVariables, + ].filter(Boolean); - if (!variables.length) return ''; + if (!variables.length) return ''; - return ( - `:root {\n` + - variables.map(line => ` ` + line + ';\n').join('') + - `}` - ); + return ( + `:root {\n` + variables.map((line) => ` ` + line + ';\n').join('') + `}` + ); } export function getAlbumStylesheet(album, {to}) { - return [ - album.wallpaperArtistContribs.length && fixWS` + return [ + album.wallpaperArtistContribs.length && + fixWS` body::before { - background-image: url("${to('media.albumWallpaper', album.directory, album.wallpaperFileExtension)}"); + background-image: url("${to( + 'media.albumWallpaper', + album.directory, + album.wallpaperFileExtension + )}"); ${album.wallpaperStyle} } `, - album.bannerStyle && fixWS` + album.bannerStyle && + fixWS` #banner img { ${album.bannerStyle} } - ` - ].filter(Boolean).join('\n'); + `, + ] + .filter(Boolean) + .join('\n'); } // Divided track lists -export function generateTrackListDividedByGroups(tracks, { - getTrackItem, - language, - wikiData, -}) { - const { divideTrackListsByGroups: groups } = wikiData.wikiInfo; +export function generateTrackListDividedByGroups( + tracks, + {getTrackItem, language, wikiData} +) { + const {divideTrackListsByGroups: groups} = wikiData.wikiInfo; - if (!groups?.length) { - return html.tag('ul', tracks.map(t => getTrackItem(t))); - } - - const lists = Object.fromEntries(groups.map(group => [group.directory, {group, tracks: []}])); - const other = []; - - for (const track of tracks) { - const { album } = track; - const group = groups.find(g => g.albums.includes(album)); - if (group) { - lists[group.directory].tracks.push(track); - } else { - other.push(track); - } + if (!groups?.length) { + return html.tag( + 'ul', + tracks.map((t) => getTrackItem(t)) + ); + } + + const lists = Object.fromEntries( + groups.map((group) => [group.directory, {group, tracks: []}]) + ); + const other = []; + + for (const track of tracks) { + const {album} = track; + const group = groups.find((g) => g.albums.includes(album)); + if (group) { + lists[group.directory].tracks.push(track); + } else { + other.push(track); } + } - const ddul = tracks => fixWS` + const ddul = (tracks) => fixWS` <dd><ul> - ${tracks.map(t => getTrackItem(t)).join('\n')} + ${tracks.map((t) => getTrackItem(t)).join('\n')} </ul></dd> `; - return html.tag('dl', Object.values(lists) - .filter(({ tracks }) => tracks.length) - .flatMap(({ group, tracks }) => [ - html.tag('dt', language.formatString('trackList.group', {group: group.name})), - ddul(tracks) - ]) - .concat(other.length ? [ - `<dt>${language.formatString('trackList.group', { - group: language.formatString('trackList.group.other') - })}</dt>`, - ddul(other) - ] : [])); + return html.tag( + 'dl', + Object.values(lists) + .filter(({tracks}) => tracks.length) + .flatMap(({group, tracks}) => [ + html.tag( + 'dt', + language.formatString('trackList.group', {group: group.name}) + ), + ddul(tracks), + ]) + .concat( + other.length + ? [ + `<dt>${language.formatString('trackList.group', { + group: language.formatString('trackList.group.other'), + })}</dt>`, + ddul(other), + ] + : [] + ) + ); } // Fancy lookin' links export function fancifyURL(url, {language, album = false} = {}) { - let local = Symbol(); - let domain; - try { - domain = new URL(url).hostname; - } catch (error) { - // No support for relative local URLs yet, sorry! (I.e, local URLs must - // be absolute relative to the domain name in order to work.) - domain = local; - } - return fixWS`<a href="${url}" class="nowrap">${ - domain === local ? language.$('misc.external.local') : - domain.includes('bandcamp.com') ? language.$('misc.external.bandcamp') : - BANDCAMP_DOMAINS.includes(domain) ? language.$('misc.external.bandcamp.domain', {domain}) : - MASTODON_DOMAINS.includes(domain) ? language.$('misc.external.mastodon.domain', {domain}) : - domain.includes('youtu') ? (album - ? (url.includes('list=') - ? language.$('misc.external.youtube.playlist') - : language.$('misc.external.youtube.fullAlbum')) - : language.$('misc.external.youtube')) : - domain.includes('soundcloud') ? language.$('misc.external.soundcloud') : - domain.includes('tumblr.com') ? language.$('misc.external.tumblr') : - domain.includes('twitter.com') ? language.$('misc.external.twitter') : - domain.includes('deviantart.com') ? language.$('misc.external.deviantart') : - domain.includes('wikipedia.org') ? language.$('misc.external.wikipedia') : - domain.includes('poetryfoundation.org') ? language.$('misc.external.poetryFoundation') : - domain.includes('instagram.com') ? language.$('misc.external.instagram') : - domain.includes('patreon.com') ? language.$('misc.external.patreon') : - domain - }</a>`; + let local = Symbol(); + let domain; + try { + domain = new URL(url).hostname; + } catch (error) { + // No support for relative local URLs yet, sorry! (I.e, local URLs must + // be absolute relative to the domain name in order to work.) + domain = local; + } + return fixWS`<a href="${url}" class="nowrap">${ + domain === local + ? language.$('misc.external.local') + : domain.includes('bandcamp.com') + ? language.$('misc.external.bandcamp') + : BANDCAMP_DOMAINS.includes(domain) + ? language.$('misc.external.bandcamp.domain', {domain}) + : MASTODON_DOMAINS.includes(domain) + ? language.$('misc.external.mastodon.domain', {domain}) + : domain.includes('youtu') + ? album + ? url.includes('list=') + ? language.$('misc.external.youtube.playlist') + : language.$('misc.external.youtube.fullAlbum') + : language.$('misc.external.youtube') + : domain.includes('soundcloud') + ? language.$('misc.external.soundcloud') + : domain.includes('tumblr.com') + ? language.$('misc.external.tumblr') + : domain.includes('twitter.com') + ? language.$('misc.external.twitter') + : domain.includes('deviantart.com') + ? language.$('misc.external.deviantart') + : domain.includes('wikipedia.org') + ? language.$('misc.external.wikipedia') + : domain.includes('poetryfoundation.org') + ? language.$('misc.external.poetryFoundation') + : domain.includes('instagram.com') + ? language.$('misc.external.instagram') + : domain.includes('patreon.com') + ? language.$('misc.external.patreon') + : domain + }</a>`; } export function fancifyFlashURL(url, flash, {language}) { - const link = fancifyURL(url, {language}); - return `<span class="nowrap">${ - url.includes('homestuck.com') ? (isNaN(Number(flash.page)) - ? language.$('misc.external.flash.homestuck.secret', {link}) - : language.$('misc.external.flash.homestuck.page', {link, page: flash.page})) : - url.includes('bgreco.net') ? language.$('misc.external.flash.bgreco', {link}) : - url.includes('youtu') ? language.$('misc.external.flash.youtube', {link}) : - link - }</span>`; + const link = fancifyURL(url, {language}); + return `<span class="nowrap">${ + url.includes('homestuck.com') + ? isNaN(Number(flash.page)) + ? language.$('misc.external.flash.homestuck.secret', {link}) + : language.$('misc.external.flash.homestuck.page', { + link, + page: flash.page, + }) + : url.includes('bgreco.net') + ? language.$('misc.external.flash.bgreco', {link}) + : url.includes('youtu') + ? language.$('misc.external.flash.youtube', {link}) + : link + }</span>`; } export function iconifyURL(url, {language, to}) { - const domain = new URL(url).hostname; - const [ id, msg ] = ( - domain.includes('bandcamp.com') ? ['bandcamp', language.$('misc.external.bandcamp')] : - BANDCAMP_DOMAINS.includes(domain) ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] : - MASTODON_DOMAINS.includes(domain) ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] : - domain.includes('youtu') ? ['youtube', language.$('misc.external.youtube')] : - domain.includes('soundcloud') ? ['soundcloud', language.$('misc.external.soundcloud')] : - domain.includes('tumblr.com') ? ['tumblr', language.$('misc.external.tumblr')] : - domain.includes('twitter.com') ? ['twitter', language.$('misc.external.twitter')] : - domain.includes('deviantart.com') ? ['deviantart', language.$('misc.external.deviantart')] : - domain.includes('instagram.com') ? ['instagram', language.$('misc.external.bandcamp')] : - ['globe', language.$('misc.external.domain', {domain})] - ); - return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`; + const domain = new URL(url).hostname; + const [id, msg] = domain.includes('bandcamp.com') + ? ['bandcamp', language.$('misc.external.bandcamp')] + : BANDCAMP_DOMAINS.includes(domain) + ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] + : MASTODON_DOMAINS.includes(domain) + ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] + : domain.includes('youtu') + ? ['youtube', language.$('misc.external.youtube')] + : domain.includes('soundcloud') + ? ['soundcloud', language.$('misc.external.soundcloud')] + : domain.includes('tumblr.com') + ? ['tumblr', language.$('misc.external.tumblr')] + : domain.includes('twitter.com') + ? ['twitter', language.$('misc.external.twitter')] + : domain.includes('deviantart.com') + ? ['deviantart', language.$('misc.external.deviantart')] + : domain.includes('instagram.com') + ? ['instagram', language.$('misc.external.bandcamp')] + : ['globe', language.$('misc.external.domain', {domain})]; + return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to( + 'shared.staticFile', + `icons.svg#icon-${id}` + )}"></use></svg></a>`; } // Grids export function getGridHTML({ - img, - language, - - entries, - srcFn, - linkFn, - noSrcTextFn = () => '', - altFn = () => '', - detailsFn = null, - lazy = true + img, + language, + + entries, + srcFn, + linkFn, + noSrcTextFn = () => '', + altFn = () => '', + detailsFn = null, + lazy = true, }) { - return entries.map(({ large, item }, i) => linkFn(item, - { - class: ['grid-item', 'box', large && 'large-grid-item'], - text: fixWS` + return entries + .map(({large, item}, i) => + linkFn(item, { + class: ['grid-item', 'box', large && 'large-grid-item'], + text: fixWS` ${img({ - src: srcFn(item), - alt: altFn(item), - thumb: 'small', - lazy: (typeof lazy === 'number' ? i >= lazy : lazy), - square: true, - reveal: getRevealStringFromTags(item.artTags, {language}), - noSrcText: noSrcTextFn(item) + src: srcFn(item), + alt: altFn(item), + thumb: 'small', + lazy: typeof lazy === 'number' ? i >= lazy : lazy, + square: true, + reveal: getRevealStringFromTags(item.artTags, {language}), + noSrcText: noSrcTextFn(item), })} <span>${item.name}</span> ${detailsFn && `<span>${detailsFn(item)}</span>`} - ` - })).join('\n'); + `, + }) + ) + .join('\n'); } export function getAlbumGridHTML({ - getAlbumCover, getGridHTML, link, language, - details = false, - ...props + getAlbumCover, + getGridHTML, + link, + language, + details = false, + ...props }) { - return getGridHTML({ - srcFn: getAlbumCover, - linkFn: link.album, - detailsFn: details && (album => language.$('misc.albumGrid.details', { - tracks: language.countTracks(album.tracks.length, {unit: true}), - time: language.formatDuration(getTotalDuration(album.tracks)) + return getGridHTML({ + srcFn: getAlbumCover, + linkFn: link.album, + detailsFn: + details && + ((album) => + language.$('misc.albumGrid.details', { + tracks: language.countTracks(album.tracks.length, {unit: true}), + time: language.formatDuration(getTotalDuration(album.tracks)), })), - noSrcTextFn: album => language.$('misc.albumGrid.noCoverArt', { - album: album.name - }), - ...props - }); + noSrcTextFn: (album) => + language.$('misc.albumGrid.noCoverArt', { + album: album.name, + }), + ...props, + }); } -export function getFlashGridHTML({ - getFlashCover, getGridHTML, link, - ...props -}) { - return getGridHTML({ - srcFn: getFlashCover, - linkFn: link.flash, - ...props - }); +export function getFlashGridHTML({getFlashCover, getGridHTML, link, ...props}) { + return getGridHTML({ + srcFn: getFlashCover, + linkFn: link.flash, + ...props, + }); } // Nav-bar links -export function generateInfoGalleryLinks(currentThing, isGallery, { - link, language, - linkKeyGallery, - linkKeyInfo -}) { - return [ - link[linkKeyInfo](currentThing, { - class: isGallery ? '' : 'current', - text: language.$('misc.nav.info') - }), - link[linkKeyGallery](currentThing, { - class: isGallery ? 'current' : '', - text: language.$('misc.nav.gallery') - }) - ].join(', '); +export function generateInfoGalleryLinks( + currentThing, + isGallery, + {link, language, linkKeyGallery, linkKeyInfo} +) { + return [ + link[linkKeyInfo](currentThing, { + class: isGallery ? '' : 'current', + text: language.$('misc.nav.info'), + }), + link[linkKeyGallery](currentThing, { + class: isGallery ? 'current' : '', + text: language.$('misc.nav.gallery'), + }), + ].join(', '); } -export function generatePreviousNextLinks(current, { - data, - link, - linkKey, - language -}) { - const linkFn = link[linkKey]; - - const index = data.indexOf(current); - const previous = data[index - 1]; - const next = data[index + 1]; - - return [ - previous && linkFn(previous, { - attributes: { - id: 'previous-button', - title: previous.name - }, - text: language.$('misc.nav.previous'), - color: false - }), - next && linkFn(next, { - attributes: { - id: 'next-button', - title: next.name - }, - text: language.$('misc.nav.next'), - color: false - }) - ].filter(Boolean).join(', '); +export function generatePreviousNextLinks( + current, + {data, link, linkKey, language} +) { + const linkFn = link[linkKey]; + + const index = data.indexOf(current); + const previous = data[index - 1]; + const next = data[index + 1]; + + return [ + previous && + linkFn(previous, { + attributes: { + id: 'previous-button', + title: previous.name, + }, + text: language.$('misc.nav.previous'), + color: false, + }), + next && + linkFn(next, { + attributes: { + id: 'next-button', + title: next.name, + }, + text: language.$('misc.nav.next'), + color: false, + }), + ] + .filter(Boolean) + .join(', '); } // Footer stuff -export function getFooterLocalizationLinks(pathname, { - defaultLanguage, - languages, - paths, - language, - to -}) { - const { toPath } = paths; - const keySuffix = toPath[0].replace(/^localized\./, '.'); - const toArgs = toPath.slice(1); - - const links = Object.entries(languages) - .filter(([ code, language ]) => code !== 'default' && !language.hidden) - .map(([ code, language ]) => language) - .sort(({ name: a }, { name: b }) => a < b ? -1 : a > b ? 1 : 0) - .map(language => html.tag('span', html.tag('a', { - href: (language === defaultLanguage +export function getFooterLocalizationLinks( + pathname, + {defaultLanguage, languages, paths, language, to} +) { + const {toPath} = paths; + const keySuffix = toPath[0].replace(/^localized\./, '.'); + const toArgs = toPath.slice(1); + + const links = Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language) + .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) + .map((language) => + html.tag( + 'span', + html.tag( + 'a', + { + href: + language === defaultLanguage ? to('localizedDefaultLanguage' + keySuffix, ...toArgs) - : to('localizedWithBaseDirectory' + keySuffix, language.code, ...toArgs)) - }, language.name))); + : to( + 'localizedWithBaseDirectory' + keySuffix, + language.code, + ...toArgs + ), + }, + language.name + ) + ) + ); - return html.tag('div', - {class: 'footer-localization-links'}, - language.$('misc.uiLanguage', {languages: links.join('\n')})); + return html.tag( + 'div', + {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', {languages: links.join('\n')}) + ); } diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js index 57135a4a..43b8c8d6 100644 --- a/src/page/album-commentary.js +++ b/src/page/album-commentary.js @@ -1,144 +1,133 @@ +/** @format */ + // Album commentary page and index specifications. // Imports -import fixWS from 'fix-whitespace'; - -import { - filterAlbumsByCommentary -} from '../util/wiki-data.js'; +import * as html from '../util/html.js'; +import {filterAlbumsByCommentary} from '../util/wiki-data.js'; // Page exports export function condition({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData).length; + return filterAlbumsByCommentary(wikiData.albumData).length; } export function targets({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData); + return filterAlbumsByCommentary(wikiData.albumData); } -export function write(album, {wikiData}) { - const { wikiInfo } = wikiData; - - const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary); - const words = entries.join(' ').split(' ').length; - - const page = { - type: 'page', - path: ['albumCommentary', album.directory], - page: ({ - getAlbumStylesheet, - getLinkThemeString, - getThemeString, - link, - language, - to, - transformMultiline - }) => ({ - title: language.$('albumCommentaryPage.title', {album: album.name}), - stylesheet: getAlbumStylesheet(album), - theme: getThemeString(album.color), - - main: { - content: fixWS` - <div class="long-content"> - <h1>${language.$('albumCommentaryPage.title', { - album: link.album(album) - })}</h1> - <p>${language.$('albumCommentaryPage.infoLine', { - words: `<b>${language.formatWordCount(words, {unit: true})}</b>`, - entries: `<b>${language.countCommentaryEntries(entries.length, {unit: true})}</b>` - })}</p> - ${album.commentary && fixWS` - <h3>${language.$('albumCommentaryPage.entry.title.albumCommentary')}</h3> - <blockquote> - ${transformMultiline(album.commentary)} - </blockquote> - `} - ${album.tracks.filter(t => t.commentary).map(track => fixWS` - <h3 id="${track.directory}">${language.$('albumCommentaryPage.entry.title.trackCommentary', { - track: link.track(track) - })}</h3> - <blockquote style="${getLinkThemeString(track.color)}"> - ${transformMultiline(track.commentary)} - </blockquote> - `).join('\n')} - </div> - ` - }, - - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.commentaryIndex'], - title: language.$('commentaryIndex.title') - }, - { - html: language.$('albumCommentaryPage.nav.album', { - album: link.albumCommentary(album, {class: 'current'}) - }) - } - ] - } - }) - }; - - return [page]; +export function write(album) { + const entries = [album, ...album.tracks] + .filter((x) => x.commentary) + .map((x) => x.commentary); + const words = entries.join(' ').split(' ').length; + + const page = { + type: 'page', + path: ['albumCommentary', album.directory], + page: ({ + getAlbumStylesheet, + getLinkThemeString, + getThemeString, + link, + language, + transformMultiline, + }) => ({ + title: language.$('albumCommentaryPage.title', {album: album.name}), + stylesheet: getAlbumStylesheet(album), + theme: getThemeString(album.color), + + main: { + content: html.tag('div', {class: 'long-content'}, [ + html.tag('h1', language.$('albumCommentaryPage.title', { + album: link.album(album), + })), + html.tag('p', language.$('albumCommentaryPage.infoLine', { + words: html.tag('b', language.formatWordCount(words, {unit: true})), + entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})), + })), + ...album.commentary ? [ + html.tag('h3', language.$('albumCommentaryPage.entry.title.albumCommentary')), + html.tag('blockquote', transformMultiline(album.commentary)), + ] : [], + ...album.tracks.filter(t => t.commentary).flatMap(track => [ + html.tag('h3', + {id: 'track.directory'}, + language.$('albumCommentaryPage.entry.title.trackCommentary', { + track: link.track(track), + })), + html.tag('blockquote', + {style: getLinkThemeString(track.color)}, + transformMultiline(track.commentary)), + ]) + ]), + }, + + nav: { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + path: ['localized.commentaryIndex'], + title: language.$('commentaryIndex.title'), + }, + { + html: language.$('albumCommentaryPage.nav.album', { + album: link.albumCommentary(album, {class: 'current'}), + }), + }, + ], + }, + }), + }; + + return [page]; } export function writeTargetless({wikiData}) { - const data = filterAlbumsByCommentary(wikiData.albumData) - .map(album => ({ - album, - entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary) - })) - .map(({ album, entries }) => ({ - album, entries, - words: entries.join(' ').split(' ').length - })); - - const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0); - const totalWords = data.reduce((acc, {words}) => acc + words, 0); - - const page = { - type: 'page', - path: ['commentaryIndex'], - page: ({ - link, - language - }) => ({ - title: language.$('commentaryIndex.title'), - - main: { - content: fixWS` - <div class="long-content"> - <h1>${language.$('commentaryIndex.title')}</h1> - <p>${language.$('commentaryIndex.infoLine', { - words: `<b>${language.formatWordCount(totalWords, {unit: true})}</b>`, - entries: `<b>${language.countCommentaryEntries(totalEntries, {unit: true})}</b>` - })}</p> - <p>${language.$('commentaryIndex.albumList.title')}</p> - <ul> - ${data - .map(({ album, entries, words }) => fixWS` - <li>${language.$('commentaryIndex.albumList.item', { - album: link.albumCommentary(album), - words: language.formatWordCount(words, {unit: true}), - entries: language.countCommentaryEntries(entries.length, {unit: true}) - })}</li> - `) - .join('\n')} - </ul> - </div> - ` - }, - - nav: {simple: true} - }) - }; - - return [page]; + const data = filterAlbumsByCommentary(wikiData.albumData) + .map((album) => ({ + album, + entries: [album, ...album.tracks] + .filter((x) => x.commentary) + .map((x) => x.commentary), + })) + .map(({album, entries}) => ({ + album, + entries, + words: entries.join(' ').split(' ').length, + })); + + const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0); + const totalWords = data.reduce((acc, {words}) => acc + words, 0); + + const page = { + type: 'page', + path: ['commentaryIndex'], + page: ({link, language}) => ({ + title: language.$('commentaryIndex.title'), + + main: { + content: html.tag('div', {class: 'long-content'}, [ + html.tag('h1', language.$('commentaryIndex.title')), + html.tag('p', language.$('commentaryIndex.infoLine', { + words: html.tag('b', language.formatWordCount(totalWords, {unit: true})), + entries: html.tag('b', language.countCommentaryEntries(totalEntries, {unit: true})), + })), + html.tag('p', language.$('commentaryIndex.albumList.title')), + html.tag('ul', data.map(({album, entries, words}) => + html.tag('li', language.$('commentaryIndex.albumList.item', { + album: link.albumCommentary(album), + words: language.formatWordCount(words, {unit: true}), + entries: language.countCommentaryEntries(entries.length, {unit: true}), + })))) + ]), + }, + + nav: {simple: true}, + }), + }; + + return [page]; } diff --git a/src/page/album.js b/src/page/album.js index c265fdc6..12755ae9 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -1,3 +1,5 @@ +/** @format */ + // Album page specification. // Imports @@ -6,480 +8,644 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - bindOpts, - compareArrays, -} from '../util/sugar.js'; +import {bindOpts, compareArrays} from '../util/sugar.js'; import { - getAlbumCover, - getAlbumListTag, - getTotalDuration, + getAlbumCover, + getAlbumListTag, + getTotalDuration, } from '../util/wiki-data.js'; // Page exports export function targets({wikiData}) { - return wikiData.albumData; + return wikiData.albumData; } export function write(album, {wikiData}) { - const { wikiInfo } = wikiData; - - const unbound_trackToListItem = (track, { + const unbound_trackToListItem = ( + track, + {getArtistString, getLinkThemeString, link, language} + ) => { + const itemOpts = { + duration: language.formatDuration(track.duration ?? 0), + track: link.track(track), + }; + return `<li style="${getLinkThemeString(track.color)}">${ + compareArrays( + track.artistContribs.map((c) => c.who), + album.artistContribs.map((c) => c.who), + {checkOrder: false} + ) + ? language.$('trackList.item.withDuration', itemOpts) + : language.$('trackList.item.withDuration.withArtists', { + ...itemOpts, + by: `<span class="by">${language.$( + 'trackList.item.withArtists.by', + { + artists: getArtistString(track.artistContribs), + } + )}</span>`, + }) + }</li>`; + }; + + const hasCommentaryEntries = + [album, ...album.tracks].filter((x) => x.commentary).length > 0; + const hasAdditionalFiles = album.additionalFiles?.length > 0; + const albumDuration = getTotalDuration(album.tracks); + + const listTag = getAlbumListTag(album); + + const data = { + type: 'data', + path: ['album', album.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForAlbum, + serializeLink, + }) => ({ + name: album.name, + directory: album.directory, + dates: { + released: album.date, + trackArtAdded: album.trackArtDate, + coverArtAdded: album.coverArtDate, + addedToWiki: album.dateAddedToWiki, + }, + duration: albumDuration, + color: album.color, + cover: serializeCover(album, getAlbumCover), + artistContribs: serializeContribs(album.artistContribs), + coverArtistContribs: serializeContribs(album.coverArtistContribs), + wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs), + bannerArtistContribs: serializeContribs(album.bannerArtistContribs), + groups: serializeGroupsForAlbum(album), + trackGroups: album.trackGroups?.map((trackGroup) => ({ + name: trackGroup.name, + color: trackGroup.color, + tracks: trackGroup.tracks.map((track) => track.directory), + })), + tracks: album.tracks.map((track) => ({ + link: serializeLink(track), + duration: track.duration, + })), + }), + }; + + const page = { + type: 'page', + path: ['album', album.directory], + page: ({ + fancifyURL, + generateAdditionalFilesShortcut, + generateAdditionalFilesList, + generateChronologyLinks, + generateCoverLink, + getAlbumCover, + getAlbumStylesheet, + getArtistString, + getLinkThemeString, + getSizeOfAdditionalFile, + getThemeString, + link, + language, + transformMultiline, + urls, + }) => { + const trackToListItem = bindOpts(unbound_trackToListItem, { getArtistString, getLinkThemeString, link, - language - }) => { - const itemOpts = { - duration: language.formatDuration(track.duration ?? 0), - track: link.track(track) - }; - return `<li style="${getLinkThemeString(track.color)}">${ - (compareArrays( - track.artistContribs.map(c => c.who), - album.artistContribs.map(c => c.who), - {checkOrder: false}) - ? language.$('trackList.item.withDuration', itemOpts) - : language.$('trackList.item.withDuration.withArtists', { - ...itemOpts, - by: `<span class="by">${ - language.$('trackList.item.withArtists.by', { - artists: getArtistString(track.artistContribs) - }) - }</span>` - })) - }</li>`; - }; - - const hasCommentaryEntries = ([album, ...album.tracks].filter(x => x.commentary).length > 0); - const hasAdditionalFiles = (album.additionalFiles?.length > 0); - const albumDuration = getTotalDuration(album.tracks); - - const listTag = getAlbumListTag(album); - - const data = { - type: 'data', - path: ['album', album.directory], - data: ({ - serializeContribs, - serializeCover, - serializeGroupsForAlbum, - serializeLink - }) => ({ - name: album.name, - directory: album.directory, - dates: { - released: album.date, - trackArtAdded: album.trackArtDate, - coverArtAdded: album.coverArtDate, - addedToWiki: album.dateAddedToWiki - }, - duration: albumDuration, - color: album.color, - cover: serializeCover(album, getAlbumCover), - artistContribs: serializeContribs(album.artistContribs), - coverArtistContribs: serializeContribs(album.coverArtistContribs), - wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs), - bannerArtistContribs: serializeContribs(album.bannerArtistContribs), - groups: serializeGroupsForAlbum(album), - trackGroups: album.trackGroups?.map(trackGroup => ({ - name: trackGroup.name, - color: trackGroup.color, - tracks: trackGroup.tracks.map(track => track.directory) - })), - tracks: album.tracks.map(track => ({ - link: serializeLink(track), - duration: track.duration - })) - }) - }; - - const page = { - type: 'page', - path: ['album', album.directory], - page: ({ - fancifyURL, - generateAdditionalFilesShortcut, - generateAdditionalFilesList, - generateChronologyLinks, - generateCoverLink, - getAlbumCover, - getAlbumStylesheet, - getArtistString, - getLinkThemeString, - getSizeOfAdditionalFile, - getThemeString, - link, - language, - transformMultiline, - urls, - }) => { - const trackToListItem = bindOpts(unbound_trackToListItem, { - getArtistString, - getLinkThemeString, - link, - language - }); - - const cover = getAlbumCover(album); - - return { - title: language.$('albumPage.title', {album: album.name}), - stylesheet: getAlbumStylesheet(album), - theme: getThemeString(album.color, [ - `--album-directory: ${album.directory}` - ]), - - banner: album.bannerArtistContribs.length && { - dimensions: album.bannerDimensions, - path: ['media.albumBanner', album.directory, album.bannerFileExtension], - alt: language.$('misc.alt.albumBanner'), - position: 'top' - }, - - main: { - content: fixWS` - ${cover && generateCoverLink({ + language, + }); + + const cover = getAlbumCover(album); + + return { + title: language.$('albumPage.title', {album: album.name}), + stylesheet: getAlbumStylesheet(album), + theme: getThemeString(album.color, [ + `--album-directory: ${album.directory}`, + ]), + + banner: album.bannerArtistContribs.length && { + dimensions: album.bannerDimensions, + path: [ + 'media.albumBanner', + album.directory, + album.bannerFileExtension, + ], + alt: language.$('misc.alt.albumBanner'), + position: 'top', + }, + + main: { + content: fixWS` + ${ + cover && + generateCoverLink({ src: cover, alt: language.$('misc.alt.albumCover'), - tags: album.artTags - })} - <h1>${language.$('albumPage.title', {album: album.name})}</h1> + tags: album.artTags, + }) + } + <h1>${language.$('albumPage.title', { + album: album.name, + })}</h1> <p> ${[ - album.artistContribs.length && language.$('releaseInfo.by', { - artists: getArtistString(album.artistContribs, { - showContrib: true, - showIcons: true - }) + album.artistContribs.length && + language.$('releaseInfo.by', { + artists: getArtistString( + album.artistContribs, + { + showContrib: true, + showIcons: true, + } + ), + }), + album.coverArtistContribs.length && + language.$('releaseInfo.coverArtBy', { + artists: getArtistString( + album.coverArtistContribs, + { + showContrib: true, + showIcons: true, + } + ), }), - album.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', { - artists: getArtistString(album.coverArtistContribs, { - showContrib: true, - showIcons: true - }) + album.wallpaperArtistContribs.length && + language.$('releaseInfo.wallpaperArtBy', { + artists: getArtistString( + album.wallpaperArtistContribs, + { + showContrib: true, + showIcons: true, + } + ), }), - album.wallpaperArtistContribs.length && language.$('releaseInfo.wallpaperArtBy', { - artists: getArtistString(album.wallpaperArtistContribs, { - showContrib: true, - showIcons: true - }) + album.bannerArtistContribs.length && + language.$('releaseInfo.bannerArtBy', { + artists: getArtistString( + album.bannerArtistContribs, + { + showContrib: true, + showIcons: true, + } + ), }), - album.bannerArtistContribs.length && language.$('releaseInfo.bannerArtBy', { - artists: getArtistString(album.bannerArtistContribs, { - showContrib: true, - showIcons: true - }) + album.date && + language.$('releaseInfo.released', { + date: language.formatDate(album.date), }), - album.date && language.$('releaseInfo.released', { - date: language.formatDate(album.date) + album.coverArtDate && + +album.coverArtDate !== +album.date && + language.$('releaseInfo.artReleased', { + date: language.formatDate(album.coverArtDate), }), - (album.coverArtDate && - +album.coverArtDate !== +album.date && - language.$('releaseInfo.artReleased', { - date: language.formatDate(album.coverArtDate) - })), - language.$('releaseInfo.duration', { - duration: language.formatDuration(albumDuration, {approximate: album.tracks.length > 1}) - }) - ].filter(Boolean).join('<br>\n')} + language.$('releaseInfo.duration', { + duration: language.formatDuration( + albumDuration, + { + approximate: album.tracks.length > 1, + } + ), + }), + ] + .filter(Boolean) + .join('<br>\n')} </p> - ${(hasAdditionalFiles || hasCommentaryEntries) && fixWS`<p> + ${ + (hasAdditionalFiles || hasCommentaryEntries) && + fixWS`<p> ${[ - hasAdditionalFiles && generateAdditionalFilesShortcut(album.additionalFiles, {language}), - hasCommentaryEntries && language.$('releaseInfo.viewCommentary', { - link: link.albumCommentary(album, { - text: language.$('releaseInfo.viewCommentary.link') - }) - }) - ].filter(Boolean).join('<br>\n') - }</p>`} - ${album.urls?.length && `<p>${ - language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(album.urls.map(url => fancifyURL(url, {album: true}))) - }) - }</p>`} - ${album.trackGroups && (album.trackGroups.length > 1 || !album.trackGroups[0].isDefaultTrackGroup) ? fixWS` + hasAdditionalFiles && + generateAdditionalFilesShortcut( + album.additionalFiles, + { + language, + } + ), + hasCommentaryEntries && + language.$('releaseInfo.viewCommentary', { + link: link.albumCommentary(album, { + text: language.$( + 'releaseInfo.viewCommentary.link' + ), + }), + }), + ] + .filter(Boolean) + .join('<br>\n')}</p>` + } + ${ + album.urls?.length && + `<p>${language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList( + album.urls.map((url) => + fancifyURL(url, {album: true}) + ) + ), + })}</p>` + } + ${ + album.trackGroups && + (album.trackGroups.length > 1 || + !album.trackGroups[0].isDefaultTrackGroup) + ? fixWS` <dl class="album-group-list"> - ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS` - <dt>${ - language.$('trackList.section.withDuration', { - duration: language.formatDuration(getTotalDuration(tracks), {approximate: tracks.length > 1}), - section: name - }) - }</dt> - <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}> - ${tracks.map(trackToListItem).join('\n')} + ${album.trackGroups + .map( + ({ + name, + startIndex, + tracks, + }) => fixWS` + <dt>${language.$( + 'trackList.section.withDuration', + { + duration: language.formatDuration( + getTotalDuration(tracks), + { + approximate: tracks.length > 1, + } + ), + section: name, + } + )}</dt> + <dd><${ + listTag === 'ol' + ? `ol start="${startIndex + 1}"` + : listTag + }> + ${tracks + .map(trackToListItem) + .join('\n')} </${listTag}></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> - ` : fixWS` + ` + : fixWS` <${listTag}> ${album.tracks.map(trackToListItem).join('\n')} </${listTag}> - `} - ${album.dateAddedToWiki && fixWS` + ` + } + ${ + album.dateAddedToWiki && + fixWS` <p> ${[ - language.$('releaseInfo.addedToWiki', { - date: language.formatDate(album.dateAddedToWiki) - }) - ].filter(Boolean).join('<br>\n')} + language.$('releaseInfo.addedToWiki', { + date: language.formatDate( + album.dateAddedToWiki + ), + }), + ] + .filter(Boolean) + .join('<br>\n')} </p> - `} - ${hasAdditionalFiles && generateAdditionalFilesList(album.additionalFiles, { + ` + } + ${ + hasAdditionalFiles && + generateAdditionalFilesList(album.additionalFiles, { // TODO: Kinda near the metal here... - getFileSize: file => getSizeOfAdditionalFile(urls - .from('media.root') - .to('media.albumAdditionalFile', album.directory, file)), - linkFile: file => link.albumAdditionalFile({album, file}), - })} - ${album.commentary && fixWS` + getFileSize: (file) => + getSizeOfAdditionalFile( + urls + .from('media.root') + .to( + 'media.albumAdditionalFile', + album.directory, + file + ) + ), + linkFile: (file) => + link.albumAdditionalFile({album, file}), + }) + } + ${ + album.commentary && + fixWS` <p>${language.$('releaseInfo.artistCommentary')}</p> <blockquote> ${transformMultiline(album.commentary)} </blockquote> - `} - ` - }, - - sidebarLeft: generateAlbumSidebar(album, null, { - fancifyURL, - getLinkThemeString, - link, - language, - transformMultiline, - wikiData - }), - - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - html: language.$('albumPage.nav.album', { - album: link.album(album, {class: 'current'}) - }) - }, - ], - bottomRowContent: generateAlbumNavLinks(album, null, {language}), - content: generateAlbumChronologyLinks(album, null, {generateChronologyLinks}), - }, - - secondaryNav: generateAlbumSecondaryNav(album, null, { - language, - link, - getLinkThemeString, - }), - }; - } - }; + ` + } + `, + }, + + sidebarLeft: generateAlbumSidebar(album, null, { + fancifyURL, + getLinkThemeString, + link, + language, + transformMultiline, + wikiData, + }), + + nav: { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + html: language.$('albumPage.nav.album', { + album: link.album(album, {class: 'current'}), + }), + }, + ], + bottomRowContent: generateAlbumNavLinks(album, null, {language}), + content: generateAlbumChronologyLinks(album, null, { + generateChronologyLinks, + }), + }, + + secondaryNav: generateAlbumSecondaryNav(album, null, { + language, + link, + getLinkThemeString, + }), + }; + }, + }; - return [page, data]; + return [page, data]; } // Utility functions export function generateAlbumSidebar(album, currentTrack, { - fancifyURL, - getLinkThemeString, - link, - language, - transformMultiline, - wikiData + fancifyURL, + getLinkThemeString, + link, + language, + transformMultiline, }) { - const listTag = getAlbumListTag(album); - - /* - const trackGroups = album.trackGroups || [{ - name: language.$('albumSidebar.trackList.fallbackGroupName'), - color: album.color, - startIndex: 0, - tracks: album.tracks - }]; - */ - - const { trackGroups } = album; - - const trackToListItem = track => html.tag('li', - {class: track === currentTrack && 'current'}, - language.$('albumSidebar.trackList.item', { - track: link.track(track) - })); - - const nameOrDefault = (isDefaultTrackGroup, name) => - (isDefaultTrackGroup - ? language.$('albumSidebar.trackList.fallbackGroupName') - : name); - - const trackListPart = fixWS` + const listTag = getAlbumListTag(album); + + /* + const trackGroups = album.trackGroups || [{ + name: language.$('albumSidebar.trackList.fallbackGroupName'), + color: album.color, + startIndex: 0, + tracks: album.tracks + }]; + */ + + const {trackGroups} = album; + + const trackToListItem = (track) => + html.tag( + 'li', + {class: track === currentTrack && 'current'}, + language.$('albumSidebar.trackList.item', { + track: link.track(track), + }) + ); + + const nameOrDefault = (isDefaultTrackGroup, name) => + isDefaultTrackGroup + ? language.$('albumSidebar.trackList.fallbackGroupName') + : name; + + const trackListPart = fixWS` <h1>${link.album(album)}</h1> - ${trackGroups.map(({ name, color, startIndex, tracks, isDefaultTrackGroup }) => - html.tag('details', { + ${trackGroups + .map(({name, color, startIndex, tracks, isDefaultTrackGroup}) => + html.tag( + 'details', + { // Leave side8ar track groups collapsed on al8um homepage, // since there's already a view of all the groups expanded // in the main content area. open: currentTrack && tracks.includes(currentTrack), - class: tracks.includes(currentTrack) && 'current' - }, [ - html.tag('summary', - {style: getLinkThemeString(color)}, - (listTag === 'ol' - ? language.$('albumSidebar.trackList.group.withRange', { - group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, name)}</span>`, - range: `${startIndex + 1}–${startIndex + tracks.length}` - }) - : language.$('albumSidebar.trackList.group', { - group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, name)}</span>` - })) + class: tracks.includes(currentTrack) && 'current', + }, + [ + html.tag( + 'summary', + {style: getLinkThemeString(color)}, + listTag === 'ol' + ? language.$('albumSidebar.trackList.group.withRange', { + group: `<span class="group-name">${nameOrDefault( + isDefaultTrackGroup, + name + )}</span>`, + range: `${startIndex + 1}–${ + startIndex + tracks.length + }`, + }) + : language.$('albumSidebar.trackList.group', { + group: `<span class="group-name">${nameOrDefault( + isDefaultTrackGroup, + name + )}</span>`, + }) ), fixWS` - <${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}> + <${ + listTag === 'ol' + ? `ol start="${startIndex + 1}"` + : listTag + }> ${tracks.map(trackToListItem).join('\n')} </${listTag}> - ` - ])).join('\n')} + `, + ] + ) + ) + .join('\n')} `; - const { groups } = album; - - const groupParts = groups.map(group => { - const albums = group.albums.filter(album => album.date); - const index = albums.indexOf(album); - const next = index >= 0 && albums[index + 1]; - const previous = index > 0 && albums[index - 1]; - return {group, next, previous}; - }).map(({group, next, previous}) => fixWS` - <h1>${ - language.$('albumSidebar.groupBox.title', { - group: link.groupInfo(group) - }) - }</h1> + const {groups} = album; + + const groupParts = groups + .map((group) => { + const albums = group.albums.filter((album) => album.date); + const index = albums.indexOf(album); + const next = index >= 0 && albums[index + 1]; + const previous = index > 0 && albums[index - 1]; + return {group, next, previous}; + }) + .map( + ({group, next, previous}) => fixWS` + <h1>${language.$('albumSidebar.groupBox.title', { + group: link.groupInfo(group), + })}</h1> ${!currentTrack && transformMultiline(group.descriptionShort)} - ${group.urls?.length && `<p>${ - language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url))) - }) - }</p>`} - ${!currentTrack && fixWS` - ${next && `<p class="group-chronology-link">${ - language.$('albumSidebar.groupBox.next', { - album: link.album(next) - }) - }</p>`} - ${previous && `<p class="group-chronology-link">${ - language.$('albumSidebar.groupBox.previous', { - album: link.album(previous) - }) - }</p>`} - `} - `); - - if (groupParts.length) { - if (currentTrack) { - const combinedGroupPart = groupParts.join('\n<hr>\n'); - return { - multiple: [ - trackListPart, - combinedGroupPart - ] - }; - } else { - return { - multiple: [ - ...groupParts, - trackListPart - ] - }; + ${ + group.urls?.length && + `<p>${language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList( + group.urls.map((url) => fancifyURL(url)) + ), + })}</p>` } + ${ + !currentTrack && + fixWS` + ${ + next && + `<p class="group-chronology-link">${language.$( + 'albumSidebar.groupBox.next', + { + album: link.album(next), + } + )}</p>` + } + ${ + previous && + `<p class="group-chronology-link">${language.$( + 'albumSidebar.groupBox.previous', + { + album: link.album(previous), + } + )}</p>` + } + ` + } + ` + ); + + if (groupParts.length) { + if (currentTrack) { + const combinedGroupPart = groupParts.join('\n<hr>\n'); + return { + multiple: [trackListPart, combinedGroupPart], + }; } else { - return { - content: trackListPart - }; + return { + multiple: [...groupParts, trackListPart], + }; } + } else { + return { + content: trackListPart, + }; + } } -export function generateAlbumSecondaryNav(album, currentTrack, { - link, - language, - getLinkThemeString, -}) { - const { groups } = album; - - if (!groups.length) { - return null; - } - - const groupParts = groups.map(group => { - const albums = group.albums.filter(album => album.date); - const index = albums.indexOf(album); - const next = index >= 0 && albums[index + 1]; - const previous = index > 0 && albums[index - 1]; - return {group, next, previous}; - }).map(({group, next, previous}) => { - const previousNext = !currentTrack && [ - previous && link.album(previous, {color: false, text: language.$('misc.nav.previous')}), - next && link.album(next, {color: false, text: language.$('misc.nav.next')}) - ].filter(Boolean); - return html.tag('span', {style: getLinkThemeString(group.color)}, [ - language.$('albumSidebar.groupBox.title', { - group: link.groupInfo(group) +export function generateAlbumSecondaryNav( + album, + currentTrack, + {link, language, getLinkThemeString} +) { + const {groups} = album; + + if (!groups.length) { + return null; + } + + const groupParts = groups + .map((group) => { + const albums = group.albums.filter((album) => album.date); + const index = albums.indexOf(album); + const next = index >= 0 && albums[index + 1]; + const previous = index > 0 && albums[index - 1]; + return {group, next, previous}; + }) + .map(({group, next, previous}) => { + const previousNext = + !currentTrack && + [ + previous && + link.album(previous, { + color: false, + text: language.$('misc.nav.previous'), + }), + next && + link.album(next, { + color: false, + text: language.$('misc.nav.next'), }), - previousNext?.length && `(${previousNext.join(',\n')})` - ]); + ].filter(Boolean); + return html.tag('span', {style: getLinkThemeString(group.color)}, [ + language.$('albumSidebar.groupBox.title', { + group: link.groupInfo(group), + }), + previousNext?.length && `(${previousNext.join(',\n')})`, + ]); }); - return { - classes: ['dot-between-spans'], - content: groupParts.join('\n'), - }; + return { + classes: ['dot-between-spans'], + content: groupParts.join('\n'), + }; } -export function generateAlbumNavLinks(album, currentTrack, { - generatePreviousNextLinks, - language -}) { - if (album.tracks.length <= 1) { - return ''; - } - - const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, { - data: album.tracks, - linkKey: 'track' +export function generateAlbumNavLinks( + album, + currentTrack, + {generatePreviousNextLinks, language} +) { + if (album.tracks.length <= 1) { + return ''; + } + + const previousNextLinks = + currentTrack && + generatePreviousNextLinks(currentTrack, { + data: album.tracks, + linkKey: 'track', }); - const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${ - (currentTrack - ? language.$('trackPage.nav.random') - : language.$('albumPage.nav.randomTrack')) - }</a>`; - - return (previousNextLinks - ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)` - : `<span class="js-hide-until-data">(${randomLink})</span>`); + const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${ + currentTrack + ? language.$('trackPage.nav.random') + : language.$('albumPage.nav.randomTrack') + }</a>`; + + return previousNextLinks + ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)` + : `<span class="js-hide-until-data">(${randomLink})</span>`; } -export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) { - return html.tag('div', { - [html.onlyIfContent]: true, - class: 'nav-chronology-links', - }, [ - currentTrack && generateChronologyLinks(currentTrack, { - contribKey: 'artistContribs', - getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor], - headingString: 'misc.chronology.heading.track' +export function generateAlbumChronologyLinks( + album, + currentTrack, + {generateChronologyLinks} +) { + return html.tag( + 'div', + { + [html.onlyIfContent]: true, + class: 'nav-chronology-links', + }, + [ + currentTrack && + generateChronologyLinks(currentTrack, { + contribKey: 'artistContribs', + getThings: (artist) => [ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ], + headingString: 'misc.chronology.heading.track', }), - currentTrack && generateChronologyLinks(currentTrack, { - contribKey: 'contributorContribs', - getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor], - headingString: 'misc.chronology.heading.track' + currentTrack && + generateChronologyLinks(currentTrack, { + contribKey: 'contributorContribs', + getThings: (artist) => [ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ], + headingString: 'misc.chronology.heading.track', }), - generateChronologyLinks(currentTrack || album, { - contribKey: 'coverArtistContribs', - dateKey: 'coverArtDate', - getThings: artist => [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist], - headingString: 'misc.chronology.heading.coverArt' - }) - ].filter(Boolean).join('\n')); + generateChronologyLinks(currentTrack || album, { + contribKey: 'coverArtistContribs', + dateKey: 'coverArtDate', + getThings: (artist) => [ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ], + headingString: 'misc.chronology.heading.coverArt', + }), + ] + .filter(Boolean) + .join('\n') + ); } diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js index ac23e902..3d882f65 100644 --- a/src/page/artist-alias.js +++ b/src/page/artist-alias.js @@ -1,22 +1,21 @@ +/** @format */ + // Artist alias redirect pages. // (Makes old permalinks bring visitors to the up-to-date page.) export function targets({wikiData}) { - return wikiData.artistAliasData; + return wikiData.artistAliasData; } -export function write(aliasArtist, {wikiData}) { - // This function doesn't actually use wikiData, 8ut, um, consistency? - - const { aliasedArtist } = aliasArtist; +export function write(aliasArtist) { + const {aliasedArtist} = aliasArtist; - const redirect = { - type: 'redirect', - fromPath: ['artist', aliasArtist.directory], - toPath: ['artist', aliasedArtist.directory], - title: () => aliasedArtist.name - }; + const redirect = { + type: 'redirect', + fromPath: ['artist', aliasArtist.directory], + toPath: ['artist', aliasedArtist.directory], + title: () => aliasedArtist.name, + }; - return [redirect]; + return [redirect]; } - diff --git a/src/page/artist.js b/src/page/artist.js index 6c31a010..481b4e36 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -1,3 +1,5 @@ +/** @format */ + // Artist page specification. // // NB: See artist-alias.js for artist alias redirect pages. @@ -8,511 +10,749 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - bindOpts, - unique -} from '../util/sugar.js'; +import {bindOpts, unique} from '../util/sugar.js'; import { - chunkByProperties, - getTotalDuration, - sortAlbumsTracksChronologically, - sortByDate, - sortByDirectory, - sortChronologically, + chunkByProperties, + getTotalDuration, + sortAlbumsTracksChronologically, + sortChronologically, } from '../util/wiki-data.js'; // Page exports export function targets({wikiData}) { - return wikiData.artistData; + return wikiData.artistData; } export function write(artist, {wikiData}) { - const { groupData, wikiInfo } = wikiData; - - const { name, urls, contextNotes } = artist; - - const artThingsAll = sortAlbumsTracksChronologically(unique([ - ...artist.albumsAsCoverArtist ?? [], - ...artist.albumsAsWallpaperArtist ?? [], - ...artist.albumsAsBannerArtist ?? [], - ...artist.tracksAsCoverArtist ?? [] - ]), {getDate: o => o.coverArtDate}); - - const artThingsGallery = sortAlbumsTracksChronologically([ - ...artist.albumsAsCoverArtist ?? [], - ...artist.tracksAsCoverArtist ?? [] - ], {getDate: o => o.coverArtDate}); - - const commentaryThings = sortAlbumsTracksChronologically([ - ...artist.albumsAsCommentator ?? [], - ...artist.tracksAsCommentator ?? [] - ]); - - const hasGallery = artThingsGallery.length > 0; - - const getArtistsAndContrib = (thing, key) => ({ - artists: thing[key]?.filter(({ who }) => who !== artist), - contrib: thing[key]?.find(({ who }) => who === artist), - thing, - key - }); - - const artListChunks = chunkByProperties(artThingsAll.flatMap(thing => - (['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs'] - .map(key => getArtistsAndContrib(thing, key)) - .filter(({ contrib }) => contrib) - .map(props => ({ - album: thing.album || thing, - track: thing.album ? thing : null, - date: thing.date, - ...props - }))) - ), ['date', 'album']); - - const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({ - album: thing.album || thing, - track: thing.album ? thing : null - })), ['album']); - - const allTracks = sortAlbumsTracksChronologically(unique([ - ...artist.tracksAsArtist ?? [], - ...artist.tracksAsContributor ?? [] - ])); - - const chunkTracks = tracks => ( - chunkByProperties(tracks.map(track => ({ - track, - date: +track.date, - album: track.album, - duration: track.duration, - artists: (track.artistContribs.some(({ who }) => who === artist) - ? track.artistContribs.filter(({ who }) => who !== artist) - : track.contributorContribs.filter(({ who }) => who !== artist)), - contrib: { - who: artist, - whatArray: [ - track.artistContribs.find(({ who }) => who === artist)?.what, - track.contributorContribs.find(({ who }) => who === artist)?.what - ].filter(Boolean) + const {groupData, wikiInfo} = wikiData; + + const {name, urls, contextNotes} = artist; + + const artThingsAll = sortAlbumsTracksChronologically( + unique([ + ...(artist.albumsAsCoverArtist ?? []), + ...(artist.albumsAsWallpaperArtist ?? []), + ...(artist.albumsAsBannerArtist ?? []), + ...(artist.tracksAsCoverArtist ?? []), + ]), + {getDate: (o) => o.coverArtDate} + ); + + const artThingsGallery = sortAlbumsTracksChronologically( + [ + ...(artist.albumsAsCoverArtist ?? []), + ...(artist.tracksAsCoverArtist ?? []), + ], + {getDate: (o) => o.coverArtDate} + ); + + const commentaryThings = sortAlbumsTracksChronologically([ + ...(artist.albumsAsCommentator ?? []), + ...(artist.tracksAsCommentator ?? []), + ]); + + const hasGallery = artThingsGallery.length > 0; + + const getArtistsAndContrib = (thing, key) => ({ + artists: thing[key]?.filter(({who}) => who !== artist), + contrib: thing[key]?.find(({who}) => who === artist), + thing, + key, + }); + + const artListChunks = chunkByProperties( + artThingsAll.flatMap((thing) => + ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs'] + .map((key) => getArtistsAndContrib(thing, key)) + .filter(({contrib}) => contrib) + .map((props) => ({ + album: thing.album || thing, + track: thing.album ? thing : null, + date: thing.date, + ...props, + })) + ), + ['date', 'album'] + ); + + const commentaryListChunks = chunkByProperties( + commentaryThings.map((thing) => ({ + album: thing.album || thing, + track: thing.album ? thing : null, + })), + ['album'] + ); + + const allTracks = sortAlbumsTracksChronologically( + unique([ + ...(artist.tracksAsArtist ?? []), + ...(artist.tracksAsContributor ?? []), + ]) + ); + + const chunkTracks = (tracks) => + chunkByProperties( + tracks.map((track) => ({ + track, + date: +track.date, + album: track.album, + duration: track.duration, + artists: track.artistContribs.some(({who}) => who === artist) + ? track.artistContribs.filter(({who}) => who !== artist) + : track.contributorContribs.filter(({who}) => who !== artist), + contrib: { + who: artist, + whatArray: [ + track.artistContribs.find(({who}) => who === artist)?.what, + track.contributorContribs.find(({who}) => who === artist)?.what, + ].filter(Boolean), + }, + })), + ['date', 'album'] + ).map(({date, album, chunk}) => ({ + date, + album, + chunk, + duration: getTotalDuration(chunk), + })); + + const trackListChunks = chunkTracks(allTracks); + const totalDuration = getTotalDuration(allTracks); + + const countGroups = (things) => { + const usedGroups = things.flatMap( + (thing) => thing.groups || thing.album?.groups || [] + ); + return groupData + .map((group) => ({ + group, + contributions: usedGroups.filter((g) => g === group).length, + })) + .filter(({contributions}) => contributions > 0) + .sort((a, b) => b.contributions - a.contributions); + }; + + const musicGroups = countGroups(allTracks); + const artGroups = countGroups(artThingsAll); + + let flashes, flashListChunks; + if (wikiInfo.enableFlashesAndGames) { + flashes = sortChronologically(artist.flashesAsContributor?.slice() ?? []); + flashListChunks = chunkByProperties( + flashes.map((flash) => ({ + act: flash.act, + flash, + date: flash.date, + // Manual artists/contrib properties here, 8ecause we don't + // want to show the full list of other contri8utors inline. + // (It can often 8e very, very large!) + artists: [], + contrib: flash.contributorContribs.find(({who}) => who === artist), + })), + ['act'] + ).map(({act, chunk}) => ({ + act, + chunk, + dateFirst: chunk[0].date, + dateLast: chunk[chunk.length - 1].date, + })); + } + + const generateEntryAccents = ({ + getArtistString, + language, + original, + entry, + artists, + contrib, + }) => + original + ? language.$('artistPage.creditList.entry.rerelease', {entry}) + : artists.length + ? contrib.what || contrib.whatArray?.length + ? language.$( + 'artistPage.creditList.entry.withArtists.withContribution', + { + entry, + artists: getArtistString(artists), + contribution: contrib.whatArray + ? language.formatUnitList(contrib.whatArray) + : contrib.what, } - })), ['date', 'album']) - .map(({date, album, chunk}) => ({ - date, album, chunk, - duration: getTotalDuration(chunk), - }))); - - const trackListChunks = chunkTracks(allTracks); - const totalDuration = getTotalDuration(allTracks); - - const countGroups = things => { - const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []); - return groupData - .map(group => ({ - group, - contributions: usedGroups.filter(g => g === group).length - })) - .filter(({ contributions }) => contributions > 0) - .sort((a, b) => b.contributions - a.contributions); - }; + ) + : language.$('artistPage.creditList.entry.withArtists', { + entry, + artists: getArtistString(artists), + }) + : contrib.what || contrib.whatArray?.length + ? language.$('artistPage.creditList.entry.withContribution', { + entry, + contribution: contrib.whatArray + ? language.formatUnitList(contrib.whatArray) + : contrib.what, + }) + : entry; - const musicGroups = countGroups(allTracks); - const artGroups = countGroups(artThingsAll); - - let flashes, flashListChunks; - if (wikiInfo.enableFlashesAndGames) { - flashes = sortChronologically(artist.flashesAsContributor?.slice() ?? []); - flashListChunks = ( - chunkByProperties(flashes.map(flash => ({ - act: flash.act, - flash, - date: flash.date, - // Manual artists/contrib properties here, 8ecause we don't - // want to show the full list of other contri8utors inline. - // (It can often 8e very, very large!) - artists: [], - contrib: flash.contributorContribs.find(({ who }) => who === artist) - })), ['act']) - .map(({ act, chunk }) => ({ - act, chunk, - dateFirst: chunk[0].date, - dateLast: chunk[chunk.length - 1].date - }))); - } - - const generateEntryAccents = ({ - getArtistString, language, - original, entry, artists, contrib - }) => - (original - ? language.$('artistPage.creditList.entry.rerelease', {entry}) - : (artists.length - ? ((contrib.what || contrib.whatArray?.length) - ? language.$('artistPage.creditList.entry.withArtists.withContribution', { - entry, - artists: getArtistString(artists), - contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what) - }) - : language.$('artistPage.creditList.entry.withArtists', { - entry, - artists: getArtistString(artists) - })) - : ((contrib.what || contrib.whatArray?.length) - ? language.$('artistPage.creditList.entry.withContribution', { - entry, - contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what) - }) - : entry))); - - const unbound_generateTrackList = (chunks, { - getArtistString, link, language - }) => fixWS` + const unbound_generateTrackList = ( + chunks, + {getArtistString, link, language} + ) => fixWS` <dl> - ${chunks.map(({date, album, chunk, duration}) => fixWS` + ${chunks + .map( + ({date, album, chunk, duration}) => fixWS` <dt>${ - (date && duration) ? language.$('artistPage.creditList.album.withDate.withDuration', { + date && duration + ? language.$( + 'artistPage.creditList.album.withDate.withDuration', + { + album: link.album(album), + date: language.formatDate(date), + duration: language.formatDuration(duration, { + approximate: true, + }), + } + ) + : date + ? language.$('artistPage.creditList.album.withDate', { album: link.album(album), date: language.formatDate(date), - duration: language.formatDuration(duration, {approximate: true}) - }) : date ? language.$('artistPage.creditList.album.withDate', { + }) + : duration + ? language.$('artistPage.creditList.album.withDuration', { album: link.album(album), - date: language.formatDate(date) - }) : duration ? language.$('artistPage.creditList.album.withDuration', { + duration: language.formatDuration(duration, { + approximate: true, + }), + }) + : language.$('artistPage.creditList.album', { album: link.album(album), - duration: language.formatDuration(duration, {approximate: true}) - }) : language.$('artistPage.creditList.album', { - album: link.album(album) - })}</dt> + }) + }</dt> <dd><ul> - ${(chunk - .map(({track, ...props}) => ({ - original: track.originalReleaseTrack, - entry: language.$('artistPage.creditList.entry.track.withDuration', { - track: link.track(track), - duration: language.formatDuration(track.duration ?? 0) - }), - ...props - })) - .map(({original, ...opts}) => html.tag('li', - {class: original && 'rerelease'}, - generateEntryAccents({getArtistString, language, original, ...opts}))) - .join('\n'))} + ${chunk + .map(({track, ...props}) => ({ + original: track.originalReleaseTrack, + entry: language.$( + 'artistPage.creditList.entry.track.withDuration', + { + track: link.track(track), + duration: language.formatDuration( + track.duration ?? 0 + ), + } + ), + ...props, + })) + .map(({original, ...opts}) => + html.tag( + 'li', + {class: original && 'rerelease'}, + generateEntryAccents({ + getArtistString, + language, + original, + ...opts, + }) + ) + ) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> `; - const unbound_serializeArtistsAndContrib = (key, { - serializeContribs, - serializeLink - }) => thing => { - const { artists, contrib } = getArtistsAndContrib(thing, key); - const ret = {}; - ret.link = serializeLink(thing); - if (contrib.what) ret.contribution = contrib.what; - if (artists.length) ret.otherArtists = serializeContribs(artists); - return ret; + const unbound_serializeArtistsAndContrib = + (key, {serializeContribs, serializeLink}) => + (thing) => { + const {artists, contrib} = getArtistsAndContrib(thing, key); + const ret = {}; + ret.link = serializeLink(thing); + if (contrib.what) ret.contribution = contrib.what; + if (artists.length) ret.otherArtists = serializeContribs(artists); + return ret; }; - const unbound_serializeTrackListChunks = (chunks, {serializeLink}) => - chunks.map(({date, album, chunk, duration}) => ({ - album: serializeLink(album), - date, - duration, - tracks: chunk.map(({ track }) => ({ - link: serializeLink(track), - duration: track.duration - })) - })); - - const data = { - type: 'data', - path: ['artist', artist.directory], - data: ({ - serializeContribs, - serializeLink - }) => { - const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, { - serializeContribs, - serializeLink - }); - - const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, { - serializeLink - }); - - return { - albums: { - asCoverArtist: artist.albumsAsCoverArtist?.map(serializeArtistsAndContrib('coverArtistContribs')), - asWallpaperArtist: artist.albumsAsWallpaperArtist?.map(serializeArtistsAndContrib('wallpaperArtistContribs')), - asBannerArtist: artist.albumsAsBannerArtist?.map(serializeArtistsAndContrib('bannerArtistContribs')) - }, - flashes: wikiInfo.enableFlashesAndGames ? { - asContributor: (artist.flashesAsContributor - ?.map(flash => getArtistsAndContrib(flash, 'contributorContribs')) - .map(({ contrib, thing: flash }) => ({ - link: serializeLink(flash), - contribution: contrib.what - }))) - } : null, - tracks: { - asArtist: artist.tracksAsArtist.map(serializeArtistsAndContrib('artistContribs')), - asContributor: artist.tracksAsContributor.map(serializeArtistsAndContrib('contributorContribs')), - chunked: serializeTrackListChunks(trackListChunks) - } - }; + const unbound_serializeTrackListChunks = (chunks, {serializeLink}) => + chunks.map(({date, album, chunk, duration}) => ({ + album: serializeLink(album), + date, + duration, + tracks: chunk.map(({track}) => ({ + link: serializeLink(track), + duration: track.duration, + })), + })); + + const data = { + type: 'data', + path: ['artist', artist.directory], + data: ({serializeContribs, serializeLink}) => { + const serializeArtistsAndContrib = bindOpts( + unbound_serializeArtistsAndContrib, + { + serializeContribs, + serializeLink, } - }; + ); - const infoPage = { - type: 'page', - path: ['artist', artist.directory], - page: ({ - fancifyURL, - generateCoverLink, - generateInfoGalleryLinks, - getArtistAvatar, - getArtistString, - link, - language, - to, - transformMultiline - }) => { - const generateTrackList = bindOpts(unbound_generateTrackList, { - getArtistString, - link, - language - }); - - return { - title: language.$('artistPage.title', {artist: name}), - - main: { - content: fixWS` - ${artist.hasAvatar && generateCoverLink({ + const serializeTrackListChunks = bindOpts( + unbound_serializeTrackListChunks, + { + serializeLink, + } + ); + + return { + albums: { + asCoverArtist: artist.albumsAsCoverArtist?.map( + serializeArtistsAndContrib('coverArtistContribs') + ), + asWallpaperArtist: artist.albumsAsWallpaperArtist?.map( + serializeArtistsAndContrib('wallpaperArtistContribs') + ), + asBannerArtist: artist.albumsAsBannerArtist?.map( + serializeArtistsAndContrib('bannerArtistContribs') + ), + }, + flashes: wikiInfo.enableFlashesAndGames + ? { + asContributor: artist.flashesAsContributor + ?.map((flash) => + getArtistsAndContrib(flash, 'contributorContribs') + ) + .map(({contrib, thing: flash}) => ({ + link: serializeLink(flash), + contribution: contrib.what, + })), + } + : null, + tracks: { + asArtist: artist.tracksAsArtist.map( + serializeArtistsAndContrib('artistContribs') + ), + asContributor: artist.tracksAsContributor.map( + serializeArtistsAndContrib('contributorContribs') + ), + chunked: serializeTrackListChunks(trackListChunks), + }, + }; + }, + }; + + const infoPage = { + type: 'page', + path: ['artist', artist.directory], + page: ({ + fancifyURL, + generateCoverLink, + generateInfoGalleryLinks, + getArtistAvatar, + getArtistString, + link, + language, + transformMultiline, + }) => { + const generateTrackList = bindOpts(unbound_generateTrackList, { + getArtistString, + link, + language, + }); + + return { + title: language.$('artistPage.title', {artist: name}), + + main: { + content: fixWS` + ${ + artist.hasAvatar && + generateCoverLink({ src: getArtistAvatar(artist), - alt: language.$('misc.alt.artistAvatar') - })} - <h1>${language.$('artistPage.title', {artist: name})}</h1> - ${contextNotes && fixWS` + alt: language.$('misc.alt.artistAvatar'), + }) + } + <h1>${language.$('artistPage.title', { + artist: name, + })}</h1> + ${ + contextNotes && + fixWS` <p>${language.$('releaseInfo.note')}</p> <blockquote> ${transformMultiline(contextNotes)} </blockquote> <hr> - `} - ${urls?.length && `<p>${language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(urls.map(url => fancifyURL(url, {language}))) - })}</p>`} - ${hasGallery && `<p>${language.$('artistPage.viewArtGallery', { + ` + } + ${ + urls?.length && + `<p>${language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList( + urls.map((url) => fancifyURL(url, {language})) + ), + })}</p>` + } + ${ + hasGallery && + `<p>${language.$('artistPage.viewArtGallery', { link: link.artistGallery(artist, { - text: language.$('artistPage.viewArtGallery.link') - }) - })}</p>`} + text: language.$( + 'artistPage.viewArtGallery.link' + ), + }), + })}</p>` + } <p>${language.$('misc.jumpTo.withLinks', { - links: language.formatUnitList([ - allTracks.length && `<a href="#tracks">${language.$('artistPage.trackList.title')}</a>`, - artThingsAll.length && `<a href="#art">${language.$('artistPage.artList.title')}</a>`, - wikiInfo.enableFlashesAndGames && flashes.length && `<a href="#flashes">${language.$('artistPage.flashList.title')}</a>`, - commentaryThings.length && `<a href="#commentary">${language.$('artistPage.commentaryList.title')}</a>` - ].filter(Boolean)) + links: language.formatUnitList( + [ + allTracks.length && + `<a href="#tracks">${language.$( + 'artistPage.trackList.title' + )}</a>`, + artThingsAll.length && + `<a href="#art">${language.$( + 'artistPage.artList.title' + )}</a>`, + wikiInfo.enableFlashesAndGames && + flashes.length && + `<a href="#flashes">${language.$( + 'artistPage.flashList.title' + )}</a>`, + commentaryThings.length && + `<a href="#commentary">${language.$( + 'artistPage.commentaryList.title' + )}</a>`, + ].filter(Boolean) + ), })}</p> - ${allTracks.length && fixWS` - <h2 id="tracks">${language.$('artistPage.trackList.title')}</h2> - <p>${language.$('artistPage.contributedDurationLine', { + ${ + allTracks.length && + fixWS` + <h2 id="tracks">${language.$( + 'artistPage.trackList.title' + )}</h2> + <p>${language.$( + 'artistPage.contributedDurationLine', + { artist: artist.name, - duration: language.formatDuration(totalDuration, {approximate: true, unit: true}) - })}</p> + duration: language.formatDuration( + totalDuration, + { + approximate: true, + unit: true, + } + ), + } + )}</p> <p>${language.$('artistPage.musicGroupsLine', { - groups: language.formatUnitList(musicGroups - .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: language.countContributions(contributions) - }))) + groups: language.formatUnitList( + musicGroups.map(({group, contributions}) => + language.$('artistPage.groupsLine.item', { + group: link.groupInfo(group), + contributions: + language.countContributions( + contributions + ), + }) + ) + ), })}</p> ${generateTrackList(trackListChunks)} - `} - ${artThingsAll.length && fixWS` - <h2 id="art">${language.$('artistPage.artList.title')}</h2> - ${hasGallery && `<p>${language.$('artistPage.viewArtGallery.orBrowseList', { - link: link.artistGallery(artist, { - text: language.$('artistPage.viewArtGallery.link') - }) - })}</p>`} + ` + } + ${ + artThingsAll.length && + fixWS` + <h2 id="art">${language.$( + 'artistPage.artList.title' + )}</h2> + ${ + hasGallery && + `<p>${language.$( + 'artistPage.viewArtGallery.orBrowseList', + { + link: link.artistGallery(artist, { + text: language.$( + 'artistPage.viewArtGallery.link' + ), + }), + } + )}</p>` + } <p>${language.$('artistPage.artGroupsLine', { - groups: language.formatUnitList(artGroups - .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: language.countContributions(contributions) - }))) + groups: language.formatUnitList( + artGroups.map(({group, contributions}) => + language.$('artistPage.groupsLine.item', { + group: link.groupInfo(group), + contributions: + language.countContributions( + contributions + ), + }) + ) + ), })}</p> <dl> - ${artListChunks.map(({date, album, chunk}) => fixWS` - <dt>${language.$('artistPage.creditList.album.withDate', { + ${artListChunks + .map( + ({date, album, chunk}) => fixWS` + <dt>${language.$( + 'artistPage.creditList.album.withDate', + { album: link.album(album), - date: language.formatDate(date) - })}</dt> + date: language.formatDate(date), + } + )}</dt> <dd><ul> - ${(chunk - .map(({album, track, key, ...props}) => ({ - entry: (track - ? language.$('artistPage.creditList.entry.track', { - track: link.track(track) - }) - : `<i>${language.$('artistPage.creditList.entry.album.' + { - wallpaperArtistContribs: 'wallpaperArt', - bannerArtistContribs: 'bannerArt', - coverArtistContribs: 'coverArt' - }[key])}</i>`), - ...props - })) - .map(opts => generateEntryAccents({getArtistString, language, ...opts})) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${chunk + .map( + ({ + track, + key, + ...props + }) => ({ + entry: track + ? language.$( + 'artistPage.creditList.entry.track', + { + track: link.track(track), + } + ) + : `<i>${language.$( + 'artistPage.creditList.entry.album.' + + { + wallpaperArtistContribs: + 'wallpaperArt', + bannerArtistContribs: + 'bannerArt', + coverArtistContribs: + 'coverArt', + }[key] + )}</i>`, + ...props, + }) + ) + .map((opts) => + generateEntryAccents({ + getArtistString, + language, + ...opts, + }) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> - `} - ${wikiInfo.enableFlashesAndGames && flashes.length && fixWS` - <h2 id="flashes">${language.$('artistPage.flashList.title')}</h2> + ` + } + ${ + wikiInfo.enableFlashesAndGames && + flashes.length && + fixWS` + <h2 id="flashes">${language.$( + 'artistPage.flashList.title' + )}</h2> <dl> - ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS` - <dt>${language.$('artistPage.creditList.flashAct.withDateRange', { - act: link.flash(chunk[0].flash, {text: act.name}), - dateRange: language.formatDateRange(dateFirst, dateLast) - })}</dt> + ${flashListChunks + .map( + ({ + act, + chunk, + dateFirst, + dateLast, + }) => fixWS` + <dt>${language.$( + 'artistPage.creditList.flashAct.withDateRange', + { + act: link.flash(chunk[0].flash, { + text: act.name, + }), + dateRange: language.formatDateRange( + dateFirst, + dateLast + ), + } + )}</dt> <dd><ul> - ${(chunk - .map(({flash, ...props}) => ({ - entry: language.$('artistPage.creditList.entry.flash', { - flash: link.flash(flash) - }), - ...props - })) - .map(opts => generateEntryAccents({getArtistString, language, ...opts})) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${chunk + .map(({flash, ...props}) => ({ + entry: language.$( + 'artistPage.creditList.entry.flash', + { + flash: link.flash(flash), + } + ), + ...props, + })) + .map((opts) => + generateEntryAccents({ + getArtistString, + language, + ...opts, + }) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> - `} - ${commentaryThings.length && fixWS` - <h2 id="commentary">${language.$('artistPage.commentaryList.title')}</h2> + ` + } + ${ + commentaryThings.length && + fixWS` + <h2 id="commentary">${language.$( + 'artistPage.commentaryList.title' + )}</h2> <dl> - ${commentaryListChunks.map(({album, chunk}) => fixWS` - <dt>${language.$('artistPage.creditList.album', { - album: link.album(album) - })}</dt> + ${commentaryListChunks + .map( + ({album, chunk}) => fixWS` + <dt>${language.$( + 'artistPage.creditList.album', + { + album: link.album(album), + } + )}</dt> <dd><ul> - ${(chunk - .map(({album, track, ...props}) => track - ? language.$('artistPage.creditList.entry.track', { - track: link.track(track) - }) - : `<i>${language.$('artistPage.creditList.entry.album.commentary')}</i>`) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${chunk + .map(({track}) => + track + ? language.$( + 'artistPage.creditList.entry.track', + { + track: link.track(track), + } + ) + : `<i>${language.$( + 'artistPage.creditList.entry.album.commentary' + )}</i>` + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul></dd> - `).join('\n')} + ` + ) + .join('\n')} </dl> - `} - ` - }, - - nav: generateNavForArtist(artist, false, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData - }) - }; - } - }; - - const galleryPage = hasGallery && { - type: 'page', - path: ['artistGallery', artist.directory], - page: ({ - generateInfoGalleryLinks, - getAlbumCover, - getGridHTML, - getTrackCover, - link, - language, - to - }) => ({ - title: language.$('artistGalleryPage.title', {artist: name}), - - main: { - classes: ['top-index'], - content: fixWS` - <h1>${language.$('artistGalleryPage.title', {artist: name})}</h1> - <p class="quick-info">${language.$('artistGalleryPage.infoLine', { - coverArts: language.countCoverArts(artThingsGallery.length, {unit: true}) - })}</p> + ` + } + `, + }, + + nav: generateNavForArtist(artist, false, hasGallery, { + generateInfoGalleryLinks, + link, + language, + wikiData, + }), + }; + }, + }; + + const galleryPage = hasGallery && { + type: 'page', + path: ['artistGallery', artist.directory], + page: ({ + generateInfoGalleryLinks, + getAlbumCover, + getGridHTML, + getTrackCover, + link, + language, + }) => ({ + title: language.$('artistGalleryPage.title', {artist: name}), + + main: { + classes: ['top-index'], + content: fixWS` + <h1>${language.$('artistGalleryPage.title', { + artist: name, + })}</h1> + <p class="quick-info">${language.$( + 'artistGalleryPage.infoLine', + { + coverArts: language.countCoverArts( + artThingsGallery.length, + { + unit: true, + } + ), + } + )}</p> <div class="grid-listing"> ${getGridHTML({ - entries: artThingsGallery.map(item => ({item})), - srcFn: thing => (thing.album - ? getTrackCover(thing) - : getAlbumCover(thing)), - linkFn: (thing, opts) => (thing.album - ? link.track(thing, opts) - : link.album(thing, opts)) + entries: artThingsGallery.map((item) => ({item})), + srcFn: (thing) => + thing.album + ? getTrackCover(thing) + : getAlbumCover(thing), + linkFn: (thing, opts) => + thing.album + ? link.track(thing, opts) + : link.album(thing, opts), })} </div> - ` - }, - - nav: generateNavForArtist(artist, true, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData - }) - }) - }; - - return [data, infoPage, galleryPage].filter(Boolean); + `, + }, + + nav: generateNavForArtist(artist, true, hasGallery, { + generateInfoGalleryLinks, + link, + language, + wikiData, + }), + }), + }; + + return [data, infoPage, galleryPage].filter(Boolean); } // Utility functions -function generateNavForArtist(artist, isGallery, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData -}) { - const { wikiInfo } = wikiData; - - const infoGalleryLinks = (hasGallery && - generateInfoGalleryLinks(artist, isGallery, { - link, language, - linkKeyGallery: 'artistGallery', - linkKeyInfo: 'artist' - })) +function generateNavForArtist( + artist, + isGallery, + hasGallery, + {generateInfoGalleryLinks, link, language, wikiData} +) { + const {wikiInfo} = wikiData; + + const infoGalleryLinks = + hasGallery && + generateInfoGalleryLinks(artist, isGallery, { + link, + language, + linkKeyGallery: 'artistGallery', + linkKeyInfo: 'artist', + }); - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiInfo.enableListings && - { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title') - }, - { - html: language.$('artistPage.nav.artist', { - artist: link.artist(artist, {class: 'current'}) - }) - }, - hasGallery && - { - divider: false, - html: `(${infoGalleryLinks})` - } - ] - }; + return { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + wikiInfo.enableListings && { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + { + html: language.$('artistPage.nav.artist', { + artist: link.artist(artist, {class: 'current'}), + }), + }, + hasGallery && { + divider: false, + html: `(${infoGalleryLinks})`, + }, + ], + }; } diff --git a/src/page/flash.js b/src/page/flash.js index 21a22b94..a4b3b9b0 100644 --- a/src/page/flash.js +++ b/src/page/flash.js @@ -1,3 +1,5 @@ +/** @format */ + // Flash page and index specifications. // Imports @@ -6,247 +8,318 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - getFlashLink -} from '../util/wiki-data.js'; +import {getFlashLink} from '../util/wiki-data.js'; // Page exports export function condition({wikiData}) { - return wikiData.wikiInfo.enableFlashesAndGames; + return wikiData.wikiInfo.enableFlashesAndGames; } export function targets({wikiData}) { - return wikiData.flashData; + return wikiData.flashData; } export function write(flash, {wikiData}) { - const page = { - type: 'page', - path: ['flash', flash.directory], - page: ({ - fancifyFlashURL, - generateChronologyLinks, - generateCoverLink, - generatePreviousNextLinks, - getArtistString, - getFlashCover, - getThemeString, - link, - language, - transformInline - }) => ({ - title: language.$('flashPage.title', {flash: flash.name}), - theme: getThemeString(flash.color, [ - `--flash-directory: ${flash.directory}` - ]), - - main: { - content: fixWS` - <h1>${language.$('flashPage.title', {flash: flash.name})}</h1> + const page = { + type: 'page', + path: ['flash', flash.directory], + page: ({ + fancifyFlashURL, + generateChronologyLinks, + generateCoverLink, + generatePreviousNextLinks, + getArtistString, + getFlashCover, + getThemeString, + link, + language, + }) => ({ + title: language.$('flashPage.title', {flash: flash.name}), + theme: getThemeString(flash.color, [ + `--flash-directory: ${flash.directory}`, + ]), + + main: { + content: fixWS` + <h1>${language.$('flashPage.title', { + flash: flash.name, + })}</h1> ${generateCoverLink({ - src: getFlashCover(flash), - alt: language.$('misc.alt.flashArt') + src: getFlashCover(flash), + alt: language.$('misc.alt.flashArt'), })} - <p>${language.$('releaseInfo.released', {date: language.formatDate(flash.date)})}</p> - ${(flash.page || flash.urls?.length) && `<p>${language.$('releaseInfo.playOn', { - links: language.formatDisjunctionList([ + <p>${language.$('releaseInfo.released', { + date: language.formatDate(flash.date), + })}</p> + ${ + (flash.page || flash.urls?.length) && + `<p>${language.$('releaseInfo.playOn', { + links: language.formatDisjunctionList( + [ flash.page && getFlashLink(flash), - ...flash.urls ?? [] - ].map(url => fancifyFlashURL(url, flash))) - })}</p>`} - ${flash.featuredTracks && fixWS` - <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p> + ...(flash.urls ?? []), + ].map((url) => fancifyFlashURL(url, flash)) + ), + })}</p>` + } + ${ + flash.featuredTracks && + fixWS` + <p>Tracks featured in <i>${flash.name.replace( + /\.$/, + '' + )}</i>:</p> <ul> - ${(flash.featuredTracks - .map(track => language.$('trackList.item.withArtists', { - track: link.track(track), - by: `<span class="by">${ - language.$('trackList.item.withArtists.by', { - artists: getArtistString(track.artistContribs) - }) - }</span>` - })) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${flash.featuredTracks + .map((track) => + language.$('trackList.item.withArtists', { + track: link.track(track), + by: `<span class="by">${language.$( + 'trackList.item.withArtists.by', + { + artists: getArtistString( + track.artistContribs + ), + } + )}</span>`, + }) + ) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> - `} - ${flash.contributorContribs.length && fixWS` + ` + } + ${ + flash.contributorContribs.length && + fixWS` <p>${language.$('releaseInfo.contributors')}</p> <ul> ${flash.contributorContribs - .map(contrib => `<li>${getArtistString([contrib], { + .map( + (contrib) => + `<li>${getArtistString([contrib], { showContrib: true, - showIcons: true - })}</li>`) - .join('\n')} + showIcons: true, + })}</li>` + ) + .join('\n')} </ul> - `} - ` - }, - - sidebarLeft: generateSidebarForFlash(flash, {link, language, wikiData}), - nav: generateNavForFlash(flash, { - generateChronologyLinks, - generatePreviousNextLinks, - link, - language, - wikiData - }) - }) - }; - - return [page]; + ` + } + `, + }, + + sidebarLeft: generateSidebarForFlash(flash, {link, language, wikiData}), + nav: generateNavForFlash(flash, { + generateChronologyLinks, + generatePreviousNextLinks, + link, + language, + wikiData, + }), + }), + }; + + return [page]; } export function writeTargetless({wikiData}) { - const { flashActData } = wikiData; - - const page = { - type: 'page', - path: ['flashIndex'], - page: ({ - getFlashGridHTML, - getLinkThemeString, - link, - language - }) => ({ - title: language.$('flashIndex.title'), - - main: { - classes: ['flash-index'], - content: fixWS` + const {flashActData} = wikiData; + + const page = { + type: 'page', + path: ['flashIndex'], + page: ({getFlashGridHTML, getLinkThemeString, link, language}) => ({ + title: language.$('flashIndex.title'), + + main: { + classes: ['flash-index'], + content: fixWS` <h1>${language.$('flashIndex.title')}</h1> <div class="long-content"> <p class="quick-info">${language.$('misc.jumpTo')}</p> <ul class="quick-info"> - ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS` - <li><a href="#${anchor}" style="${getLinkThemeString(jumpColor)}">${jump}</a></li> - `).join('\n')} + ${flashActData + .filter((act) => act.jump) + .map( + ({anchor, jump, jumpColor}) => fixWS` + <li><a href="#${anchor}" style="${getLinkThemeString( + jumpColor + )}">${jump}</a></li> + ` + ) + .join('\n')} </ul> </div> - ${flashActData.map((act, i) => fixWS` - <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}">${link.flash(act.flashes[0], {text: act.name})}</h2> + ${flashActData + .map( + (act, i) => fixWS` + <h2 id="${act.anchor}" style="${getLinkThemeString( + act.color + )}">${link.flash(act.flashes[0], { + text: act.name, + })}</h2> <div class="grid-listing"> ${getFlashGridHTML({ - entries: act.flashes.map(flash => ({item: flash})), - lazy: i === 0 ? 4 : true + entries: act.flashes.map((flash) => ({ + item: flash, + })), + lazy: i === 0 ? 4 : true, })} </div> - `).join('\n')} - ` - }, + ` + ) + .join('\n')} + `, + }, - nav: {simple: true} - }) - }; + nav: {simple: true}, + }), + }; - return [page]; + return [page]; } // Utility functions -function generateNavForFlash(flash, { - generateChronologyLinks, - generatePreviousNextLinks, - link, - language, - wikiData -}) { - const { flashData, wikiInfo } = wikiData; - - const previousNextLinks = generatePreviousNextLinks(flash, { - data: flashData, - linkKey: 'flash' - }); - - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.flashIndex'], - title: language.$('flashIndex.title') - }, - { - html: language.$('flashPage.nav.flash', { - flash: link.flash(flash, {class: 'current'}) - }) - }, - ], - - bottomRowContent: previousNextLinks && `(${previousNextLinks})`, +function generateNavForFlash( + flash, + {generateChronologyLinks, generatePreviousNextLinks, link, language, wikiData} +) { + const {flashData} = wikiData; - content: fixWS` + const previousNextLinks = generatePreviousNextLinks(flash, { + data: flashData, + linkKey: 'flash', + }); + + return { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + path: ['localized.flashIndex'], + title: language.$('flashIndex.title'), + }, + { + html: language.$('flashPage.nav.flash', { + flash: link.flash(flash, {class: 'current'}), + }), + }, + ], + + bottomRowContent: previousNextLinks && `(${previousNextLinks})`, + + content: fixWS` <div> ${generateChronologyLinks(flash, { - headingString: 'misc.chronology.heading.flash', - contribKey: 'contributorContribs', - getThings: artist => artist.flashesAsContributor + headingString: 'misc.chronology.heading.flash', + contribKey: 'contributorContribs', + getThings: (artist) => artist.flashesAsContributor, })} </div> - ` - }; + `, + }; } function generateSidebarForFlash(flash, {link, language, wikiData}) { - // all hard-coded, sorry :( - // this doesnt have a super portable implementation/design...yet!! - - const { flashActData } = wikiData; - - const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6')); - const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon')); - const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon')); - const actIndex = flashActData.indexOf(flash.act); - const side = ( - (actIndex < 0) ? 0 : - (actIndex < act6) ? 1 : - (actIndex <= outsideCanon) ? 2 : - 3 - ); - const currentAct = flash && flash.act; - - return { - content: fixWS` - <h1>${link.flashIndex('', {text: language.$('flashIndex.title')})}</h1> + // all hard-coded, sorry :( + // this doesnt have a super portable implementation/design...yet!! + + const {flashActData} = wikiData; + + const act6 = flashActData.findIndex((act) => act.name.startsWith('Act 6')); + const postCanon = flashActData.findIndex((act) => + act.name.includes('Post Canon') + ); + const outsideCanon = + postCanon + + flashActData + .slice(postCanon) + .findIndex((act) => !act.name.includes('Post Canon')); + const actIndex = flashActData.indexOf(flash.act); + const side = + actIndex < 0 ? 0 : actIndex < act6 ? 1 : actIndex <= outsideCanon ? 2 : 3; + const currentAct = flash && flash.act; + + return { + content: fixWS` + <h1>${link.flashIndex('', { + text: language.$('flashIndex.title'), + })}</h1> <dl> - ${flashActData.filter(act => - act.name.startsWith('Act 1') || - act.name.startsWith('Act 6 Act 1') || - act.name.startsWith('Hiveswap') || - // Sorry not sorry -Yiffy - (({index = flashActData.indexOf(act)} = {}) => ( - index < act6 ? side === 1 : - index < outsideCanon ? side === 2 : - true - ))() - ).flatMap(act => [ - act.name.startsWith('Act 1') && html.tag('dt', + ${flashActData + .filter( + (act) => + act.name.startsWith('Act 1') || + act.name.startsWith('Act 6 Act 1') || + act.name.startsWith('Hiveswap') || + // Sorry not sorry -Yiffy + (({index = flashActData.indexOf(act)} = {}) => + index < act6 + ? side === 1 + : index < outsideCanon + ? side === 2 + : true)() + ) + .flatMap((act) => [ + (act.name.startsWith('Act 1') && + html.tag( + 'dt', {class: ['side', side === 1 && 'current']}, - link.flash(act.flashes[0], {color: '#4ac925', text: `Side 1 (Acts 1-5)`})) - || act.name.startsWith('Act 6 Act 1') && html.tag('dt', - {class: ['side', side === 2 && 'current']}, - link.flash(act.flashes[0], {color: '#1076a2', text: `Side 2 (Acts 6-7)`})) - || act.name.startsWith('Hiveswap Act 1') && html.tag('dt', - {class: ['side', side === 3 && 'current']}, - link.flash(act.flashes[0], {color: '#008282', text: `Outside Canon (Misc. Games)`})), - (({index = flashActData.indexOf(act)} = {}) => ( - index < act6 ? side === 1 : - index < outsideCanon ? side === 2 : - true - ))() && html.tag('dt', + link.flash(act.flashes[0], { + color: '#4ac925', + text: `Side 1 (Acts 1-5)`, + }) + )) || + (act.name.startsWith('Act 6 Act 1') && + html.tag( + 'dt', + {class: ['side', side === 2 && 'current']}, + link.flash(act.flashes[0], { + color: '#1076a2', + text: `Side 2 (Acts 6-7)`, + }) + )) || + (act.name.startsWith('Hiveswap Act 1') && + html.tag( + 'dt', + {class: ['side', side === 3 && 'current']}, + link.flash(act.flashes[0], { + color: '#008282', + text: `Outside Canon (Misc. Games)`, + }) + )), + (({index = flashActData.indexOf(act)} = {}) => + index < act6 + ? side === 1 + : index < outsideCanon + ? side === 2 + : true)() && + html.tag( + 'dt', {class: act === currentAct && 'current'}, - link.flash(act.flashes[0], {text: act.name})), - act === currentAct && fixWS` + link.flash(act.flashes[0], {text: act.name}) + ), + act === currentAct && + fixWS` <dd><ul> - ${act.flashes.map(f => html.tag('li', - {class: f === flash && 'current'}, - link.flash(f))).join('\n')} + ${act.flashes + .map((f) => + html.tag( + 'li', + {class: f === flash && 'current'}, + link.flash(f) + ) + ) + .join('\n')} </ul></dd> - ` - ]).filter(Boolean).join('\n')} + `, + ]) + .filter(Boolean) + .join('\n')} </dl> - ` - }; + `, + }; } diff --git a/src/page/group.js b/src/page/group.js index b83244a3..5a6b611e 100644 --- a/src/page/group.js +++ b/src/page/group.js @@ -1,3 +1,5 @@ +/** @format */ + // Group page specifications. // Imports @@ -6,264 +8,321 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - getTotalDuration, - sortChronologically, -} from '../util/wiki-data.js'; +import {getTotalDuration, sortChronologically} from '../util/wiki-data.js'; // Page exports export function targets({wikiData}) { - return wikiData.groupData; + return wikiData.groupData; } export function write(group, {wikiData}) { - const { listingSpec, wikiInfo } = wikiData; + const {listingSpec, wikiInfo} = wikiData; - const { albums } = group; - const tracks = albums.flatMap(album => album.tracks); - const totalDuration = getTotalDuration(tracks); + const {albums} = group; + const tracks = albums.flatMap((album) => album.tracks); + const totalDuration = getTotalDuration(tracks); - const albumLines = group.albums.map(album => ({ - album, - otherGroup: album.groups.find(g => g !== group) - })); + const albumLines = group.albums.map((album) => ({ + album, + otherGroup: album.groups.find((g) => g !== group), + })); - const infoPage = { - type: 'page', - path: ['groupInfo', group.directory], - page: ({ - generateInfoGalleryLinks, - generatePreviousNextLinks, - getLinkThemeString, - getThemeString, - fancifyURL, - link, - language, - transformMultiline - }) => ({ - title: language.$('groupInfoPage.title', {group: group.name}), - theme: getThemeString(group.color), + const infoPage = { + type: 'page', + path: ['groupInfo', group.directory], + page: ({ + generateInfoGalleryLinks, + generatePreviousNextLinks, + getLinkThemeString, + getThemeString, + fancifyURL, + link, + language, + transformMultiline, + }) => ({ + title: language.$('groupInfoPage.title', {group: group.name}), + theme: getThemeString(group.color), - main: { - content: fixWS` - <h1>${language.$('groupInfoPage.title', {group: group.name})}</h1> - ${group.urls?.length && `<p>${ - language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url, {language}))) - }) - }</p>`} + main: { + content: fixWS` + <h1>${language.$('groupInfoPage.title', { + group: group.name, + })}</h1> + ${ + group.urls?.length && + `<p>${language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList( + group.urls.map((url) => fancifyURL(url, {language})) + ), + })}</p>` + } <blockquote> ${transformMultiline(group.description)} </blockquote> <h2>${language.$('groupInfoPage.albumList.title')}</h2> - <p>${ - language.$('groupInfoPage.viewAlbumGallery', { - link: link.groupGallery(group, { - text: language.$('groupInfoPage.viewAlbumGallery.link') - }) - }) - }</p> + <p>${language.$('groupInfoPage.viewAlbumGallery', { + link: link.groupGallery(group, { + text: language.$('groupInfoPage.viewAlbumGallery.link'), + }), + })}</p> <ul> - ${albumLines.map(({ album, otherGroup }) => { - const item = (album.date - ? language.$('groupInfoPage.albumList.item', { - year: album.date.getFullYear(), - album: link.album(album) + ${albumLines + .map(({album, otherGroup}) => { + const item = album.date + ? language.$('groupInfoPage.albumList.item', { + year: album.date.getFullYear(), + album: link.album(album), }) - : language.$('groupInfoPage.albumList.item.withoutYear', { - album: link.album(album) - })); - return html.tag('li', (otherGroup - ? language.$('groupInfoPage.albumList.item.withAccent', { - item, - accent: html.tag('span', + : language.$( + 'groupInfoPage.albumList.item.withoutYear', + { + album: link.album(album), + } + ); + return html.tag( + 'li', + otherGroup + ? language.$( + 'groupInfoPage.albumList.item.withAccent', + { + item, + accent: html.tag( + 'span', {class: 'other-group-accent'}, - language.$('groupInfoPage.albumList.item.otherGroupAccent', { - group: link.groupInfo(otherGroup, {color: false}) - })) - }) - : item)); - }).join('\n')} + language.$( + 'groupInfoPage.albumList.item.otherGroupAccent', + { + group: link.groupInfo(otherGroup, { + color: false, + }), + } + ) + ), + } + ) + : item + ); + }) + .join('\n')} </ul> - ` - }, + `, + }, - sidebarLeft: generateGroupSidebar(group, false, { - getLinkThemeString, - link, - language, - wikiData - }), + sidebarLeft: generateGroupSidebar(group, false, { + getLinkThemeString, + link, + language, + wikiData, + }), - nav: generateGroupNav(group, false, { - generateInfoGalleryLinks, - generatePreviousNextLinks, - link, - language, - wikiData - }) - }) - }; + nav: generateGroupNav(group, false, { + generateInfoGalleryLinks, + generatePreviousNextLinks, + link, + language, + wikiData, + }), + }), + }; - const galleryPage = { - type: 'page', - path: ['groupGallery', group.directory], - page: ({ - generateInfoGalleryLinks, - generatePreviousNextLinks, - getAlbumGridHTML, - getLinkThemeString, - getThemeString, - link, - language - }) => ({ - title: language.$('groupGalleryPage.title', {group: group.name}), - theme: getThemeString(group.color), + const galleryPage = { + type: 'page', + path: ['groupGallery', group.directory], + page: ({ + generateInfoGalleryLinks, + generatePreviousNextLinks, + getAlbumGridHTML, + getLinkThemeString, + getThemeString, + link, + language, + }) => ({ + title: language.$('groupGalleryPage.title', {group: group.name}), + theme: getThemeString(group.color), - main: { - classes: ['top-index'], - content: fixWS` - <h1>${language.$('groupGalleryPage.title', {group: group.name})}</h1> - <p class="quick-info">${ - language.$('groupGalleryPage.infoLine', { - tracks: `<b>${language.countTracks(tracks.length, {unit: true})}</b>`, - albums: `<b>${language.countAlbums(albums.length, {unit: true})}</b>`, - time: `<b>${language.formatDuration(totalDuration, {unit: true})}</b>` - }) - }</p> - ${wikiInfo.enableGroupUI && wikiInfo.enableListings && html.tag('p', + main: { + classes: ['top-index'], + content: fixWS` + <h1>${language.$('groupGalleryPage.title', { + group: group.name, + })}</h1> + <p class="quick-info">${language.$( + 'groupGalleryPage.infoLine', + { + tracks: `<b>${language.countTracks(tracks.length, { + unit: true, + })}</b>`, + albums: `<b>${language.countAlbums(albums.length, { + unit: true, + })}</b>`, + time: `<b>${language.formatDuration(totalDuration, { + unit: true, + })}</b>`, + } + )}</p> + ${ + wikiInfo.enableGroupUI && + wikiInfo.enableListings && + html.tag( + 'p', {class: 'quick-info'}, language.$('groupGalleryPage.anotherGroupLine', { - link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), { - text: language.$('groupGalleryPage.anotherGroupLine.link') - }) + link: link.listing( + listingSpec.find( + (l) => l.directory === 'groups/by-category' + ), + { + text: language.$( + 'groupGalleryPage.anotherGroupLine.link' + ), + } + ), }) - )} + ) + } <div class="grid-listing"> ${getAlbumGridHTML({ - entries: sortChronologically(group.albums.map(album => ({ - item: album, - directory: album.directory, - name: album.name, - date: album.date, - }))).reverse(), - details: true + entries: sortChronologically( + group.albums.map((album) => ({ + item: album, + directory: album.directory, + name: album.name, + date: album.date, + })) + ).reverse(), + details: true, })} </div> - ` - }, + `, + }, - sidebarLeft: generateGroupSidebar(group, true, { - getLinkThemeString, - link, - language, - wikiData - }), + sidebarLeft: generateGroupSidebar(group, true, { + getLinkThemeString, + link, + language, + wikiData, + }), - nav: generateGroupNav(group, true, { - generateInfoGalleryLinks, - generatePreviousNextLinks, - link, - language, - wikiData - }) - }) - }; + nav: generateGroupNav(group, true, { + generateInfoGalleryLinks, + generatePreviousNextLinks, + link, + language, + wikiData, + }), + }), + }; - return [infoPage, galleryPage]; + return [infoPage, galleryPage]; } // Utility functions -function generateGroupSidebar(currentGroup, isGallery, { - getLinkThemeString, - link, - language, - wikiData -}) { - const { groupCategoryData, wikiInfo } = wikiData; +function generateGroupSidebar( + currentGroup, + isGallery, + {getLinkThemeString, link, language, wikiData} +) { + const {groupCategoryData, wikiInfo} = wikiData; - if (!wikiInfo.enableGroupUI) { - return null; - } + if (!wikiInfo.enableGroupUI) { + return null; + } - const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; + const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; - return { - content: fixWS` + return { + content: fixWS` <h1>${language.$('groupSidebar.title')}</h1> - ${groupCategoryData.map(category => - html.tag('details', { + ${groupCategoryData + .map((category) => + html.tag( + 'details', + { open: category === currentGroup.category, - class: category === currentGroup.category && 'current' - }, [ - html.tag('summary', - {style: getLinkThemeString(category.color)}, - language.$('groupSidebar.groupList.category', { - category: `<span class="group-name">${category.name}</span>` - })), - html.tag('ul', - category.groups.map(group => html.tag('li', - { - class: group === currentGroup && 'current', - style: getLinkThemeString(group.color) - }, - language.$('groupSidebar.groupList.item', { - group: link[linkKey](group) - })))) - ])).join('\n')} + class: category === currentGroup.category && 'current', + }, + [ + html.tag( + 'summary', + {style: getLinkThemeString(category.color)}, + language.$('groupSidebar.groupList.category', { + category: `<span class="group-name">${category.name}</span>`, + }) + ), + html.tag( + 'ul', + category.groups.map((group) => + html.tag( + 'li', + { + class: group === currentGroup && 'current', + style: getLinkThemeString(group.color), + }, + language.$('groupSidebar.groupList.item', { + group: link[linkKey](group), + }) + ) + ) + ), + ] + ) + ) + .join('\n')} </dl> - ` - }; + `, + }; } -function generateGroupNav(currentGroup, isGallery, { +function generateGroupNav( + currentGroup, + isGallery, + { generateInfoGalleryLinks, generatePreviousNextLinks, link, language, - wikiData -}) { - const { groupData, wikiInfo } = wikiData; + wikiData, + } +) { + const {groupData, wikiInfo} = wikiData; - if (!wikiInfo.enableGroupUI) { - return {simple: true}; - } + if (!wikiInfo.enableGroupUI) { + return {simple: true}; + } - const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo'; - const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; + const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; - const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, { - linkKeyGallery: 'groupGallery', - linkKeyInfo: 'groupInfo' - }); + const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, { + linkKeyGallery: 'groupGallery', + linkKeyInfo: 'groupInfo', + }); - const previousNextLinks = generatePreviousNextLinks(currentGroup, { - data: groupData, - linkKey - }); + const previousNextLinks = generatePreviousNextLinks(currentGroup, { + data: groupData, + linkKey, + }); - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiInfo.enableListings && - { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title') - }, - { - html: language.$('groupPage.nav.group', { - group: link[linkKey](currentGroup, {class: 'current'}) - }) - }, - { - divider: false, - html: (previousNextLinks - ? `(${infoGalleryLinks}; ${previousNextLinks})` - : `(${previousNextLinks})`) - } - ] - }; + return { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + wikiInfo.enableListings && { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + { + html: language.$('groupPage.nav.group', { + group: link[linkKey](currentGroup, {class: 'current'}), + }), + }, + { + divider: false, + html: previousNextLinks + ? `(${infoGalleryLinks}; ${previousNextLinks})` + : `(${previousNextLinks})`, + }, + ], + }; } diff --git a/src/page/homepage.js b/src/page/homepage.js index a19df6cf..1356aaf5 100644 --- a/src/page/homepage.js +++ b/src/page/homepage.js @@ -1,3 +1,5 @@ +/** @format */ + // Homepage specification. // Imports @@ -6,119 +8,184 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - getNewAdditions, - getNewReleases -} from '../util/wiki-data.js'; +import {getNewAdditions, getNewReleases} from '../util/wiki-data.js'; // Page exports export function writeTargetless({wikiData}) { - const { newsData, staticPageData, homepageLayout, wikiInfo } = wikiData; - - const page = { - type: 'page', - path: ['home'], - page: ({ - getAlbumGridHTML, - getLinkThemeString, - link, - language, - to, - transformInline, - transformMultiline - }) => ({ - title: wikiInfo.name, - showWikiNameInTitle: false, - - meta: { - description: wikiInfo.description - }, - - main: { - classes: ['top-index'], - content: fixWS` + const {newsData, staticPageData, homepageLayout, wikiInfo} = wikiData; + + const page = { + type: 'page', + path: ['home'], + page: ({ + getAlbumGridHTML, + getLinkThemeString, + link, + language, + to, + transformInline, + transformMultiline, + }) => ({ + title: wikiInfo.name, + showWikiNameInTitle: false, + + meta: { + description: wikiInfo.description, + }, + + main: { + classes: ['top-index'], + content: fixWS` <h1>${wikiInfo.name}</h1> - ${homepageLayout.rows?.map((row, i) => fixWS` - <section class="row" style="${getLinkThemeString(row.color)}"> + ${homepageLayout.rows + ?.map( + (row, i) => fixWS` + <section class="row" style="${getLinkThemeString( + row.color + )}"> <h2>${row.name}</h2> - ${row.type === 'albums' && fixWS` + ${ + row.type === 'albums' && + fixWS` <div class="grid-listing"> ${getAlbumGridHTML({ - entries: ( - row.sourceGroupByRef === 'new-releases' ? getNewReleases(row.countAlbumsFromGroup, {wikiData}) : - row.sourceGroupByRef === 'new-additions' ? getNewAdditions(row.countAlbumsFromGroup, {wikiData}) : - ((row.sourceGroup?.albums ?? []) - .slice() - .reverse() - .filter(album => album.isListedOnHomepage) - .slice(0, row.countAlbumsFromGroup) - .map(album => ({item: album}))) - ).concat(row.sourceAlbums.map(album => ({item: album}))), - lazy: i > 0 + entries: (row.sourceGroupByRef === + 'new-releases' + ? getNewReleases( + row.countAlbumsFromGroup, + { + wikiData, + } + ) + : row.sourceGroupByRef === + 'new-additions' + ? getNewAdditions( + row.countAlbumsFromGroup, + { + wikiData, + } + ) + : (row.sourceGroup?.albums ?? []) + .slice() + .reverse() + .filter( + (album) => + album.isListedOnHomepage + ) + .slice(0, row.countAlbumsFromGroup) + .map((album) => ({item: album})) + ).concat( + row.sourceAlbums.map((album) => ({ + item: album, + })) + ), + lazy: i > 0, })} - ${row.actionLinks?.length && fixWS` + ${ + row.actionLinks?.length && + fixWS` <div class="grid-actions"> - ${row.actionLinks.map(action => transformInline(action) - .replace('<a', '<a class="box grid-item"')).join('\n')} + ${row.actionLinks + .map((action) => + transformInline(action).replace( + '<a', + '<a class="box grid-item"' + ) + ) + .join('\n')} </div> - `} + ` + } </div> - `} + ` + } </section> - `).join('\n')} - ` - }, - - sidebarLeft: homepageLayout.sidebarContent && { - wide: true, - collapse: false, - // This is a pretty filthy hack! 8ut otherwise, the [[news]] part - // gets treated like it's a reference to the track named "news", - // which o8viously isn't what we're going for. Gotta catch that - // 8efore we pass it to transformMultiline, 'cuz otherwise it'll - // get repl8ced with just the word "news" (or anything else that - // transformMultiline does with references it can't match) -- and - // we can't match that for replacing it with the news column! - // - // And no, I will not make [[news]] into part of transformMultiline - // (even though that would 8e hilarious). - content: (transformMultiline(homepageLayout.sidebarContent.replace('[[news]]', '__GENERATE_NEWS__')) - .replace('<p>__GENERATE_NEWS__</p>', wikiInfo.enableNews ? fixWS` + ` + ) + .join('\n')} + `, + }, + + sidebarLeft: homepageLayout.sidebarContent && { + wide: true, + collapse: false, + // This is a pretty filthy hack! 8ut otherwise, the [[news]] part + // gets treated like it's a reference to the track named "news", + // which o8viously isn't what we're going for. Gotta catch that + // 8efore we pass it to transformMultiline, 'cuz otherwise it'll + // get repl8ced with just the word "news" (or anything else that + // transformMultiline does with references it can't match) -- and + // we can't match that for replacing it with the news column! + // + // And no, I will not make [[news]] into part of transformMultiline + // (even though that would 8e hilarious). + content: transformMultiline( + homepageLayout.sidebarContent.replace('[[news]]', '__GENERATE_NEWS__') + ).replace( + '<p>__GENERATE_NEWS__</p>', + wikiInfo.enableNews + ? fixWS` <h1>${language.$('homepage.news.title')}</h1> - ${newsData.slice(0, 3).map((entry, i) => html.tag('article', - {class: ['news-entry', i === 0 && 'first-news-entry']}, - fixWS` - <h2><time>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2> + ${newsData + .slice(0, 3) + .map((entry, i) => + html.tag( + 'article', + { + class: [ + 'news-entry', + i === 0 && 'first-news-entry', + ], + }, + fixWS` + <h2><time>${language.formatDate( + entry.date + )}</time> ${link.newsEntry(entry)}</h2> ${transformMultiline(entry.contentShort)} - ${entry.contentShort !== entry.content && link.newsEntry(entry, { - text: language.$('homepage.news.entry.viewRest') - })} - `)).join('\n')} - ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`)) - }, - - nav: { - linkContainerClasses: ['nav-links-index'], - links: [ - link.home('', {text: wikiInfo.nameShort, class: 'current', to}), - - wikiInfo.enableListings && - link.listingIndex('', {text: language.$('listingIndex.title'), to}), - - wikiInfo.enableNews && - link.newsIndex('', {text: language.$('newsIndex.title'), to}), - - wikiInfo.enableFlashesAndGames && - link.flashIndex('', {text: language.$('flashIndex.title'), to}), - - ...(staticPageData - .filter(page => page.showInNavigationBar) - .map(page => link.staticPage(page, {text: page.nameShort}))), - ].filter(Boolean).map(html => ({html})), - } - }) - }; - - return [page]; + ${ + entry.contentShort !== entry.content && + link.newsEntry(entry, { + text: language.$( + 'homepage.news.entry.viewRest' + ), + }) + } + ` + ) + ) + .join('\n')} + ` + : `<p><i>News requested in content description but this feature isn't enabled</i></p>` + ), + }, + + nav: { + linkContainerClasses: ['nav-links-index'], + links: [ + link.home('', {text: wikiInfo.nameShort, class: 'current', to}), + + wikiInfo.enableListings && + link.listingIndex('', { + text: language.$('listingIndex.title'), + to, + }), + + wikiInfo.enableNews && + link.newsIndex('', {text: language.$('newsIndex.title'), to}), + + wikiInfo.enableFlashesAndGames && + link.flashIndex('', {text: language.$('flashIndex.title'), to}), + + ...staticPageData + .filter((page) => page.showInNavigationBar) + .map((page) => link.staticPage(page, {text: page.nameShort})), + ] + .filter(Boolean) + .map((html) => ({html})), + }, + }), + }; + + return [page]; } diff --git a/src/page/index.js b/src/page/index.js index f580cbea..149503f0 100644 --- a/src/page/index.js +++ b/src/page/index.js @@ -1,3 +1,5 @@ +/** @format */ + // NB: This is the index for the page/ directory and contains exports for all // other modules here! It's not the page spec for the homepage - see // homepage.js for that. diff --git a/src/page/listing.js b/src/page/listing.js index 447a0c8f..5db6c916 100644 --- a/src/page/listing.js +++ b/src/page/listing.js @@ -1,3 +1,5 @@ +/** @format */ + // Listing page specification. // // The targets here are a bit different than for most pages: rather than data @@ -14,189 +16,212 @@ import fixWS from 'fix-whitespace'; import * as html from '../util/html.js'; -import { - getTotalDuration -} from '../util/wiki-data.js'; +import {getTotalDuration} from '../util/wiki-data.js'; // Page exports export function condition({wikiData}) { - return wikiData.wikiInfo.enableListings; + return wikiData.wikiInfo.enableListings; } export function targets({wikiData}) { - return wikiData.listingSpec; + return wikiData.listingSpec; } export function write(listing, {wikiData}) { - if (listing.condition && !listing.condition({wikiData})) { - return null; - } - - const { wikiInfo } = wikiData; + if (listing.condition && !listing.condition({wikiData})) { + return null; + } - const data = (listing.data - ? listing.data({wikiData}) - : null); + const data = listing.data ? listing.data({wikiData}) : null; - const page = { - type: 'page', - path: ['listing', listing.directory], - page: opts => { - const { getLinkThemeString, link, language } = opts; - const titleKey = `listingPage.${listing.stringsKey}.title`; + const page = { + type: 'page', + path: ['listing', listing.directory], + page: (opts) => { + const {getLinkThemeString, link, language} = opts; + const titleKey = `listingPage.${listing.stringsKey}.title`; - return { - title: language.$(titleKey), + return { + title: language.$(titleKey), - main: { - content: fixWS` + main: { + content: fixWS` <h1>${language.$(titleKey)}</h1> - ${listing.html && (listing.data + ${ + listing.html && + (listing.data ? listing.html(data, opts) - : listing.html(opts))} - ${listing.row && fixWS` + : listing.html(opts)) + } + ${ + listing.row && + fixWS` <ul> - ${(data - .map(item => listing.row(item, opts)) - .map(row => `<li>${row}</li>`) - .join('\n'))} + ${data + .map((item) => listing.row(item, opts)) + .map((row) => `<li>${row}</li>`) + .join('\n')} </ul> - `} - ` - }, - - sidebarLeft: { - content: generateSidebarForListings(listing, { - getLinkThemeString, - link, - language, - wikiData - }) - }, - - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title') - }, - {toCurrentPage: true} - ] - } - }; - } - }; - - return [page]; + ` + } + `, + }, + + sidebarLeft: { + content: generateSidebarForListings(listing, { + getLinkThemeString, + link, + language, + wikiData, + }), + }, + + nav: { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + {toCurrentPage: true}, + ], + }, + }; + }, + }; + + return [page]; } export function writeTargetless({wikiData}) { - const { albumData, trackData, wikiInfo } = wikiData; + const {albumData, trackData, wikiInfo} = wikiData; - const totalDuration = getTotalDuration(trackData); + const totalDuration = getTotalDuration(trackData); - const page = { - type: 'page', - path: ['listingIndex'], - page: ({ - getLinkThemeString, - language, - link - }) => ({ - title: language.$('listingIndex.title'), + const page = { + type: 'page', + path: ['listingIndex'], + page: ({getLinkThemeString, language, link}) => ({ + title: language.$('listingIndex.title'), - main: { - content: fixWS` + main: { + content: fixWS` <h1>${language.$('listingIndex.title')}</h1> <p>${language.$('listingIndex.infoLine', { - wiki: wikiInfo.name, - tracks: `<b>${language.countTracks(trackData.length, {unit: true})}</b>`, - albums: `<b>${language.countAlbums(albumData.length, {unit: true})}</b>`, - duration: `<b>${language.formatDuration(totalDuration, {approximate: true, unit: true})}</b>` + wiki: wikiInfo.name, + tracks: `<b>${language.countTracks(trackData.length, { + unit: true, + })}</b>`, + albums: `<b>${language.countAlbums(albumData.length, { + unit: true, + })}</b>`, + duration: `<b>${language.formatDuration(totalDuration, { + approximate: true, + unit: true, + })}</b>`, })}</p> <hr> <p>${language.$('listingIndex.exploreList')}</p> - ${generateLinkIndexForListings(null, false, {link, language, wikiData})} - ` - }, - - sidebarLeft: { - content: generateSidebarForListings(null, { - getLinkThemeString, - link, - language, - wikiData - }) - }, - - nav: {simple: true} - }) - }; - - return [page]; -}; + ${generateLinkIndexForListings(null, false, { + link, + language, + wikiData, + })} + `, + }, + + sidebarLeft: { + content: generateSidebarForListings(null, { + getLinkThemeString, + link, + language, + wikiData, + }), + }, + + nav: {simple: true}, + }), + }; + + return [page]; +} // Utility functions -function generateSidebarForListings(currentListing, { - getLinkThemeString, - link, - language, - wikiData -}) { - return fixWS` - <h1>${link.listingIndex('', {text: language.$('listingIndex.title')})}</h1> +function generateSidebarForListings( + currentListing, + {getLinkThemeString, link, language, wikiData} +) { + return fixWS` + <h1>${link.listingIndex('', { + text: language.$('listingIndex.title'), + })}</h1> ${generateLinkIndexForListings(currentListing, true, { - getLinkThemeString, - link, - language, - wikiData + getLinkThemeString, + link, + language, + wikiData, })} `; } -function generateLinkIndexForListings(currentListing, forSidebar, { - getLinkThemeString, - link, - language, - wikiData -}) { - const { listingTargetSpec, wikiInfo } = wikiData; - - const filteredByCondition = listingTargetSpec - .map(({ listings, ...rest }) => ({ - ...rest, - listings: listings.filter(({ condition: c }) => !c || c({wikiData})) - })) - .filter(({ listings }) => listings.length > 0); - - const genUL = listings => html.tag('ul', - listings.map(listing => html.tag('li', - {class: [listing === currentListing && 'current']}, - link.listing(listing, {text: language.$(`listingPage.${listing.stringsKey}.title.short`)}) - ))); - - if (forSidebar) { - return filteredByCondition.map(({ title, listings }) => - html.tag('details', { - open: !forSidebar || listings.includes(currentListing), - class: listings.includes(currentListing) && 'current' - }, [ - html.tag('summary', - {style: getLinkThemeString(wikiInfo.color)}, - html.tag('span', - {class: 'group-name'}, - title({language}))), - genUL(listings) - ])).join('\n'); - } else { - return html.tag('dl', - filteredByCondition.flatMap(({ title, listings }) => [ - html.tag('dt', title({language})), - html.tag('dd', genUL(listings)) - ])); - } +function generateLinkIndexForListings( + currentListing, + forSidebar, + {getLinkThemeString, link, language, wikiData} +) { + const {listingTargetSpec, wikiInfo} = wikiData; + + const filteredByCondition = listingTargetSpec + .map(({listings, ...rest}) => ({ + ...rest, + listings: listings.filter(({condition: c}) => !c || c({wikiData})), + })) + .filter(({listings}) => listings.length > 0); + + const genUL = (listings) => + html.tag( + 'ul', + listings.map((listing) => + html.tag( + 'li', + {class: [listing === currentListing && 'current']}, + link.listing(listing, { + text: language.$(`listingPage.${listing.stringsKey}.title.short`), + }) + ) + ) + ); + + if (forSidebar) { + return filteredByCondition + .map(({title, listings}) => + html.tag( + 'details', + { + open: !forSidebar || listings.includes(currentListing), + class: listings.includes(currentListing) && 'current', + }, + [ + html.tag( + 'summary', + {style: getLinkThemeString(wikiInfo.color)}, + html.tag('span', {class: 'group-name'}, title({language})) + ), + genUL(listings), + ] + ) + ) + .join('\n'); + } else { + return html.tag( + 'dl', + filteredByCondition.flatMap(({title, listings}) => [ + html.tag('dt', title({language})), + html.tag('dd', genUL(listings)), + ]) + ); + } } diff --git a/src/page/news.js b/src/page/news.js index 9336506f..478ff9d0 100644 --- a/src/page/news.js +++ b/src/page/news.js @@ -1,3 +1,5 @@ +/** @format */ + // News entry & index page specifications. // Imports @@ -7,121 +9,130 @@ import fixWS from 'fix-whitespace'; // Page exports export function condition({wikiData}) { - return wikiData.wikiInfo.enableNews; + return wikiData.wikiInfo.enableNews; } export function targets({wikiData}) { - return wikiData.newsData; + return wikiData.newsData; } export function write(entry, {wikiData}) { - const page = { - type: 'page', - path: ['newsEntry', entry.directory], - page: ({ - generatePreviousNextLinks, - link, - language, - transformMultiline, - }) => ({ - title: language.$('newsEntryPage.title', {entry: entry.name}), - - main: { - content: fixWS` + const page = { + type: 'page', + path: ['newsEntry', entry.directory], + page: ({ + generatePreviousNextLinks, + link, + language, + transformMultiline, + }) => ({ + title: language.$('newsEntryPage.title', {entry: entry.name}), + + main: { + content: fixWS` <div class="long-content"> - <h1>${language.$('newsEntryPage.title', {entry: entry.name})}</h1> - <p>${language.$('newsEntryPage.published', {date: language.formatDate(entry.date)})}</p> + <h1>${language.$('newsEntryPage.title', { + entry: entry.name, + })}</h1> + <p>${language.$('newsEntryPage.published', { + date: language.formatDate(entry.date), + })}</p> ${transformMultiline(entry.content)} </div> - ` - }, - - nav: generateNewsEntryNav(entry, { - generatePreviousNextLinks, - link, - language, - wikiData - }) - }) - }; - - return [page]; + `, + }, + + nav: generateNewsEntryNav(entry, { + generatePreviousNextLinks, + link, + language, + wikiData, + }), + }), + }; + + return [page]; } export function writeTargetless({wikiData}) { - const { newsData } = wikiData; - - const page = { - type: 'page', - path: ['newsIndex'], - page: ({ - link, - language, - transformMultiline - }) => ({ - title: language.$('newsIndex.title'), - - main: { - content: fixWS` + const {newsData} = wikiData; + + const page = { + type: 'page', + path: ['newsIndex'], + page: ({link, language, transformMultiline}) => ({ + title: language.$('newsIndex.title'), + + main: { + content: fixWS` <div class="long-content news-index"> <h1>${language.$('newsIndex.title')}</h1> - ${newsData.map(entry => fixWS` + ${newsData + .map( + (entry) => fixWS` <article id="${entry.directory}"> - <h2><time>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2> + <h2><time>${language.formatDate( + entry.date + )}</time> ${link.newsEntry(entry)}</h2> ${transformMultiline(entry.contentShort)} - ${entry.contentShort !== entry.content && `<p>${link.newsEntry(entry, { - text: language.$('newsIndex.entry.viewRest') - })}</p>`} + ${ + entry.contentShort !== entry.content && + `<p>${link.newsEntry(entry, { + text: language.$( + 'newsIndex.entry.viewRest' + ), + })}</p>` + } </article> - `).join('\n')} + ` + ) + .join('\n')} </div> - ` - }, + `, + }, - nav: {simple: true} - }) - }; + nav: {simple: true}, + }), + }; - return [page]; + return [page]; } // Utility functions -function generateNewsEntryNav(entry, { - generatePreviousNextLinks, +function generateNewsEntryNav( + entry, + {generatePreviousNextLinks, link, language, wikiData} +) { + const {newsData} = wikiData; + + // The newsData list is sorted reverse chronologically (newest ones first), + // so the way we find next/previous entries is flipped from normal. + const previousNextLinks = generatePreviousNextLinks(entry, { link, language, - wikiData -}) { - const { wikiInfo, newsData } = wikiData; - - // The newsData list is sorted reverse chronologically (newest ones first), - // so the way we find next/previous entries is flipped from normal. - const previousNextLinks = generatePreviousNextLinks(entry, { - link, language, - data: newsData.slice().reverse(), - linkKey: 'newsEntry' - }); - - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.newsIndex'], - title: language.$('newsEntryPage.nav.news') - }, - { - html: language.$('newsEntryPage.nav.entry', { - date: language.formatDate(entry.date), - entry: link.newsEntry(entry, {class: 'current'}) - }) - }, - previousNextLinks && - { - divider: false, - html: `(${previousNextLinks})` - } - ] - }; + data: newsData.slice().reverse(), + linkKey: 'newsEntry', + }); + + return { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + path: ['localized.newsIndex'], + title: language.$('newsEntryPage.nav.news'), + }, + { + html: language.$('newsEntryPage.nav.entry', { + date: language.formatDate(entry.date), + entry: link.newsEntry(entry, {class: 'current'}), + }), + }, + previousNextLinks && { + divider: false, + html: `(${previousNextLinks})`, + }, + ], + }; } diff --git a/src/page/static.js b/src/page/static.js index e9b6a047..2a49ff87 100644 --- a/src/page/static.js +++ b/src/page/static.js @@ -1,3 +1,5 @@ +/** @format */ + // Static content page specification. (These are static pages coded into the // wiki data folder, used for a variety of purposes, e.g. wiki info, // changelog, and so on.) @@ -9,32 +11,29 @@ import fixWS from 'fix-whitespace'; // Page exports export function targets({wikiData}) { - return wikiData.staticPageData; + return wikiData.staticPageData; } -export function write(staticPage, {wikiData}) { - const page = { - type: 'page', - path: ['staticPage', staticPage.directory], - page: ({ - language, - transformMultiline - }) => ({ - title: staticPage.name, - stylesheet: staticPage.stylesheet, - - main: { - content: fixWS` +export function write(staticPage) { + const page = { + type: 'page', + path: ['staticPage', staticPage.directory], + page: ({transformMultiline}) => ({ + title: staticPage.name, + stylesheet: staticPage.stylesheet, + + main: { + content: fixWS` <div class="long-content"> <h1>${staticPage.name}</h1> ${transformMultiline(staticPage.content)} </div> - ` - }, + `, + }, - nav: {simple: true} - }) - }; + nav: {simple: true}, + }), + }; - return [page]; + return [page]; } diff --git a/src/page/tag.js b/src/page/tag.js index 471439da..38f7e213 100644 --- a/src/page/tag.js +++ b/src/page/tag.js @@ -1,3 +1,5 @@ +/** @format */ + // Art tag page specification. // Imports @@ -7,105 +9,91 @@ import fixWS from 'fix-whitespace'; // Page exports export function condition({wikiData}) { - return wikiData.wikiInfo.enableArtTagUI; + return wikiData.wikiInfo.enableArtTagUI; } export function targets({wikiData}) { - return wikiData.artTagData.filter(tag => !tag.isContentWarning); + return wikiData.artTagData.filter((tag) => !tag.isContentWarning); } export function write(tag, {wikiData}) { - const { wikiInfo } = wikiData; - const { taggedInThings: things } = tag; + const {taggedInThings: things} = tag; - // Display things featuring this art tag in reverse chronological order, - // sticking the most recent additions near the top! - const thingsReversed = things.slice().reverse(); + // Display things featuring this art tag in reverse chronological order, + // sticking the most recent additions near the top! + const thingsReversed = things.slice().reverse(); - const entries = thingsReversed.map(item => ({item})); + const entries = thingsReversed.map((item) => ({item})); - const page = { - type: 'page', - path: ['tag', tag.directory], - page: ({ - generatePreviousNextLinks, - getAlbumCover, - getGridHTML, - getThemeString, - getTrackCover, - link, - language, - to - }) => ({ - title: language.$('tagPage.title', {tag: tag.name}), - theme: getThemeString(tag.color), + const page = { + type: 'page', + path: ['tag', tag.directory], + page: ({ + getAlbumCover, + getGridHTML, + getThemeString, + getTrackCover, + link, + language, + }) => ({ + title: language.$('tagPage.title', {tag: tag.name}), + theme: getThemeString(tag.color), - main: { - classes: ['top-index'], - content: fixWS` + main: { + classes: ['top-index'], + content: fixWS` <h1>${language.$('tagPage.title', {tag: tag.name})}</h1> <p class="quick-info">${language.$('tagPage.infoLine', { - coverArts: language.countCoverArts(things.length, {unit: true}) + coverArts: language.countCoverArts(things.length, { + unit: true, + }), })}</p> <div class="grid-listing"> ${getGridHTML({ - entries, - srcFn: thing => (thing.album - ? getTrackCover(thing) - : getAlbumCover(thing)), - linkFn: (thing, opts) => (thing.album - ? link.track(thing, opts) - : link.album(thing, opts)) + entries, + srcFn: (thing) => + thing.album + ? getTrackCover(thing) + : getAlbumCover(thing), + linkFn: (thing, opts) => + thing.album + ? link.track(thing, opts) + : link.album(thing, opts), })} </div> - ` - }, + `, + }, - nav: generateTagNav(tag, { - generatePreviousNextLinks, - link, - language, - wikiData - }) - }) - }; + nav: generateTagNav(tag, { + link, + language, + wikiData, + }), + }), + }; - return [page]; + return [page]; } // Utility functions -function generateTagNav(tag, { - generatePreviousNextLinks, - link, - language, - wikiData -}) { - const previousNextLinks = generatePreviousNextLinks(tag, { - data: wikiData.artTagData.filter(tag => !tag.isContentWarning), - linkKey: 'tag' - }); - - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiData.wikiInfo.enableListings && - { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title') - }, - { - html: language.$('tagPage.nav.tag', { - tag: link.tag(tag, {class: 'current'}) - }) - }, - /* - previousNextLinks && { - divider: false, - html: `(${previousNextLinks})` - } - */ - ] - }; +function generateTagNav( + tag, + {link, language, wikiData} +) { + return { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + wikiData.wikiInfo.enableListings && { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + { + html: language.$('tagPage.nav.tag', { + tag: link.tag(tag, {class: 'current'}), + }), + }, + ], + }; } diff --git a/src/page/track.js b/src/page/track.js index c4ec6c59..29a07431 100644 --- a/src/page/track.js +++ b/src/page/track.js @@ -1,3 +1,5 @@ +/** @format */ + // Track page specification. // Imports @@ -5,183 +7,205 @@ import fixWS from 'fix-whitespace'; import { - generateAlbumChronologyLinks, - generateAlbumNavLinks, - generateAlbumSecondaryNav, - generateAlbumSidebar + generateAlbumChronologyLinks, + generateAlbumNavLinks, + generateAlbumSecondaryNav, + generateAlbumSidebar, } from './album.js'; import * as html from '../util/html.js'; -import { - bindOpts -} from '../util/sugar.js'; +import {bindOpts} from '../util/sugar.js'; import { - getTrackCover, - getAlbumListTag, - sortChronologically, + getTrackCover, + getAlbumListTag, + sortChronologically, } from '../util/wiki-data.js'; // Page exports export function targets({wikiData}) { - return wikiData.trackData; + return wikiData.trackData; } export function write(track, {wikiData}) { - const { groupData, wikiInfo } = wikiData; - const { album, referencedByTracks, referencedTracks, otherReleases } = track; - - const listTag = getAlbumListTag(album); + const {wikiInfo} = wikiData; + const {album, referencedByTracks, referencedTracks, otherReleases} = track; - let flashesThatFeature; - if (wikiInfo.enableFlashesAndGames) { - flashesThatFeature = sortChronologically([track, ...otherReleases] - .flatMap(track => track.featuredInFlashes - .map(flash => ({ - flash, - as: track, - directory: flash.directory, - name: flash.name, - date: flash.date - })))); - } + const listTag = getAlbumListTag(album); - const unbound_getTrackItem = (track, {getArtistString, link, language}) => ( - html.tag('li', language.$('trackList.item.withArtists', { - track: link.track(track), - by: `<span class="by">${language.$('trackList.item.withArtists.by', { - artists: getArtistString(track.artistContribs) - })}</span>` - }))); + let flashesThatFeature; + if (wikiInfo.enableFlashesAndGames) { + flashesThatFeature = sortChronologically( + [track, ...otherReleases].flatMap((track) => + track.featuredInFlashes.map((flash) => ({ + flash, + as: track, + directory: flash.directory, + name: flash.name, + date: flash.date, + })) + ) + ); + } - const unbound_generateTrackList = (tracks, {getArtistString, link, language}) => html.tag('ul', - tracks.map(track => unbound_getTrackItem(track, {getArtistString, link, language})) + const unbound_getTrackItem = (track, {getArtistString, link, language}) => + html.tag( + 'li', + language.$('trackList.item.withArtists', { + track: link.track(track), + by: `<span class="by">${language.$('trackList.item.withArtists.by', { + artists: getArtistString(track.artistContribs), + })}</span>`, + }) ); - const hasCommentary = track.commentary || otherReleases.some(t => t.commentary); - const generateCommentary = ({ - link, - language, - transformMultiline - }) => transformMultiline([ + const hasCommentary = + track.commentary || otherReleases.some((t) => t.commentary); + const generateCommentary = ({link, language, transformMultiline}) => + transformMultiline( + [ track.commentary, - ...otherReleases.map(track => - (track.commentary?.split('\n') - .filter(line => line.replace(/<\/b>/g, '').includes(':</i>')) - .map(line => fixWS` + ...otherReleases.map((track) => + track.commentary + ?.split('\n') + .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>')) + .map( + (line) => fixWS` ${line} - ${language.$('releaseInfo.artistCommentary.seeOriginalRelease', { - original: link.track(track) - })} - `) - .join('\n'))) - ].filter(Boolean).join('\n')); + ${language.$( + 'releaseInfo.artistCommentary.seeOriginalRelease', + { + original: link.track(track), + } + )} + ` + ) + .join('\n') + ), + ] + .filter(Boolean) + .join('\n') + ); - const data = { - type: 'data', - path: ['track', track.directory], - data: ({ - serializeContribs, - serializeCover, - serializeGroupsForTrack, - serializeLink - }) => ({ - name: track.name, - directory: track.directory, - dates: { - released: track.date, - originallyReleased: track.originalDate, - coverArtAdded: track.coverArtDate - }, - duration: track.duration, - color: track.color, - cover: serializeCover(track, getTrackCover), - artistsContribs: serializeContribs(track.artistContribs), - contributorContribs: serializeContribs(track.contributorContribs), - coverArtistContribs: serializeContribs(track.coverArtistContribs || []), - album: serializeLink(track.album), - groups: serializeGroupsForTrack(track), - references: track.references.map(serializeLink), - referencedBy: track.referencedBy.map(serializeLink), - alsoReleasedAs: otherReleases.map(track => ({ - track: serializeLink(track), - album: serializeLink(track.album) - })) - }) - }; + const data = { + type: 'data', + path: ['track', track.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForTrack, + serializeLink, + }) => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate, + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artistsContribs: serializeContribs(track.artistContribs), + contributorContribs: serializeContribs(track.contributorContribs), + coverArtistContribs: serializeContribs(track.coverArtistContribs || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map((track) => ({ + track: serializeLink(track), + album: serializeLink(track.album), + })), + }), + }; - const getSocialEmbedDescription = ({ - getArtistString: _getArtistString, - language, - }) => { - const hasArtists = (track.artistContribs?.length > 0); - const hasCoverArtists = (track.coverArtistContribs?.length > 0); - const getArtistString = contribs => _getArtistString(contribs, { - // We don't want to put actual HTML tags in social embeds (sadly - // they don't get parsed and displayed, generally speaking), so - // override the link argument so that artist "links" just show - // their names. - link: {artist: artist => artist.name} - }); - if (!hasArtists && !hasCoverArtists) return ''; - return language.formatString( - 'trackPage.socialEmbed.body' + [ - hasArtists && '.withArtists', - hasCoverArtists && '.withCoverArtists', - ].filter(Boolean).join(''), - Object.fromEntries([ - hasArtists && ['artists', getArtistString(track.artistContribs)], - hasCoverArtists && ['coverArtists', getArtistString(track.coverArtistContribs)], - ].filter(Boolean))) - }; + const getSocialEmbedDescription = ({ + getArtistString: _getArtistString, + language, + }) => { + const hasArtists = track.artistContribs?.length > 0; + const hasCoverArtists = track.coverArtistContribs?.length > 0; + const getArtistString = (contribs) => + _getArtistString(contribs, { + // We don't want to put actual HTML tags in social embeds (sadly + // they don't get parsed and displayed, generally speaking), so + // override the link argument so that artist "links" just show + // their names. + link: {artist: (artist) => artist.name}, + }); + if (!hasArtists && !hasCoverArtists) return ''; + return language.formatString( + 'trackPage.socialEmbed.body' + + [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] + .filter(Boolean) + .join(''), + Object.fromEntries( + [ + hasArtists && ['artists', getArtistString(track.artistContribs)], + hasCoverArtists && [ + 'coverArtists', + getArtistString(track.coverArtistContribs), + ], + ].filter(Boolean) + ) + ); + }; - const page = { - type: 'page', - path: ['track', track.directory], - page: ({ - absoluteTo, - fancifyURL, - generateChronologyLinks, - generateCoverLink, - generatePreviousNextLinks, - generateTrackListDividedByGroups, - getAlbumStylesheet, - getArtistString, - getLinkThemeString, - getThemeString, - getTrackCover, - link, - language, - transformInline, - transformLyrics, - transformMultiline, - to, - urls, - }) => { - const getTrackItem = bindOpts(unbound_getTrackItem, {getArtistString, link, language}); - const cover = getTrackCover(track); + const page = { + type: 'page', + path: ['track', track.directory], + page: ({ + absoluteTo, + fancifyURL, + generateChronologyLinks, + generateCoverLink, + generatePreviousNextLinks, + generateTrackListDividedByGroups, + getAlbumStylesheet, + getArtistString, + getLinkThemeString, + getThemeString, + getTrackCover, + link, + language, + transformLyrics, + transformMultiline, + to, + urls, + }) => { + const getTrackItem = bindOpts(unbound_getTrackItem, { + getArtistString, + link, + language, + }); + const cover = getTrackCover(track); - return { - title: language.$('trackPage.title', {track: track.name}), - stylesheet: getAlbumStylesheet(album, {to}), - theme: getThemeString(track.color, [ - `--album-directory: ${album.directory}`, - `--track-directory: ${track.directory}` - ]), + return { + title: language.$('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + theme: getThemeString(track.color, [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ]), - socialEmbed: { - heading: language.$('trackPage.socialEmbed.heading', {album: track.album.name}), - headingLink: absoluteTo('localized.album', album.directory), - title: language.$('trackPage.socialEmbed.title', {track: track.name}), - description: getSocialEmbedDescription({getArtistString, language}), - image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), - color: track.color, - }, + socialEmbed: { + heading: language.$('trackPage.socialEmbed.heading', { + album: track.album.name, + }), + headingLink: absoluteTo('localized.album', album.directory), + title: language.$('trackPage.socialEmbed.title', { + track: track.name, + }), + description: getSocialEmbedDescription({getArtistString, language}), + image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), + color: track.color, + }, - // disabled for now! shifting banner position per height of page is disorienting - /* + // disabled for now! shifting banner position per height of page is disorienting + /* banner: album.bannerArtistContribs.length && { classes: ['dim'], dimensions: album.bannerDimensions, @@ -191,156 +215,239 @@ export function write(track, {wikiData}) { }, */ - main: { - content: fixWS` - ${cover && generateCoverLink({ + main: { + content: fixWS` + ${ + cover && + generateCoverLink({ src: cover, alt: language.$('misc.alt.trackCover'), - tags: track.artTags - })} - <h1>${language.$('trackPage.title', {track: track.name})}</h1> + tags: track.artTags, + }) + } + <h1>${language.$('trackPage.title', { + track: track.name, + })}</h1> <p> ${[ - language.$('releaseInfo.by', { - artists: getArtistString(track.artistContribs, { - showContrib: true, - showIcons: true - }) + language.$('releaseInfo.by', { + artists: getArtistString(track.artistContribs, { + showContrib: true, + showIcons: true, }), - track.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', { - artists: getArtistString(track.coverArtistContribs, { - showContrib: true, - showIcons: true - }) + }), + track.coverArtistContribs.length && + language.$('releaseInfo.coverArtBy', { + artists: getArtistString( + track.coverArtistContribs, + { + showContrib: true, + showIcons: true, + } + ), }), - track.date && language.$('releaseInfo.released', { - date: language.formatDate(track.date) + track.date && + language.$('releaseInfo.released', { + date: language.formatDate(track.date), }), - (track.coverArtDate && - +track.coverArtDate !== +track.date && - language.$('releaseInfo.artReleased', { - date: language.formatDate(track.coverArtDate) - })), - track.duration && language.$('releaseInfo.duration', { - duration: language.formatDuration(track.duration) - }) - ].filter(Boolean).join('<br>\n')} + track.coverArtDate && + +track.coverArtDate !== +track.date && + language.$('releaseInfo.artReleased', { + date: language.formatDate(track.coverArtDate), + }), + track.duration && + language.$('releaseInfo.duration', { + duration: language.formatDuration( + track.duration + ), + }), + ] + .filter(Boolean) + .join('<br>\n')} </p> <p>${ - (track.urls?.length - ? language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(track.urls.map(url => fancifyURL(url, {language}))) - }) - : language.$('releaseInfo.listenOn.noLinks')) + track.urls?.length + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList( + track.urls.map((url) => + fancifyURL(url, {language}) + ) + ), + }) + : language.$('releaseInfo.listenOn.noLinks') }</p> - ${otherReleases.length && fixWS` + ${ + otherReleases.length && + fixWS` <p>${language.$('releaseInfo.alsoReleasedAs')}</p> <ul> - ${otherReleases.map(track => fixWS` - <li>${language.$('releaseInfo.alsoReleasedAs.item', { + ${otherReleases + .map( + (track) => fixWS` + <li>${language.$( + 'releaseInfo.alsoReleasedAs.item', + { track: link.track(track), - album: link.album(track.album) - })}</li> - `).join('\n')} + album: link.album(track.album), + } + )}</li> + ` + ) + .join('\n')} </ul> - `} - ${track.contributorContribs.length && fixWS` + ` + } + ${ + track.contributorContribs.length && + fixWS` <p>${language.$('releaseInfo.contributors')}</p> <ul> - ${(track.contributorContribs - .map(contrib => `<li>${getArtistString([contrib], { + ${track.contributorContribs + .map( + (contrib) => + `<li>${getArtistString([contrib], { showContrib: true, - showIcons: true - })}</li>`) - .join('\n'))} + showIcons: true, + })}</li>` + ) + .join('\n')} </ul> - `} - ${referencedTracks.length && fixWS` - <p>${language.$('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p> - ${html.tag('ul', referencedTracks.map(getTrackItem))} - `} - ${referencedByTracks.length && fixWS` - <p>${language.$('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p> - ${generateTrackListDividedByGroups(referencedByTracks, { + ` + } + ${ + referencedTracks.length && + fixWS` + <p>${language.$('releaseInfo.tracksReferenced', { + track: `<i>${track.name}</i>`, + })}</p> + ${html.tag( + 'ul', + referencedTracks.map(getTrackItem) + )} + ` + } + ${ + referencedByTracks.length && + fixWS` + <p>${language.$('releaseInfo.tracksThatReference', { + track: `<i>${track.name}</i>`, + })}</p> + ${generateTrackListDividedByGroups( + referencedByTracks, + { getTrackItem, wikiData, - })} - `} - ${wikiInfo.enableFlashesAndGames && flashesThatFeature.length && fixWS` - <p>${language.$('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p> + } + )} + ` + } + ${ + wikiInfo.enableFlashesAndGames && + flashesThatFeature.length && + fixWS` + <p>${language.$('releaseInfo.flashesThatFeature', { + track: `<i>${track.name}</i>`, + })}</p> <ul> - ${flashesThatFeature.map(({ flash, as }) => html.tag('li', - {class: as !== track && 'rerelease'}, - (as === track - ? language.$('releaseInfo.flashesThatFeature.item', { - flash: link.flash(flash) - }) - : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { - flash: link.flash(flash), - track: link.track(as) - })))).join('\n')} + ${flashesThatFeature + .map(({flash, as}) => + html.tag( + 'li', + {class: as !== track && 'rerelease'}, + as === track + ? language.$( + 'releaseInfo.flashesThatFeature.item', + { + flash: link.flash(flash), + } + ) + : language.$( + 'releaseInfo.flashesThatFeature.item.asDifferentRelease', + { + flash: link.flash(flash), + track: link.track(as), + } + ) + ) + ) + .join('\n')} </ul> - `} - ${track.lyrics && fixWS` + ` + } + ${ + track.lyrics && + fixWS` <p>${language.$('releaseInfo.lyrics')}</p> <blockquote> ${transformLyrics(track.lyrics)} </blockquote> - `} - ${hasCommentary && fixWS` + ` + } + ${ + hasCommentary && + fixWS` <p>${language.$('releaseInfo.artistCommentary')}</p> <blockquote> - ${generateCommentary({link, language, transformMultiline})} + ${generateCommentary({ + link, + language, + transformMultiline, + })} </blockquote> - `} - ` - }, + ` + } + `, + }, - sidebarLeft: generateAlbumSidebar(album, track, { - fancifyURL, - getLinkThemeString, - link, - language, - transformMultiline, - wikiData - }), + sidebarLeft: generateAlbumSidebar(album, track, { + fancifyURL, + getLinkThemeString, + link, + language, + transformMultiline, + wikiData, + }), - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.album', album.directory], - title: album.name - }, - listTag === 'ol' ? { - html: language.$('trackPage.nav.track.withNumber', { - number: album.tracks.indexOf(track) + 1, - track: link.track(track, {class: 'current', to}) - }) - } : { - html: language.$('trackPage.nav.track', { - track: link.track(track, {class: 'current', to}) - }) - }, - ].filter(Boolean), - content: generateAlbumChronologyLinks(album, track, {generateChronologyLinks}), - bottomRowContent: (album.tracks.length > 1 && - generateAlbumNavLinks(album, track, { - generatePreviousNextLinks, - language, - })), + nav: { + linkContainerClasses: ['nav-links-hierarchy'], + links: [ + {toHome: true}, + { + path: ['localized.album', album.directory], + title: album.name, + }, + listTag === 'ol' + ? { + html: language.$('trackPage.nav.track.withNumber', { + number: album.tracks.indexOf(track) + 1, + track: link.track(track, {class: 'current', to}), + }), + } + : { + html: language.$('trackPage.nav.track', { + track: link.track(track, {class: 'current', to}), + }), }, + ].filter(Boolean), + content: generateAlbumChronologyLinks(album, track, { + generateChronologyLinks, + }), + bottomRowContent: + album.tracks.length > 1 && + generateAlbumNavLinks(album, track, { + generatePreviousNextLinks, + language, + }), + }, - secondaryNav: generateAlbumSecondaryNav(album, track, { - language, - link, - getLinkThemeString, - }), - }; - } - }; + secondaryNav: generateAlbumSecondaryNav(album, track, { + language, + link, + getLinkThemeString, + }), + }; + }, + }; - return [data, page]; + return [data, page]; } - diff --git a/src/repl.js b/src/repl.js index cd4c3212..bd447bec 100644 --- a/src/repl.js +++ b/src/repl.js @@ -1,70 +1,63 @@ +/** @format */ + import * as os from 'os'; import * as path from 'path'; import * as repl from 'repl'; -import { fileURLToPath } from 'url'; -import { promisify } from 'util'; - -import { quickLoadAllFromYAML } from './data/yaml.js'; -import { logError, parseOptions } from './util/cli.js'; -import { showAggregate } from './util/sugar.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +import {quickLoadAllFromYAML} from './data/yaml.js'; +import {logError, parseOptions} from './util/cli.js'; +import {showAggregate} from './util/sugar.js'; async function main() { - const miscOptions = await parseOptions(process.argv.slice(2), { - 'data-path': { - type: 'value' - }, - - 'show-traces': { - type: 'flag' - }, - - 'no-history': { - type: 'flag' - }, + const miscOptions = await parseOptions(process.argv.slice(2), { + 'data-path': { + type: 'value', + }, + + 'no-history': { + type: 'flag', + }, + }); + + const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; + const disableHistory = miscOptions['no-history'] ?? false; + + if (!dataPath) { + logError`Expected --data-path option or HSMUSIC_DATA to be set`; + return; + } + + console.log('HSMusic data REPL'); + + const wikiData = await quickLoadAllFromYAML(dataPath); + const replServer = repl.start(); + + Object.assign(replServer.context, wikiData, {wikiData, WD: wikiData}); + + if (disableHistory) { + console.log(`\rInput history disabled (--no-history provided)`); + replServer.displayPrompt(true); + } else { + const historyFile = path.join(os.homedir(), '.hsmusic_repl_history'); + replServer.setupHistory(historyFile, (err) => { + if (err) { + console.error( + `\rFailed to begin locally logging input history to ${historyFile} (provide --no-history to disable)` + ); + } else { + console.log( + `\rLogging input history to ${historyFile} (provide --no-history to disable)` + ); + } + replServer.displayPrompt(true); }); - - const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; - const showAggregateTraces = miscOptions['show-traces'] ?? false; - const disableHistory = miscOptions['no-history'] ?? false; - - if (!dataPath) { - logError`Expected --data-path option or HSMUSIC_DATA to be set`; - return; - } - - console.log('HSMusic data REPL'); - - const wikiData = await quickLoadAllFromYAML(dataPath); - const replServer = repl.start(); - - Object.assign( - replServer.context, - wikiData, - {wikiData, WD: wikiData} - ); - - if (disableHistory) { - console.log(`\rInput history disabled (--no-history provided)`); - replServer.displayPrompt(true); - } else { - const historyFile = path.join(os.homedir(), '.hsmusic_repl_history'); - replServer.setupHistory(historyFile, err => { - if (err) { - console.error(`\rFailed to begin locally logging input history to ${historyFile} (provide --no-history to disable)`); - } else { - console.log(`\rLogging input history to ${historyFile} (provide --no-history to disable)`); - } - replServer.displayPrompt(true); - }); - } + } } -main().catch(error => { - if (error instanceof AggregateError) { - showAggregate(error) - } else { - console.error(error); - } +main().catch((error) => { + if (error instanceof AggregateError) { + showAggregate(error); + } else { + console.error(error); + } }); diff --git a/src/static/client.js b/src/static/client.js index 7397735c..1ffcb939 100644 --- a/src/static/client.js +++ b/src/static/client.js @@ -1,3 +1,5 @@ +/** @format */ + // This is the JS file that gets loaded on the client! It's only really used for // the random track feature right now - the idea is we only use it for stuff // that cannot 8e done at static-site compile time, 8y its fundamentally @@ -5,16 +7,12 @@ // // Upd8: As of 04/02/2021, it's now used for info cards too! Nice. -import { - getColors -} from '../util/colors.js'; +import {getColors} from '../util/colors.js'; -import { - getArtistNumContributions -} from '../util/wiki-data.js'; +import {getArtistNumContributions} from '../util/wiki-data.js'; -let albumData, artistData, flashData; -let officialAlbumData, fandomAlbumData, artistNames; +let albumData, artistData; +let officialAlbumData, fandomAlbumData; let ready = false; @@ -23,127 +21,133 @@ let ready = false; const language = document.documentElement.getAttribute('lang'); let list; -if ( - typeof Intl === 'object' && - typeof Intl.ListFormat === 'function' -) { - const getFormat = type => { - const formatter = new Intl.ListFormat(language, {type}); - return formatter.format.bind(formatter); - }; - - list = { - conjunction: getFormat('conjunction'), - disjunction: getFormat('disjunction'), - unit: getFormat('unit') - }; +if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { + const getFormat = (type) => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit'), + }; } else { - // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. - // We use the same mock for every list 'cuz we don't have any of the - // necessary CLDR info to appropri8tely distinguish 8etween them. - const arbitraryMock = array => array.join(', '); - - list = { - conjunction: arbitraryMock, - disjunction: arbitraryMock, - unit: arbitraryMock - }; + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = (array) => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock, + }; } // Miscellaneous helpers ---------------------------------- function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; - } + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } } function pick(array) { - return array[Math.floor(Math.random() * array.length)]; + return array[Math.floor(Math.random() * array.length)]; } function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); + return getComputedStyle(el).getPropertyValue(key).trim(); } function getRefDirectory(ref) { - return ref.split(':')[1]; + return ref.split(':')[1]; } function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find(album => album.directory === directory); -} - -function getFlash(el) { - const directory = cssProp(el, '--flash-directory'); - return flashData.find(flash => flash.directory === directory); + const directory = cssProp(el, '--album-directory'); + return albumData.find((album) => album.directory === directory); } // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); -const openAlbum = d => rebase(`album/${d}`); -const openTrack = d => rebase(`track/${d}`); -const openArtist = d => rebase(`artist/${d}`); -const openFlash = d => rebase(`flash/${d}`); - -function getTrackListAndIndex() { - const album = getAlbum(document.body); - const directory = cssProp(document.body, '--track-directory'); - if (!directory && !album) return {}; - if (!directory) return {list: album.tracks}; - const trackIndex = album.tracks.findIndex(track => track.directory === directory); - return {list: album.tracks, index: trackIndex}; -} - -function openRandomTrack() { - const { list } = getTrackListAndIndex(); - if (!list) return; - return openTrack(pick(list)); -} - -function getFlashListAndIndex() { - const list = flashData.filter(flash => !flash.act8r8k) - const flash = getFlash(document.body); - if (!flash) return {list}; - const flashIndex = list.indexOf(flash); - return {list, index: flashIndex}; -} +const openAlbum = (d) => rebase(`album/${d}`); +const openTrack = (d) => rebase(`track/${d}`); +const openArtist = (d) => rebase(`artist/${d}`); // TODO: This should also use urlSpec. function fetchData(type, directory) { - return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')) - .then(res => res.json()); + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); } // JS-based links ----------------------------------------- for (const a of document.body.querySelectorAll('[data-random]')) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } - - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - switch (a.dataset.random) { - case 'album': return a.href = openAlbum(pick(albumData).directory); - case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory); - case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory); - case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), [])))); - case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); - case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); - case 'artist': return a.href = openArtist(pick(artistData).directory); - case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => getArtistNumContributions(artist) > 1)).directory); - } + a.addEventListener('click', (evt) => { + if (!ready) { + evt.preventDefault(); + return; + } + + setTimeout(() => { + a.href = rebase('js-disabled'); }); + switch (a.dataset.random) { + case 'album': + return (a.href = openAlbum(pick(albumData).directory)); + case 'album-in-fandom': + return (a.href = openAlbum(pick(fandomAlbumData).directory)); + case 'album-in-official': + return (a.href = openAlbum(pick(officialAlbumData).directory)); + case 'track': + return (a.href = openTrack( + getRefDirectory( + pick( + albumData.map((a) => a.tracks).reduce((a, b) => a.concat(b), []) + ) + ) + )); + case 'track-in-album': + return (a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)))); + case 'track-in-fandom': + return (a.href = openTrack( + getRefDirectory( + pick( + fandomAlbumData.reduce( + (acc, album) => acc.concat(album.tracks), + [] + ) + ) + ) + )); + case 'track-in-official': + return (a.href = openTrack( + getRefDirectory( + pick( + officialAlbumData.reduce( + (acc, album) => acc.concat(album.tracks), + [] + ) + ) + ) + )); + case 'artist': + return (a.href = openArtist(pick(artistData).directory)); + case 'artist-more-than-one-contrib': + return (a.href = openArtist( + pick( + artistData.filter((artist) => getArtistNumContributions(artist) > 1) + ).directory + )); + } + }); } const next = document.getElementById('next-button'); @@ -151,38 +155,38 @@ const previous = document.getElementById('previous-button'); const random = document.getElementById('random-button'); const prependTitle = (el, prepend) => { - const existing = el.getAttribute('title'); - if (existing) { - el.setAttribute('title', prepend + ' ' + existing); - } else { - el.setAttribute('title', prepend); - } + const existing = el.getAttribute('title'); + if (existing) { + el.setAttribute('title', prepend + ' ' + existing); + } else { + el.setAttribute('title', prepend); + } }; if (next) prependTitle(next, '(Shift+N)'); if (previous) prependTitle(previous, '(Shift+P)'); if (random) prependTitle(random, '(Shift+R)'); -document.addEventListener('keypress', event => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - if (next) next.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - if (previous) previous.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - if (random && ready) random.click(); - } +document.addEventListener('keypress', (event) => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + if (next) next.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + if (previous) previous.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + if (random && ready) random.click(); } + } }); for (const reveal of document.querySelectorAll('.reveal')) { - reveal.addEventListener('click', event => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - } - }); + reveal.addEventListener('click', (event) => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + } + }); } const elements1 = document.getElementsByClassName('js-hide-once-data'); @@ -190,20 +194,24 @@ const elements2 = document.getElementsByClassName('js-show-once-data'); for (const element of elements1) element.style.display = 'block'; -fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => { +fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { albumData = data.albumData; artistData = data.artistData; - flashData = data.flashData; - officialAlbumData = albumData.filter(album => album.groups.includes('group:official')); - fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official')); - artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name); + officialAlbumData = albumData.filter((album) => + album.groups.includes('group:official') + ); + fandomAlbumData = albumData.filter( + (album) => !album.groups.includes('group:official') + ); for (const element of elements1) element.style.display = 'none'; for (const element of elements2) element.style.display = 'block'; ready = true; -}); + }); // Data & info card --------------------------------------- @@ -216,197 +224,210 @@ let fastHover = false; let endFastHoverTimeout = null; function colorLink(a, color) { - if (color) { - const { primary, dim } = getColors(color); - a.style.setProperty('--primary-color', primary); - a.style.setProperty('--dim-color', dim); - } + if (color) { + const {primary, dim} = getColors(color); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } } function link(a, type, {name, directory, color}) { - colorLink(a, color); - a.innerText = name - a.href = getLinkHref(type, directory); + colorLink(a, color); + a.innerText = name; + a.href = getLinkHref(type, directory); } function joinElements(type, elements) { - // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on - // strings. So instead, we'll pass the element's outer HTML's (which means - // the entire HTML of that element). - // - // That does mean this function returns a string, so always 8e sure to - // set innerHTML when using it (not appendChild). - - return list[type](elements.map(el => el.outerHTML)); + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map((el) => el.outerHTML)); } const infoCard = (() => { - const container = document.getElementById('info-card-container'); - - let cancelShow = false; - let hideTimeout = null; - let showing = false; - - container.addEventListener('mouseenter', cancelHide); - container.addEventListener('mouseleave', readyHide); - - function show(type, target) { - cancelShow = false; - - fetchData(type, target.dataset[type]).then(data => { - // Manual DOM 'cuz we're laaaazy. - - if (cancelShow) { - return; - } - - showing = true; - - const rect = target.getBoundingClientRect(); - - container.style.setProperty('--primary-color', data.color); - - container.style.top = window.scrollY + rect.bottom + 'px'; - container.style.left = window.scrollX + rect.left + 'px'; - - // Use a short timeout to let a currently hidden (or not yet shown) - // info card teleport to the position set a8ove. (If it's currently - // shown, it'll transition to that position.) - setTimeout(() => { - container.classList.remove('hide'); - container.classList.add('show'); - }, 50); - - // 8asic details. - - const nameLink = container.querySelector('.info-card-name a'); - link(nameLink, 'track', data); - - const albumLink = container.querySelector('.info-card-album a'); - link(albumLink, 'album', data.album); - - const artistSpan = container.querySelector('.info-card-artists span'); - artistSpan.innerHTML = joinElements('conjunction', data.artists.map(({ artist }) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - })); - - const coverArtistParagraph = container.querySelector('.info-card-cover-artists'); - const coverArtistSpan = coverArtistParagraph.querySelector('span'); - if (data.coverArtists.length) { - coverArtistParagraph.style.display = 'block'; - coverArtistSpan.innerHTML = joinElements('conjunction', data.coverArtists.map(({ artist }) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - })); - } else { - coverArtistParagraph.style.display = 'none'; - } - - // Cover art. - - const [ containerNoReveal, containerReveal ] = [ - container.querySelector('.info-card-art-container.no-reveal'), - container.querySelector('.info-card-art-container.reveal') - ]; - - const [ containerShow, containerHide ] = (data.cover.warnings.length - ? [containerReveal, containerNoReveal] - : [containerNoReveal, containerReveal]); - - containerHide.style.display = 'none'; - containerShow.style.display = 'block'; - - const img = containerShow.querySelector('.info-card-art'); - img.src = rebase(data.cover.paths.small, 'rebaseMedia'); - - const imgLink = containerShow.querySelector('a'); - colorLink(imgLink, data.color); - imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); - - if (containerShow === containerReveal) { - const cw = containerShow.querySelector('.info-card-art-warnings'); - cw.innerText = list.unit(data.cover.warnings); - - const reveal = containerShow.querySelector('.reveal'); - reveal.classList.remove('revealed'); - } - }); - } - - function hide() { - container.classList.remove('show'); - container.classList.add('hide'); - cancelShow = true; - showing = false; - } - - function readyHide() { - if (!hideTimeout && showing) { - hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); - } + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then((data) => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements( + 'conjunction', + data.artists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + + const coverArtistParagraph = container.querySelector( + '.info-card-cover-artists' + ); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements( + 'conjunction', + data.coverArtists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [containerNoReveal, containerReveal] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal'), + ]; + + const [containerShow, containerHide] = data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]; + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); } + } - function cancelHide() { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = null; - } + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; } - - return { - show, - hide, - readyHide, - cancelHide - }; + } + + return { + show, + hide, + readyHide, + cancelHide, + }; })(); function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout(() => { - fastHover = true; - infoCard.show(type, evt.target); - }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout( + () => { + fastHover = true; + infoCard.show(type, evt.target); + }, + fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY + ); - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; - infoCard.cancelHide(); - }, + infoCard.cancelHide(); + }, - mouseleave(evt) { - clearTimeout(hoverTimeout); + mouseleave() { + clearTimeout(hoverTimeout); - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } - infoCard.readyHide(); - } - }; + infoCard.readyHide(); + }, + }; } const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track') + track: makeInfoCardLinkHandlers('track'), }; function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) { - a.addEventListener(eventName, handler); - } + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [eventName, handler] of Object.entries( + infoCardLinkHandlers[type] + )) { + a.addEventListener(eventName, handler); } + } } // Info cards are disa8led for now since they aren't quite ready for release, @@ -415,5 +436,5 @@ function addInfoCardLinkHandlers(type) { // localStorage.tryInfoCards = true; // if (localStorage.tryInfoCards) { - addInfoCardLinkHandlers('track'); + addInfoCardLinkHandlers('track'); } diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js index a403d7ca..1b779d26 100644 --- a/src/static/lazy-loading.js +++ b/src/static/lazy-loading.js @@ -1,3 +1,5 @@ +/** @format */ + // Lazy loading! Roll your own. Woot. // This file includes a 8unch of fall8acks and stuff like that, and is written // with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser @@ -7,45 +9,46 @@ var observer; function loadImage(image) { - image.src = image.dataset.original; + image.src = image.dataset.original; } function lazyLoad(elements) { - for (var i = 0; i < elements.length; i++) { - var item = elements[i]; - if (item.intersectionRatio > 0) { - observer.unobserve(item.target); - loadImage(item.target); - } + for (var i = 0; i < elements.length; i++) { + var item = elements[i]; + if (item.intersectionRatio > 0) { + observer.unobserve(item.target); + loadImage(item.target); } + } } function lazyLoadMain() { - // This is a live HTMLCollection! We can't iter8te over it normally 'cuz - // we'd 8e mutating its value just 8y interacting with the DOM elements it - // contains. A while loop works just fine, even though you'd think reading - // over this code that this would 8e an infinitely hanging loop. It isn't! - var elements = document.getElementsByClassName('js-hide'); - while (elements.length) { - elements[0].classList.remove('js-hide'); - } + // This is a live HTMLCollection! We can't iter8te over it normally 'cuz + // we'd 8e mutating its value just 8y interacting with the DOM elements it + // contains. A while loop works just fine, even though you'd think reading + // over this code that this would 8e an infinitely hanging loop. It isn't! + var elements = document.getElementsByClassName('js-hide'); + while (elements.length) { + elements[0].classList.remove('js-hide'); + } - var lazyElements = document.getElementsByClassName('lazy'); - if (window.IntersectionObserver) { - observer = new IntersectionObserver(lazyLoad, { - rootMargin: '200px', - threshold: 1.0 - }); - for (var i = 0; i < lazyElements.length; i++) { - observer.observe(lazyElements[i]); - } - } else { - for (var i = 0; i < lazyElements.length; i++) { - var element = lazyElements[i]; - var original = element.getAttribute('data-original'); - element.setAttribute('src', original); - } + var lazyElements = document.getElementsByClassName('lazy'); + var i; + if (window.IntersectionObserver) { + observer = new IntersectionObserver(lazyLoad, { + rootMargin: '200px', + threshold: 1.0, + }); + for (i = 0; i < lazyElements.length; i++) { + observer.observe(lazyElements[i]); + } + } else { + for (i = 0; i < lazyElements.length; i++) { + var element = lazyElements[i]; + var original = element.getAttribute('data-original'); + element.setAttribute('src', original); } + } } document.addEventListener('DOMContentLoaded', lazyLoadMain); diff --git a/src/static/site-basic.css b/src/static/site-basic.css index d26584ae..586f37b5 100644 --- a/src/static/site-basic.css +++ b/src/static/site-basic.css @@ -4,16 +4,16 @@ */ html { - background-color: #222222; - color: white; + background-color: #222222; + color: white; } body { - padding: 15px; + padding: 15px; } main { - background-color: rgba(0, 0, 0, 0.6); - border: 1px dotted white; - padding: 20px; + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted white; + padding: 20px; } diff --git a/src/static/site.css b/src/static/site.css index e0031351..d80c57c5 100644 --- a/src/static/site.css +++ b/src/static/site.css @@ -4,492 +4,503 @@ */ :root { - --primary-color: #0088ff; + --primary-color: #0088ff; } body { - background: black; - margin: 10px; - overflow-y: scroll; + background: black; + margin: 10px; + overflow-y: scroll; } body::before { - content: ""; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; - background-image: url("../media/bg.jpg"); - background-position: center; - background-size: cover; - opacity: 0.5; + background-image: url("../media/bg.jpg"); + background-position: center; + background-size: cover; + opacity: 0.5; } #page-container { - background-color: var(--bg-color, rgba(35, 35, 35, 0.80)); - color: #ffffff; + background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); + color: #ffffff; - max-width: 1100px; - margin: 10px auto 50px; - padding: 15px 0; + max-width: 1100px; + margin: 10px auto 50px; + padding: 15px 0; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); } #page-container > * { - margin-left: 15px; - margin-right: 15px; + margin-left: 15px; + margin-right: 15px; } #banner { - margin: 10px 0; - width: 100%; - background: black; - background-color: var(--dim-color); - border-bottom: 1px solid var(--primary-color); - position: relative; + margin: 10px 0; + width: 100%; + background: black; + background-color: var(--dim-color); + border-bottom: 1px solid var(--primary-color); + position: relative; } #banner::after { - content: ""; - box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; + content: ""; + box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; } #banner.dim img { - opacity: 0.8; + opacity: 0.8; } #banner img { - display: block; - width: 100%; - height: auto; + display: block; + width: 100%; + height: auto; } a { - color: var(--primary-color); - text-decoration: none; + color: var(--primary-color); + text-decoration: none; } a:hover { - text-decoration: underline; + text-decoration: underline; } #skippers { - position: absolute; - left: -10000px; - top: auto; - width: 1px; - height: 1px; + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; } #skippers:focus-within { - position: static; - width: unset; - height: unset; + position: static; + width: unset; + height: unset; } #skippers > .skipper:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; + content: " \00b7 "; + font-weight: 800; } .layout-columns { - display: flex; + display: flex; } -#header, #secondary-nav, #skippers, #footer { - padding: 5px; - font-size: 0.85em; +#header, +#secondary-nav, +#skippers, +#footer { + padding: 5px; + font-size: 0.85em; } -#header, #secondary-nav, #skippers { - margin-bottom: 10px; +#header, +#secondary-nav, +#skippers { + margin-bottom: 10px; } #footer { - margin-top: 10px; + margin-top: 10px; } #header { - display: grid; + display: grid; } #header.nav-has-main-links.nav-has-content { - grid-template-columns: 2.5fr 3fr; - grid-template-rows: min-content 1fr; - grid-template-areas: - "main-links content" - "bottom-row content"; + grid-template-columns: 2.5fr 3fr; + grid-template-rows: min-content 1fr; + grid-template-areas: + "main-links content" + "bottom-row content"; } #header.nav-has-main-links:not(.nav-has-content) { - grid-template-columns: 1fr; - grid-template-areas: - "main-links" - "bottom-row"; + grid-template-columns: 1fr; + grid-template-areas: + "main-links" + "bottom-row"; } .nav-main-links { - grid-area: main-links; - margin-right: 20px; + grid-area: main-links; + margin-right: 20px; } .nav-content { - grid-area: content; + grid-area: content; } .nav-bottom-row { - grid-area: bottom-row; - align-self: start; + grid-area: bottom-row; + align-self: start; } .nav-main-links > span { - white-space: nowrap; + white-space: nowrap; } .nav-main-links > span > a.current { - font-weight: 800; + font-weight: 800; } .nav-links-index > span:not(:first-child):not(.no-divider)::before { - content: "\0020\00b7\0020"; - font-weight: 800; + content: "\0020\00b7\0020"; + font-weight: 800; } .nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before { - content: "\0020/\0020"; + content: "\0020/\0020"; } #header .chronology { - display: block; + display: block; } #header .chronology .heading, #header .chronology .buttons { - display: inline-block; + display: inline-block; } #secondary-nav { - text-align: center; + text-align: center; } #secondary-nav:not(.no-hide) { - display: none; + display: none; } footer { - text-align: center; - font-style: oblique; + text-align: center; + font-style: oblique; } footer > :first-child { - margin-top: 0; + margin-top: 0; } footer > :last-child { - margin-bottom: 0; + margin-bottom: 0; } .footer-localization-links > span:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; + content: " \00b7 "; + font-weight: 800; } .nowrap { - white-space: nowrap; + white-space: nowrap; } .icons { - font-style: normal; - white-space: nowrap; + font-style: normal; + white-space: nowrap; } .icon { - display: inline-block; - width: 24px; - height: 1em; - position: relative; + display: inline-block; + width: 24px; + height: 1em; + position: relative; } .icon > svg { - width: 24px; - height: 24px; - top: -0.25em; - position: absolute; - fill: var(--primary-color); + width: 24px; + height: 24px; + top: -0.25em; + position: absolute; + fill: var(--primary-color); } .rerelease, .other-group-accent { - opacity: 0.7; - font-style: oblique; + opacity: 0.7; + font-style: oblique; } .other-group-accent { - white-space: nowrap; + white-space: nowrap; } .content-columns { - columns: 2; + columns: 2; } .content-columns .column { - break-inside: avoid; + break-inside: avoid; } .content-columns .column h2 { - margin-top: 0; - font-size: 1em; + margin-top: 0; + font-size: 1em; } -.sidebar, #content, #header, #secondary-nav, #skippers, #footer { - background-color: rgba(0, 0, 0, 0.6); - border: 1px dotted var(--primary-color); - border-radius: 3px; +.sidebar, +#content, +#header, +#secondary-nav, +#skippers, +#footer { + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted var(--primary-color); + border-radius: 3px; } .sidebar-column { - flex: 1 1 20%; - min-width: 150px; - max-width: 250px; - flex-basis: 250px; - height: 100%; + flex: 1 1 20%; + min-width: 150px; + max-width: 250px; + flex-basis: 250px; + height: 100%; } .sidebar-multiple { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .sidebar-multiple .sidebar:not(:first-child) { - margin-top: 10px; + margin-top: 10px; } .sidebar { - padding: 5px; - font-size: 0.85em; + padding: 5px; + font-size: 0.85em; } #sidebar-left { - margin-right: 10px; + margin-right: 10px; } #sidebar-right { - margin-left: 10px; + margin-left: 10px; } .sidebar.wide { - max-width: 350px; - flex-basis: 300px; - flex-shrink: 0; - flex-grow: 1; + max-width: 350px; + flex-basis: 300px; + flex-shrink: 0; + flex-grow: 1; } #content { - box-sizing: border-box; - padding: 20px; - flex-grow: 1; - flex-shrink: 3; - overflow-wrap: anywhere; + box-sizing: border-box; + padding: 20px; + flex-grow: 1; + flex-shrink: 3; + overflow-wrap: anywhere; } .sidebar > h1, .sidebar > h2, .sidebar > h3, .sidebar > p { - text-align: center; + text-align: center; } .sidebar h1 { - font-size: 1.25em; + font-size: 1.25em; } .sidebar h2 { - font-size: 1.1em; - margin: 0; + font-size: 1.1em; + margin: 0; } .sidebar h3 { - font-size: 1.1em; - font-style: oblique; - font-variant: small-caps; - margin-top: 0.3em; - margin-bottom: 0em; + font-size: 1.1em; + font-style: oblique; + font-variant: small-caps; + margin-top: 0.3em; + margin-bottom: 0em; } .sidebar > p { - margin: 0.5em 0; - padding: 0 5px; + margin: 0.5em 0; + padding: 0 5px; } .sidebar hr { - color: #555; - margin: 10px 5px; + color: #555; + margin: 10px 5px; } -.sidebar > ol, .sidebar > ul { - padding-left: 30px; - padding-right: 15px; +.sidebar > ol, +.sidebar > ul { + padding-left: 30px; + padding-right: 15px; } .sidebar > dl { - padding-right: 15px; - padding-left: 0; + padding-right: 15px; + padding-left: 0; } .sidebar > dl dt { - padding-left: 10px; - margin-top: 0.5em; + padding-left: 10px; + margin-top: 0.5em; } .sidebar > dl dt.current { - font-weight: 800; + font-weight: 800; } .sidebar > dl dd { - margin-left: 0; + margin-left: 0; } .sidebar > dl dd ul { - padding-left: 30px; - margin-left: 0; + padding-left: 30px; + margin-left: 0; } .sidebar > dl .side { - padding-left: 10px; + padding-left: 10px; } .sidebar li.current { - font-weight: 800; + font-weight: 800; } .sidebar li { - overflow-wrap: break-word; + overflow-wrap: break-word; } .sidebar > details.current summary { - font-weight: 800; + font-weight: 800; } .sidebar > details summary { - margin-top: 0.5em; - padding-left: 5px; - user-select: none; + margin-top: 0.5em; + padding-left: 5px; + user-select: none; } .sidebar > details summary .group-name { - color: var(--primary-color); + color: var(--primary-color); } .sidebar > details summary:hover { - cursor: pointer; - text-decoration: underline; - text-decoration-color: var(--primary-color); + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--primary-color); } .sidebar > details ul, .sidebar > details ol { - margin-top: 0; - margin-bottom: 0; + margin-top: 0; + margin-bottom: 0; } .sidebar > details:last-child { - margin-bottom: 10px; + margin-bottom: 10px; } .sidebar > details[open] { - margin-bottom: 1em; + margin-bottom: 1em; } .sidebar article { - text-align: left; - margin: 5px 5px 15px 5px; + text-align: left; + margin: 5px 5px 15px 5px; } .sidebar article:last-child { - margin-bottom: 5px; + margin-bottom: 5px; } .sidebar article h2, .news-index h2 { - border-bottom: 1px dotted; + border-bottom: 1px dotted; } .sidebar article h2 time, .news-index time { - float: right; - font-weight: normal; + float: right; + font-weight: normal; } #cover-art-container { - float: right; - width: 40%; - max-width: 400px; - margin: 0 0 10px 10px; - font-size: 0.8em; + float: right; + width: 40%; + max-width: 400px; + margin: 0 0 10px 10px; + font-size: 0.8em; } #cover-art img { - display: block; - width: 100%; - height: 100%; + display: block; + width: 100%; + height: 100%; } #cover-art-container p { - margin-top: 5px; + margin-top: 5px; } .image-container { - border: 2px solid var(--primary-color); - box-sizing: border-box; - position: relative; - padding: 5px; - text-align: left; - background-color: var(--dim-color); - color: white; - display: inline-block; - width: 100%; - height: 100%; + border: 2px solid var(--primary-color); + box-sizing: border-box; + position: relative; + padding: 5px; + text-align: left; + background-color: var(--dim-color); + color: white; + display: inline-block; + width: 100%; + height: 100%; } .image-inner-area { - overflow: hidden; - width: 100%; - height: 100%; - position: relative; + overflow: hidden; + width: 100%; + height: 100%; + position: relative; } .image-text-area { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 5px 15px; - background: rgba(0, 0, 0, 0.65); - box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; - line-height: 1.35em; - color: var(--primary-color); - font-style: oblique; - text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 5px 15px; + background: rgba(0, 0, 0, 0.65); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; + line-height: 1.35em; + color: var(--primary-color); + font-style: oblique; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); } img { - object-fit: cover; - /* these unfortunately dont take effect while loading, so... + object-fit: cover; + /* these unfortunately dont take effect while loading, so... text-align: center; line-height: 2em; text-shadow: 0 0 5px black; @@ -500,525 +511,528 @@ img { .js-hide, .js-show-once-data, .js-hide-once-data { - display: none; + display: none; } a.box:focus { - outline: 3px double var(--primary-color); + outline: 3px double var(--primary-color); } a.box:focus:not(:focus-visible) { - outline: none; + outline: none; } a.box img { - display: block; - width: 100%; - height: 100%; + display: block; + width: 100%; + height: 100%; } h1 { - font-size: 1.5em; + font-size: 1.5em; } #content li { - margin-bottom: 4px; + margin-bottom: 4px; } #content li i { - white-space: nowrap; + white-space: nowrap; } .grid-listing { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: flex-start; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; } .grid-item { - display: inline-block; - margin: 15px; - text-align: center; - background-color: #111111; - border: 1px dotted var(--primary-color); - border-radius: 2px; - padding: 5px; + display: inline-block; + margin: 15px; + text-align: center; + background-color: #111111; + border: 1px dotted var(--primary-color); + border-radius: 2px; + padding: 5px; } .grid-item img { - width: 100%; - height: 100%; - margin-top: auto; - margin-bottom: auto; + width: 100%; + height: 100%; + margin-top: auto; + margin-bottom: auto; } .grid-item span { - overflow-wrap: break-word; - hyphens: auto; + overflow-wrap: break-word; + hyphens: auto; } .grid-item:hover { - text-decoration: none; + text-decoration: none; } .grid-actions .grid-item:hover { - text-decoration: underline; + text-decoration: underline; } .grid-item > span { - display: block; + display: block; } .grid-item > span:not(:first-child) { - margin-top: 2px; + margin-top: 2px; } .grid-item > span:first-of-type { - margin-top: 6px; + margin-top: 6px; } .grid-item:hover > span:first-of-type { - text-decoration: underline; + text-decoration: underline; } .grid-listing > .grid-item { - flex: 1 1 26%; + flex: 1 1 26%; } .grid-actions { - display: flex; - flex-direction: column; - margin: 15px; - align-self: center; + display: flex; + flex-direction: column; + margin: 15px; + align-self: center; } .grid-actions > .grid-item { - flex-basis: unset !important; - margin: 5px; - --primary-color: inherit !important; - --dim-color: inherit !important; + flex-basis: unset !important; + margin: 5px; + --primary-color: inherit !important; + --dim-color: inherit !important; } .grid-item { - flex-basis: 240px; - min-width: 200px; - max-width: 240px; + flex-basis: 240px; + min-width: 200px; + max-width: 240px; } .grid-item:not(.large-grid-item) { - flex-basis: 180px; - min-width: 120px; - max-width: 180px; - font-size: 0.9em; + flex-basis: 180px; + min-width: 120px; + max-width: 180px; + font-size: 0.9em; } .square { - position: relative; - width: 100%; + position: relative; + width: 100%; } .square::after { - content: ""; - display: block; - padding-bottom: 100%; + content: ""; + display: block; + padding-bottom: 100%; } .square-content { - position: absolute; - width: 100%; - height: 100%; + position: absolute; + width: 100%; + height: 100%; } .vertical-square { - position: relative; - height: 100%; + position: relative; + height: 100%; } .vertical-square::after { - content: ""; - display: block; - padding-right: 100%; + content: ""; + display: block; + padding-right: 100%; } .reveal { - position: relative; - width: 100%; - height: 100%; + position: relative; + width: 100%; + height: 100%; } .reveal img { - filter: blur(20px); - opacity: 0.4; + filter: blur(20px); + opacity: 0.4; } .reveal-text { - color: white; - position: absolute; - top: 15px; - left: 10px; - right: 10px; - text-align: center; - font-weight: bold; + color: white; + position: absolute; + top: 15px; + left: 10px; + right: 10px; + text-align: center; + font-weight: bold; } .reveal-interaction { - opacity: 0.8; + opacity: 0.8; } .reveal.revealed img { - filter: none; - opacity: 1; + filter: none; + opacity: 1; } .reveal.revealed .reveal-text { - display: none; + display: none; } #content.top-index h1, #content.flash-index h1 { - text-align: center; - font-size: 2em; + text-align: center; + font-size: 2em; } #content.flash-index h2 { - text-align: center; - font-size: 2.5em; - font-variant: small-caps; - font-style: oblique; - margin-bottom: 0; - text-align: center; - width: 100%; + text-align: center; + font-size: 2.5em; + font-variant: small-caps; + font-style: oblique; + margin-bottom: 0; + text-align: center; + width: 100%; } #content.top-index h2 { - text-align: center; - font-size: 2em; - font-weight: normal; - margin-bottom: 0.25em; + text-align: center; + font-size: 2em; + font-weight: normal; + margin-bottom: 0.25em; } .quick-info { - text-align: center; + text-align: center; } ul.quick-info { - list-style: none; - padding-left: 0; + list-style: none; + padding-left: 0; } ul.quick-info li { - display: inline-block; + display: inline-block; } ul.quick-info li:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; + content: " \00b7 "; + font-weight: 800; } #intro-menu { - margin: 24px 0; - padding: 10px; - background-color: #222222; - text-align: center; - border: 1px dotted var(--primary-color); - border-radius: 2px; + margin: 24px 0; + padding: 10px; + background-color: #222222; + text-align: center; + border: 1px dotted var(--primary-color); + border-radius: 2px; } #intro-menu p { - margin: 12px 0; + margin: 12px 0; } #intro-menu a { - margin: 0 6px; + margin: 0 6px; } li .by { - display: inline-block; - font-style: oblique; + display: inline-block; + font-style: oblique; } li .by a { - display: inline-block; + display: inline-block; } p code { - font-size: 1em; - font-family: 'courier new'; - font-weight: 800; + font-size: 1em; + font-family: "courier new"; + font-weight: 800; } blockquote { - margin-left: 40px; - max-width: 600px; - margin-right: 0; + margin-left: 40px; + max-width: 600px; + margin-right: 0; } .long-content { - margin-left: 12%; - margin-right: 12%; + margin-left: 12%; + margin-right: 12%; } p img { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } dl dt { - padding-left: 40px; - max-width: 600px; + padding-left: 40px; + max-width: 600px; } dl dt { - margin-bottom: 2px; + margin-bottom: 2px; } dl dd { - margin-bottom: 1em; + margin-bottom: 1em; } -dl ul, dl ol { - margin-top: 0; - margin-bottom: 0; +dl ul, +dl ol { + margin-top: 0; + margin-bottom: 0; } .album-group-list dt { - font-style: oblique; - padding-left: 0; + font-style: oblique; + padding-left: 0; } .album-group-list dd { - margin-left: 0; + margin-left: 0; } .group-chronology-link { - font-style: oblique; + font-style: oblique; } hr.split::before { - content: "(split)"; - color: #808080; + content: "(split)"; + color: #808080; } hr.split { - position: relative; - overflow: hidden; - border: none; + position: relative; + overflow: hidden; + border: none; } hr.split::after { - display: inline-block; - content: ""; - border: 1px inset #808080; - width: 100%; - position: absolute; - top: 50%; - margin-top: -2px; - margin-left: 10px; + display: inline-block; + content: ""; + border: 1px inset #808080; + width: 100%; + position: absolute; + top: 50%; + margin-top: -2px; + margin-left: 10px; } li > ul { - margin-top: 5px; + margin-top: 5px; } #info-card-container { - position: absolute; + position: absolute; - left: 0; - right: 10px; + left: 0; + right: 10px; - pointer-events: none; /* Padding area shouldn't 8e interactive. */ - display: none; + pointer-events: none; /* Padding area shouldn't 8e interactive. */ + display: none; } #info-card-container.show, #info-card-container.hide { - display: flex; + display: flex; } #info-card-container > * { - flex-basis: 400px; + flex-basis: 400px; } #info-card-container.show { - animation: 0.2s linear forwards info-card-show; - transition: top 0.1s, left 0.1s; + animation: 0.2s linear forwards info-card-show; + transition: top 0.1s, left 0.1s; } #info-card-container.hide { - animation: 0.2s linear forwards info-card-hide; + animation: 0.2s linear forwards info-card-hide; } @keyframes info-card-show { - 0% { - opacity: 0; - margin-top: -5px; - } + 0% { + opacity: 0; + margin-top: -5px; + } - 100% { - opacity: 1; - margin-top: 0; - } + 100% { + opacity: 1; + margin-top: 0; + } } @keyframes info-card-hide { - 0% { - opacity: 1; - margin-top: 0; - } + 0% { + opacity: 1; + margin-top: 0; + } - 100% { - opacity: 0; - margin-top: 5px; - display: none !important; - } + 100% { + opacity: 0; + margin-top: 5px; + display: none !important; + } } .info-card-decor { - padding-left: 3ch; - border-top: 1px solid white; + padding-left: 3ch; + border-top: 1px solid white; } .info-card { - background-color: black; - color: white; + background-color: black; + color: white; - border: 1px dotted var(--primary-color); - border-radius: 3px; - box-shadow: 0 5px 5px black; + border: 1px dotted var(--primary-color); + border-radius: 3px; + box-shadow: 0 5px 5px black; - padding: 5px; - font-size: 0.9em; + padding: 5px; + font-size: 0.9em; - pointer-events: none; + pointer-events: none; } .info-card::after { - content: ""; - display: block; - clear: both; + content: ""; + display: block; + clear: both; } #info-card-container.show .info-card { - animation: 0.01s linear 0.2s forwards info-card-become-interactive; + animation: 0.01s linear 0.2s forwards info-card-become-interactive; } @keyframes info-card-become-interactive { - to { - pointer-events: auto; - } + to { + pointer-events: auto; + } } .info-card-art-container { - float: right; + float: right; - width: 40%; - margin: 5px; - font-size: 0.8em; + width: 40%; + margin: 5px; + font-size: 0.8em; - /* Dynamically shown. */ - display: none; + /* Dynamically shown. */ + display: none; } .info-card-art-container .image-container { - padding: 2px; + padding: 2px; } .info-card-art { - display: block; - width: 100%; - height: 100%; + display: block; + width: 100%; + height: 100%; } .info-card-name { - font-size: 1em; - border-bottom: 1px dotted; - margin: 0; + font-size: 1em; + border-bottom: 1px dotted; + margin: 0; } .info-card p { - margin-top: 0.25em; - margin-bottom: 0.25em; + margin-top: 0.25em; + margin-bottom: 0.25em; } .info-card p:last-child { - margin-bottom: 0; + margin-bottom: 0; } @media (max-width: 900px) { - .sidebar-column:not(.no-hide) { - display: none; - } + .sidebar-column:not(.no-hide) { + display: none; + } - #secondary-nav:not(.no-hide) { - display: block; - } + #secondary-nav:not(.no-hide) { + display: block; + } - .layout-columns.vertical-when-thin { - flex-direction: column; - } + .layout-columns.vertical-when-thin { + flex-direction: column; + } - .layout-columns.vertical-when-thin > *:not(:last-child) { - margin-bottom: 10px; - } + .layout-columns.vertical-when-thin > *:not(:last-child) { + margin-bottom: 10px; + } - .sidebar-column.no-hide { - max-width: unset !important; - flex-basis: unset !important; - margin-right: 0 !important; - margin-left: 0 !important; - } + .sidebar-column.no-hide { + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + } - .sidebar .news-entry:not(.first-news-entry) { - display: none; - } + .sidebar .news-entry:not(.first-news-entry) { + display: none; + } } @media (max-width: 600px) { - .content-columns { - columns: 1; - } + .content-columns { + columns: 1; + } - #cover-art-container { - float: none; - margin: 0 10px 10px 10px; - margin: 0; - width: 100%; - max-width: unset; - } + #cover-art-container { + float: none; + margin: 0 10px 10px 10px; + margin: 0; + width: 100%; + max-width: unset; + } - #header { - display: block; - } + #header { + display: block; + } - #header > div:not(:first-child) { - margin-top: 0.5em; - } + #header > div:not(:first-child) { + margin-top: 0.5em; + } } /* important easter egg mode */ -html[data-language-code=preview-en][data-url-key="localized.home"] #content h1::after { - font-family: cursive; - display: block; - content: "(Preview Build)"; +html[data-language-code="preview-en"][data-url-key="localized.home"] + #content + h1::after { + font-family: cursive; + display: block; + content: "(Preview Build)"; } -html[data-language-code=preview-en] #header h2 > :first-child::before { - content: "(Preview Build! ✨) "; - animation: preview-notice 4s infinite; +html[data-language-code="preview-en"] #header h2 > :first-child::before { + content: "(Preview Build! ✨) "; + animation: preview-notice 4s infinite; } @keyframes preview-notice { - 0% { - color: #cc5500; - } + 0% { + color: #cc5500; + } - 50% { - color: #ffaa00; - } + 50% { + color: #ffaa00; + } - 100% { - color: #cc5500; - } + 100% { + color: #cc5500; + } } diff --git a/src/strings-default.json b/src/strings-default.json index fb2e333c..d94f6deb 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -1,379 +1,379 @@ { - "meta.languageCode": "en", - "meta.languageName": "English", - "count.tracks": "{TRACKS}", - "count.tracks.withUnit.zero": "", - "count.tracks.withUnit.one": "{TRACKS} track", - "count.tracks.withUnit.two": "", - "count.tracks.withUnit.few": "", - "count.tracks.withUnit.many": "", - "count.tracks.withUnit.other": "{TRACKS} tracks", - "count.additionalFiles": "{FILES}", - "count.additionalFiles.withUnit.zero": "", - "count.additionalFiles.withUnit.one": "{FILES} additional file", - "count.additionalFiles.withUnit.two": "", - "count.additionalFiles.withUnit.few": "", - "count.additionalFiles.withUnit.many": "", - "count.additionalFiles.withUnit.other": "{FILES} additional files", - "count.albums": "{ALBUMS}", - "count.albums.withUnit.zero": "", - "count.albums.withUnit.one": "{ALBUMS} album", - "count.albums.withUnit.two": "", - "count.albums.withUnit.two": "", - "count.albums.withUnit.few": "", - "count.albums.withUnit.many": "", - "count.albums.withUnit.other": "{ALBUMS} albums", - "count.commentaryEntries": "{ENTRIES}", - "count.commentaryEntries.withUnit.zero": "", - "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", - "count.commentaryEntries.withUnit.two": "", - "count.commentaryEntries.withUnit.few": "", - "count.commentaryEntries.withUnit.many": "", - "count.commentaryEntries.withUnit.other": "{ENTRIES} entries", - "count.contributions": "{CONTRIBUTIONS}", - "count.contributions.withUnit.zero": "", - "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution", - "count.contributions.withUnit.two": "", - "count.contributions.withUnit.few": "", - "count.contributions.withUnit.many": "", - "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions", - "count.coverArts": "{COVER_ARTS}", - "count.coverArts.withUnit.zero": "", - "count.coverArts.withUnit.one": "{COVER_ARTS} cover art", - "count.coverArts.withUnit.two": "", - "count.coverArts.withUnit.few": "", - "count.coverArts.withUnit.many": "", - "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", - "count.timesReferenced": "{TIMES_REFERENCED}", - "count.timesReferenced.withUnit.zero": "", - "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", - "count.timesReferenced.withUnit.two": "", - "count.timesReferenced.withUnit.few": "", - "count.timesReferenced.withUnit.many": "", - "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced", - "count.words": "{WORDS}", - "count.words.thousand": "{WORDS}k", - "count.words.withUnit.zero": "", - "count.words.withUnit.one": "{WORDS} word", - "count.words.withUnit.two": "", - "count.words.withUnit.few": "", - "count.words.withUnit.many": "", - "count.words.withUnit.other": "{WORDS} words", - "count.timesUsed": "{TIMES_USED}", - "count.timesUsed.withUnit.zero": "", - "count.timesUsed.withUnit.one": "used {TIMES_USED} time", - "count.timesUsed.withUnit.two": "", - "count.timesUsed.withUnit.few": "", - "count.timesUsed.withUnit.many": "", - "count.timesUsed.withUnit.other": "used {TIMES_USED} times", - "count.index.zero": "", - "count.index.one": "{INDEX}st", - "count.index.two": "{INDEX}nd", - "count.index.few": "{INDEX}rd", - "count.index.many": "", - "count.index.other": "{INDEX}th", - "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}", - "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours", - "count.duration.minutes": "{MINUTES}:{SECONDS}", - "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes", - "count.duration.approximate": "~{DURATION}", - "count.duration.missing": "_:__", - "count.fileSize.terabytes": "{TERABYTES} TB", - "count.fileSize.gigabytes": "{GIGABYTES} GB", - "count.fileSize.megabytes": "{MEGABYTES} MB", - "count.fileSize.kilobytes": "{KILOBYTES} kB", - "count.fileSize.bytes": "{BYTES} bytes", - "releaseInfo.by": "By {ARTISTS}.", - "releaseInfo.from": "From {ALBUM}.", - "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", - "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", - "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.", - "releaseInfo.released": "Released {DATE}.", - "releaseInfo.artReleased": "Art released {DATE}.", - "releaseInfo.addedToWiki": "Added to wiki {DATE}.", - "releaseInfo.duration": "Duration: {DURATION}.", - "releaseInfo.viewCommentary": "View {LINK}!", - "releaseInfo.viewCommentary.link": "commentary page", - "releaseInfo.listenOn": "Listen on {LINKS}.", - "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.", - "releaseInfo.visitOn": "Visit on {LINKS}.", - "releaseInfo.playOn": "Play on {LINKS}.", - "releaseInfo.alsoReleasedAs": "Also released as:", - "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", - "releaseInfo.contributors": "Contributors:", - "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:", - "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:", - "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:", - "releaseInfo.flashesThatFeature.item": "{FLASH}", - "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})", - "releaseInfo.lyrics": "Lyrics:", - "releaseInfo.artistCommentary": "Artist commentary:", - "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", - "releaseInfo.artTags": "Tags:", - "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}", - "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:", - "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:", - "releaseInfo.additionalFiles.entry": "{TITLE}", - "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}", - "releaseInfo.additionalFiles.file": "{FILE}", - "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})", - "releaseInfo.note": "Note:", - "trackList.section.withDuration": "{SECTION} ({DURATION}):", - "trackList.group": "{GROUP}:", - "trackList.group.other": "Other", - "trackList.item.withDuration": "({DURATION}) {TRACK}", - "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", - "trackList.item.withArtists": "{TRACK} {BY}", - "trackList.item.withArtists.by": "by {ARTISTS}", - "trackList.item.rerelease": "{TRACK} (re-release)", - "misc.alt.albumCover": "album cover", - "misc.alt.albumBanner": "album banner", - "misc.alt.trackCover": "track cover", - "misc.alt.artistAvatar": "artist avatar", - "misc.alt.flashArt": "flash art", - "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)", - "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}", - "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}", - "misc.chronology.heading.track": "{INDEX} track by {ARTIST}", - "misc.external.domain": "External ({DOMAIN})", - "misc.external.local": "Wiki Archive (local upload)", - "misc.external.bandcamp": "Bandcamp", - "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})", - "misc.external.deviantart": "DeviantArt", - "misc.external.instagram": "Instagram", - "misc.external.mastodon": "Mastodon", - "misc.external.mastodon.domain": "Mastodon ({DOMAIN})", - "misc.external.patreon": "Patreon", - "misc.external.poetryFoundation": "Poetry Foundation", - "misc.external.soundcloud": "SoundCloud", - "misc.external.tumblr": "Tumblr", - "misc.external.twitter": "Twitter", - "misc.external.wikipedia": "Wikipedia", - "misc.external.youtube": "YouTube", - "misc.external.youtube.playlist": "YouTube (playlist)", - "misc.external.youtube.fullAlbum": "YouTube (full album)", - "misc.external.flash.bgreco": "{LINK} (HQ Audio)", - "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})", - "misc.external.flash.homestuck.secret": "{LINK} (secret page)", - "misc.external.flash.youtube": "{LINK} (on any device)", - "misc.nav.previous": "Previous", - "misc.nav.next": "Next", - "misc.nav.info": "Info", - "misc.nav.gallery": "Gallery", - "misc.pageTitle": "{TITLE}", - "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}", - "misc.skippers.skipToContent": "Skip to content", - "misc.skippers.skipToSidebar": "Skip to sidebar", - "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)", - "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)", - "misc.skippers.skipToFooter": "Skip to footer", - "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}", - "misc.jumpTo": "Jump to:", - "misc.jumpTo.withLinks": "Jump to: {LINKS}.", - "misc.contentWarnings": "cw: {WARNINGS}", - "misc.contentWarnings.reveal": "click to show", - "misc.albumGrid.details": "({TRACKS}, {TIME})", - "misc.albumGrid.noCoverArt": "{ALBUM}", - "misc.uiLanguage": "UI Language: {LANGUAGES}", - "homepage.title": "{TITLE}", - "homepage.news.title": "News", - "homepage.news.entry.viewRest": "(View rest of entry!)", - "albumSidebar.trackList.fallbackGroupName": "Track list", - "albumSidebar.trackList.group": "{GROUP}", - "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", - "albumSidebar.trackList.item": "{TRACK}", - "albumSidebar.groupBox.title": "{GROUP}", - "albumSidebar.groupBox.next": "Next: {ALBUM}", - "albumSidebar.groupBox.previous": "Previous: {ALBUM}", - "albumPage.title": "{ALBUM}", - "albumPage.nav.album": "{ALBUM}", - "albumPage.nav.randomTrack": "Random Track", - "albumCommentaryPage.title": "{ALBUM} - Commentary", - "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.", - "albumCommentaryPage.nav.album": "Album: {ALBUM}", - "albumCommentaryPage.entry.title.albumCommentary": "Album commentary", - "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}", - "artistPage.title": "{ARTIST}", - "artistPage.creditList.album": "{ALBUM}", - "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})", - "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})", - "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", - "artistPage.creditList.flashAct": "{ACT}", - "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", - "artistPage.creditList.entry.track": "{TRACK}", - "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", - "artistPage.creditList.entry.album.coverArt": "(cover art)", - "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)", - "artistPage.creditList.entry.album.bannerArt": "(banner art)", - "artistPage.creditList.entry.album.commentary": "(album commentary)", - "artistPage.creditList.entry.flash": "{FLASH}", - "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)", - "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})", - "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})", - "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})", - "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", - "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}", - "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", - "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})", - "artistPage.trackList.title": "Tracks", - "artistPage.artList.title": "Art", - "artistPage.flashList.title": "Flashes & Games", - "artistPage.commentaryList.title": "Commentary", - "artistPage.viewArtGallery": "View {LINK}!", - "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:", - "artistPage.viewArtGallery.link": "art gallery", - "artistPage.nav.artist": "Artist: {ARTIST}", - "artistGalleryPage.title": "{ARTIST} - Gallery", - "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.", - "commentaryIndex.title": "Commentary", - "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.", - "commentaryIndex.albumList.title": "Choose an album:", - "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})", - "flashIndex.title": "Flashes & Games", - "flashPage.title": "{FLASH}", - "flashPage.nav.flash": "{FLASH}", - "groupSidebar.title": "Groups", - "groupSidebar.groupList.category": "{CATEGORY}", - "groupSidebar.groupList.item": "{GROUP}", - "groupPage.nav.group": "Group: {GROUP}", - "groupInfoPage.title": "{GROUP}", - "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:", - "groupInfoPage.viewAlbumGallery.link": "album gallery", - "groupInfoPage.albumList.title": "Albums", - "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}", - "groupInfoPage.albumList.item.withoutYear": "{ALBUM}", - "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}", - "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})", - "groupGalleryPage.title": "{GROUP} - Gallery", - "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.", - "groupGalleryPage.anotherGroupLine": "({LINK})", - "groupGalleryPage.anotherGroupLine.link": "Choose another group to filter by!", - "listingIndex.title": "Listings", - "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.", - "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!", - "listingPage.target.album": "Albums", - "listingPage.target.artist": "Artists", - "listingPage.target.group": "Groups", - "listingPage.target.track": "Tracks", - "listingPage.target.tag": "Tags", - "listingPage.target.other": "Other", - "listingPage.listAlbums.byName.title": "Albums - by Name", - "listingPage.listAlbums.byName.title.short": "...by Name", - "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byTracks.title": "Albums - by Tracks", - "listingPage.listAlbums.byTracks.title.short": "...by Tracks", - "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byDuration.title": "Albums - by Duration", - "listingPage.listAlbums.byDuration.title.short": "...by Duration", - "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})", - "listingPage.listAlbums.byDate.title": "Albums - by Date", - "listingPage.listAlbums.byDate.title.short": "...by Date", - "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", - "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.date": "{DATE}", - "listingPage.listAlbums.byDateAdded.album": "{ALBUM}", - "listingPage.listArtists.byName.title": "Artists - by Name", - "listingPage.listArtists.byName.title.short": "...by Name", - "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byContribs.title": "Artists - by Contributions", - "listingPage.listArtists.byContribs.title.short": "...by Contributions", - "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries", - "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries", - "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", - "listingPage.listArtists.byDuration.title": "Artists - by Duration", - "listingPage.listArtists.byDuration.title.short": "...by Duration", - "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", - "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", - "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", - "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})", - "listingPage.listGroups.byName.title": "Groups - by Name", - "listingPage.listGroups.byName.title.short": "...by Name", - "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byName.item.gallery": "Gallery", - "listingPage.listGroups.byCategory.title": "Groups - by Category", - "listingPage.listGroups.byCategory.title.short": "...by Category", - "listingPage.listGroups.byCategory.category": "{CATEGORY}", - "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byCategory.group.gallery": "Gallery", - "listingPage.listGroups.byAlbums.title": "Groups - by Albums", - "listingPage.listGroups.byAlbums.title.short": "...by Albums", - "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", - "listingPage.listGroups.byTracks.title": "Groups - by Tracks", - "listingPage.listGroups.byTracks.title.short": "...by Tracks", - "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})", - "listingPage.listGroups.byDuration.title": "Groups - by Duration", - "listingPage.listGroups.byDuration.title.short": "...by Duration", - "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})", - "listingPage.listGroups.byLatest.title": "Groups - by Latest Album", - "listingPage.listGroups.byLatest.title.short": "...by Latest Album", - "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})", - "listingPage.listTracks.byName.title": "Tracks - by Name", - "listingPage.listTracks.byName.title.short": "...by Name", - "listingPage.listTracks.byName.item": "{TRACK}", - "listingPage.listTracks.byAlbum.title": "Tracks - by Album", - "listingPage.listTracks.byAlbum.title.short": "...by Album", - "listingPage.listTracks.byAlbum.album": "{ALBUM}", - "listingPage.listTracks.byAlbum.track": "{TRACK}", - "listingPage.listTracks.byDate.title": "Tracks - by Date", - "listingPage.listTracks.byDate.title.short": "...by Date", - "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})", - "listingPage.listTracks.byDate.track": "{TRACK}", - "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)", - "listingPage.listTracks.byDuration.title": "Tracks - by Duration", - "listingPage.listTracks.byDuration.title.short": "...by Duration", - "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})", - "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}", - "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})", - "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced", - "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced", - "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})", - "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})", - "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})", - "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})", - "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})", - "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics", - "listingPage.listTracks.withLyrics.title.short": "...with Lyrics", - "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})", - "listingPage.listTracks.withLyrics.track": "{TRACK}", - "listingPage.listTags.byName.title": "Tags - by Name", - "listingPage.listTags.byName.title.short": "...by Name", - "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})", - "listingPage.listTags.byUses.title": "Tags - by Uses", - "listingPage.listTags.byUses.title.short": "...by Uses", - "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})", - "listingPage.other.randomPages.title": "Random Pages", - "listingPage.other.randomPages.title.short": "Random Pages", - "listingPage.misc.trackContributors": "Track Contributors", - "listingPage.misc.artContributors": "Art Contributors", - "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", - "newsIndex.title": "News", - "newsIndex.entry.viewRest": "(View rest of entry!)", - "newsEntryPage.title": "{ENTRY}", - "newsEntryPage.published": "(Published {DATE}.)", - "newsEntryPage.nav.news": "News", - "newsEntryPage.nav.entry": "{DATE}: {ENTRY}", - "redirectPage.title": "Moved to {TITLE}", - "redirectPage.infoLine": "This page has been moved to {TARGET}.", - "tagPage.title": "{TAG}", - "tagPage.infoLine": "Appears in {COVER_ARTS}.", - "tagPage.nav.tag": "Tag: {TAG}", - "trackPage.title": "{TRACK}", - "trackPage.referenceList.fandom": "Fandom:", - "trackPage.referenceList.official": "Official:", - "trackPage.nav.track": "{TRACK}", - "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", - "trackPage.nav.random": "Random", - "trackPage.socialEmbed.heading": "{ALBUM}", - "trackPage.socialEmbed.title": "{TRACK}", - "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.", - "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.", - "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}." + "meta.languageCode": "en", + "meta.languageName": "English", + "count.tracks": "{TRACKS}", + "count.tracks.withUnit.zero": "", + "count.tracks.withUnit.one": "{TRACKS} track", + "count.tracks.withUnit.two": "", + "count.tracks.withUnit.few": "", + "count.tracks.withUnit.many": "", + "count.tracks.withUnit.other": "{TRACKS} tracks", + "count.additionalFiles": "{FILES}", + "count.additionalFiles.withUnit.zero": "", + "count.additionalFiles.withUnit.one": "{FILES} additional file", + "count.additionalFiles.withUnit.two": "", + "count.additionalFiles.withUnit.few": "", + "count.additionalFiles.withUnit.many": "", + "count.additionalFiles.withUnit.other": "{FILES} additional files", + "count.albums": "{ALBUMS}", + "count.albums.withUnit.zero": "", + "count.albums.withUnit.one": "{ALBUMS} album", + "count.albums.withUnit.two": "", + "count.albums.withUnit.two": "", + "count.albums.withUnit.few": "", + "count.albums.withUnit.many": "", + "count.albums.withUnit.other": "{ALBUMS} albums", + "count.commentaryEntries": "{ENTRIES}", + "count.commentaryEntries.withUnit.zero": "", + "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", + "count.commentaryEntries.withUnit.two": "", + "count.commentaryEntries.withUnit.few": "", + "count.commentaryEntries.withUnit.many": "", + "count.commentaryEntries.withUnit.other": "{ENTRIES} entries", + "count.contributions": "{CONTRIBUTIONS}", + "count.contributions.withUnit.zero": "", + "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution", + "count.contributions.withUnit.two": "", + "count.contributions.withUnit.few": "", + "count.contributions.withUnit.many": "", + "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions", + "count.coverArts": "{COVER_ARTS}", + "count.coverArts.withUnit.zero": "", + "count.coverArts.withUnit.one": "{COVER_ARTS} cover art", + "count.coverArts.withUnit.two": "", + "count.coverArts.withUnit.few": "", + "count.coverArts.withUnit.many": "", + "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", + "count.timesReferenced": "{TIMES_REFERENCED}", + "count.timesReferenced.withUnit.zero": "", + "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", + "count.timesReferenced.withUnit.two": "", + "count.timesReferenced.withUnit.few": "", + "count.timesReferenced.withUnit.many": "", + "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced", + "count.words": "{WORDS}", + "count.words.thousand": "{WORDS}k", + "count.words.withUnit.zero": "", + "count.words.withUnit.one": "{WORDS} word", + "count.words.withUnit.two": "", + "count.words.withUnit.few": "", + "count.words.withUnit.many": "", + "count.words.withUnit.other": "{WORDS} words", + "count.timesUsed": "{TIMES_USED}", + "count.timesUsed.withUnit.zero": "", + "count.timesUsed.withUnit.one": "used {TIMES_USED} time", + "count.timesUsed.withUnit.two": "", + "count.timesUsed.withUnit.few": "", + "count.timesUsed.withUnit.many": "", + "count.timesUsed.withUnit.other": "used {TIMES_USED} times", + "count.index.zero": "", + "count.index.one": "{INDEX}st", + "count.index.two": "{INDEX}nd", + "count.index.few": "{INDEX}rd", + "count.index.many": "", + "count.index.other": "{INDEX}th", + "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}", + "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours", + "count.duration.minutes": "{MINUTES}:{SECONDS}", + "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes", + "count.duration.approximate": "~{DURATION}", + "count.duration.missing": "_:__", + "count.fileSize.terabytes": "{TERABYTES} TB", + "count.fileSize.gigabytes": "{GIGABYTES} GB", + "count.fileSize.megabytes": "{MEGABYTES} MB", + "count.fileSize.kilobytes": "{KILOBYTES} kB", + "count.fileSize.bytes": "{BYTES} bytes", + "releaseInfo.by": "By {ARTISTS}.", + "releaseInfo.from": "From {ALBUM}.", + "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", + "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", + "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.", + "releaseInfo.released": "Released {DATE}.", + "releaseInfo.artReleased": "Art released {DATE}.", + "releaseInfo.addedToWiki": "Added to wiki {DATE}.", + "releaseInfo.duration": "Duration: {DURATION}.", + "releaseInfo.viewCommentary": "View {LINK}!", + "releaseInfo.viewCommentary.link": "commentary page", + "releaseInfo.listenOn": "Listen on {LINKS}.", + "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.", + "releaseInfo.visitOn": "Visit on {LINKS}.", + "releaseInfo.playOn": "Play on {LINKS}.", + "releaseInfo.alsoReleasedAs": "Also released as:", + "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", + "releaseInfo.contributors": "Contributors:", + "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:", + "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:", + "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:", + "releaseInfo.flashesThatFeature.item": "{FLASH}", + "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})", + "releaseInfo.lyrics": "Lyrics:", + "releaseInfo.artistCommentary": "Artist commentary:", + "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", + "releaseInfo.artTags": "Tags:", + "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}", + "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:", + "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:", + "releaseInfo.additionalFiles.entry": "{TITLE}", + "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}", + "releaseInfo.additionalFiles.file": "{FILE}", + "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})", + "releaseInfo.note": "Note:", + "trackList.section.withDuration": "{SECTION} ({DURATION}):", + "trackList.group": "{GROUP}:", + "trackList.group.other": "Other", + "trackList.item.withDuration": "({DURATION}) {TRACK}", + "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", + "trackList.item.withArtists": "{TRACK} {BY}", + "trackList.item.withArtists.by": "by {ARTISTS}", + "trackList.item.rerelease": "{TRACK} (re-release)", + "misc.alt.albumCover": "album cover", + "misc.alt.albumBanner": "album banner", + "misc.alt.trackCover": "track cover", + "misc.alt.artistAvatar": "artist avatar", + "misc.alt.flashArt": "flash art", + "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)", + "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}", + "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}", + "misc.chronology.heading.track": "{INDEX} track by {ARTIST}", + "misc.external.domain": "External ({DOMAIN})", + "misc.external.local": "Wiki Archive (local upload)", + "misc.external.bandcamp": "Bandcamp", + "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})", + "misc.external.deviantart": "DeviantArt", + "misc.external.instagram": "Instagram", + "misc.external.mastodon": "Mastodon", + "misc.external.mastodon.domain": "Mastodon ({DOMAIN})", + "misc.external.patreon": "Patreon", + "misc.external.poetryFoundation": "Poetry Foundation", + "misc.external.soundcloud": "SoundCloud", + "misc.external.tumblr": "Tumblr", + "misc.external.twitter": "Twitter", + "misc.external.wikipedia": "Wikipedia", + "misc.external.youtube": "YouTube", + "misc.external.youtube.playlist": "YouTube (playlist)", + "misc.external.youtube.fullAlbum": "YouTube (full album)", + "misc.external.flash.bgreco": "{LINK} (HQ Audio)", + "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})", + "misc.external.flash.homestuck.secret": "{LINK} (secret page)", + "misc.external.flash.youtube": "{LINK} (on any device)", + "misc.nav.previous": "Previous", + "misc.nav.next": "Next", + "misc.nav.info": "Info", + "misc.nav.gallery": "Gallery", + "misc.pageTitle": "{TITLE}", + "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}", + "misc.skippers.skipToContent": "Skip to content", + "misc.skippers.skipToSidebar": "Skip to sidebar", + "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)", + "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)", + "misc.skippers.skipToFooter": "Skip to footer", + "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}", + "misc.jumpTo": "Jump to:", + "misc.jumpTo.withLinks": "Jump to: {LINKS}.", + "misc.contentWarnings": "cw: {WARNINGS}", + "misc.contentWarnings.reveal": "click to show", + "misc.albumGrid.details": "({TRACKS}, {TIME})", + "misc.albumGrid.noCoverArt": "{ALBUM}", + "misc.uiLanguage": "UI Language: {LANGUAGES}", + "homepage.title": "{TITLE}", + "homepage.news.title": "News", + "homepage.news.entry.viewRest": "(View rest of entry!)", + "albumSidebar.trackList.fallbackGroupName": "Track list", + "albumSidebar.trackList.group": "{GROUP}", + "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", + "albumSidebar.trackList.item": "{TRACK}", + "albumSidebar.groupBox.title": "{GROUP}", + "albumSidebar.groupBox.next": "Next: {ALBUM}", + "albumSidebar.groupBox.previous": "Previous: {ALBUM}", + "albumPage.title": "{ALBUM}", + "albumPage.nav.album": "{ALBUM}", + "albumPage.nav.randomTrack": "Random Track", + "albumCommentaryPage.title": "{ALBUM} - Commentary", + "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.", + "albumCommentaryPage.nav.album": "Album: {ALBUM}", + "albumCommentaryPage.entry.title.albumCommentary": "Album commentary", + "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}", + "artistPage.title": "{ARTIST}", + "artistPage.creditList.album": "{ALBUM}", + "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})", + "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})", + "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", + "artistPage.creditList.flashAct": "{ACT}", + "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", + "artistPage.creditList.entry.track": "{TRACK}", + "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", + "artistPage.creditList.entry.album.coverArt": "(cover art)", + "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)", + "artistPage.creditList.entry.album.bannerArt": "(banner art)", + "artistPage.creditList.entry.album.commentary": "(album commentary)", + "artistPage.creditList.entry.flash": "{FLASH}", + "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)", + "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})", + "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})", + "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})", + "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", + "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}", + "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", + "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})", + "artistPage.trackList.title": "Tracks", + "artistPage.artList.title": "Art", + "artistPage.flashList.title": "Flashes & Games", + "artistPage.commentaryList.title": "Commentary", + "artistPage.viewArtGallery": "View {LINK}!", + "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:", + "artistPage.viewArtGallery.link": "art gallery", + "artistPage.nav.artist": "Artist: {ARTIST}", + "artistGalleryPage.title": "{ARTIST} - Gallery", + "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.", + "commentaryIndex.title": "Commentary", + "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.", + "commentaryIndex.albumList.title": "Choose an album:", + "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})", + "flashIndex.title": "Flashes & Games", + "flashPage.title": "{FLASH}", + "flashPage.nav.flash": "{FLASH}", + "groupSidebar.title": "Groups", + "groupSidebar.groupList.category": "{CATEGORY}", + "groupSidebar.groupList.item": "{GROUP}", + "groupPage.nav.group": "Group: {GROUP}", + "groupInfoPage.title": "{GROUP}", + "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:", + "groupInfoPage.viewAlbumGallery.link": "album gallery", + "groupInfoPage.albumList.title": "Albums", + "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}", + "groupInfoPage.albumList.item.withoutYear": "{ALBUM}", + "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}", + "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})", + "groupGalleryPage.title": "{GROUP} - Gallery", + "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.", + "groupGalleryPage.anotherGroupLine": "({LINK})", + "groupGalleryPage.anotherGroupLine.link": "Choose another group to filter by!", + "listingIndex.title": "Listings", + "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.", + "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!", + "listingPage.target.album": "Albums", + "listingPage.target.artist": "Artists", + "listingPage.target.group": "Groups", + "listingPage.target.track": "Tracks", + "listingPage.target.tag": "Tags", + "listingPage.target.other": "Other", + "listingPage.listAlbums.byName.title": "Albums - by Name", + "listingPage.listAlbums.byName.title.short": "...by Name", + "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", + "listingPage.listAlbums.byTracks.title": "Albums - by Tracks", + "listingPage.listAlbums.byTracks.title.short": "...by Tracks", + "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})", + "listingPage.listAlbums.byDuration.title": "Albums - by Duration", + "listingPage.listAlbums.byDuration.title.short": "...by Duration", + "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})", + "listingPage.listAlbums.byDate.title": "Albums - by Date", + "listingPage.listAlbums.byDate.title.short": "...by Date", + "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", + "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki", + "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", + "listingPage.listAlbums.byDateAdded.date": "{DATE}", + "listingPage.listAlbums.byDateAdded.album": "{ALBUM}", + "listingPage.listArtists.byName.title": "Artists - by Name", + "listingPage.listArtists.byName.title.short": "...by Name", + "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byContribs.title": "Artists - by Contributions", + "listingPage.listArtists.byContribs.title.short": "...by Contributions", + "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries", + "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries", + "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", + "listingPage.listArtists.byDuration.title": "Artists - by Duration", + "listingPage.listArtists.byDuration.title.short": "...by Duration", + "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", + "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", + "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", + "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})", + "listingPage.listGroups.byName.title": "Groups - by Name", + "listingPage.listGroups.byName.title.short": "...by Name", + "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", + "listingPage.listGroups.byName.item.gallery": "Gallery", + "listingPage.listGroups.byCategory.title": "Groups - by Category", + "listingPage.listGroups.byCategory.title.short": "...by Category", + "listingPage.listGroups.byCategory.category": "{CATEGORY}", + "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})", + "listingPage.listGroups.byCategory.group.gallery": "Gallery", + "listingPage.listGroups.byAlbums.title": "Groups - by Albums", + "listingPage.listGroups.byAlbums.title.short": "...by Albums", + "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", + "listingPage.listGroups.byTracks.title": "Groups - by Tracks", + "listingPage.listGroups.byTracks.title.short": "...by Tracks", + "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})", + "listingPage.listGroups.byDuration.title": "Groups - by Duration", + "listingPage.listGroups.byDuration.title.short": "...by Duration", + "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})", + "listingPage.listGroups.byLatest.title": "Groups - by Latest Album", + "listingPage.listGroups.byLatest.title.short": "...by Latest Album", + "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})", + "listingPage.listTracks.byName.title": "Tracks - by Name", + "listingPage.listTracks.byName.title.short": "...by Name", + "listingPage.listTracks.byName.item": "{TRACK}", + "listingPage.listTracks.byAlbum.title": "Tracks - by Album", + "listingPage.listTracks.byAlbum.title.short": "...by Album", + "listingPage.listTracks.byAlbum.album": "{ALBUM}", + "listingPage.listTracks.byAlbum.track": "{TRACK}", + "listingPage.listTracks.byDate.title": "Tracks - by Date", + "listingPage.listTracks.byDate.title.short": "...by Date", + "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.byDate.track": "{TRACK}", + "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)", + "listingPage.listTracks.byDuration.title": "Tracks - by Duration", + "listingPage.listTracks.byDuration.title.short": "...by Duration", + "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})", + "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)", + "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)", + "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}", + "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})", + "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced", + "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced", + "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})", + "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)", + "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)", + "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})", + "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)", + "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)", + "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})", + "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})", + "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics", + "listingPage.listTracks.withLyrics.title.short": "...with Lyrics", + "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.withLyrics.track": "{TRACK}", + "listingPage.listTags.byName.title": "Tags - by Name", + "listingPage.listTags.byName.title.short": "...by Name", + "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})", + "listingPage.listTags.byUses.title": "Tags - by Uses", + "listingPage.listTags.byUses.title.short": "...by Uses", + "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})", + "listingPage.other.randomPages.title": "Random Pages", + "listingPage.other.randomPages.title.short": "Random Pages", + "listingPage.misc.trackContributors": "Track Contributors", + "listingPage.misc.artContributors": "Art Contributors", + "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", + "newsIndex.title": "News", + "newsIndex.entry.viewRest": "(View rest of entry!)", + "newsEntryPage.title": "{ENTRY}", + "newsEntryPage.published": "(Published {DATE}.)", + "newsEntryPage.nav.news": "News", + "newsEntryPage.nav.entry": "{DATE}: {ENTRY}", + "redirectPage.title": "Moved to {TITLE}", + "redirectPage.infoLine": "This page has been moved to {TARGET}.", + "tagPage.title": "{TAG}", + "tagPage.infoLine": "Appears in {COVER_ARTS}.", + "tagPage.nav.tag": "Tag: {TAG}", + "trackPage.title": "{TRACK}", + "trackPage.referenceList.fandom": "Fandom:", + "trackPage.referenceList.official": "Official:", + "trackPage.nav.track": "{TRACK}", + "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", + "trackPage.nav.random": "Random", + "trackPage.socialEmbed.heading": "{ALBUM}", + "trackPage.socialEmbed.title": "{TRACK}", + "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.", + "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.", + "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}." } diff --git a/src/upd8.js b/src/upd8.js index d9bca28f..b2d5846c 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +/** @format */ // HEY N8RDS! // @@ -32,8 +33,7 @@ // node.js and you'll 8e fine. ...Within the project root. O8viously. import * as path from 'path'; -import { promisify } from 'util'; -import { fileURLToPath } from 'url'; +import {fileURLToPath} from 'url'; // I made this dependency myself! A long, long time ago. It is pro8a8ly my // most useful li8rary ever. I'm not sure 8esides me actually uses it, though. @@ -45,131 +45,104 @@ import fixWS from 'fix-whitespace'; import he from 'he'; import { - copyFile, - mkdir, - readFile, - stat, - symlink, - writeFile, - unlink, + copyFile, + mkdir, + readFile, + stat, + symlink, + writeFile, + unlink, } from 'fs/promises'; -import { inspect as nodeInspect } from 'util'; - import genThumbs from './gen-thumbs.js'; -import { listingSpec, listingTargetSpec } from './listing-spec.js'; +import {listingSpec, listingTargetSpec} from './listing-spec.js'; import urlSpec from './url-spec.js'; import * as pageSpecs from './page/index.js'; -import find, { bindFind } from './util/find.js'; +import find, {bindFind} from './util/find.js'; import * as html from './util/html.js'; import unbound_link, {getLinkThemeString} from './util/link.js'; -import { findFiles } from './util/io.js'; +import {findFiles} from './util/io.js'; import CacheableObject from './data/cacheable-object.js'; -import { serializeThings } from './data/serialize.js'; +import {serializeThings} from './data/serialize.js'; -import { - Language, -} from './data/things.js'; +import {Language} from './data/things.js'; import { - filterDuplicateDirectories, - filterReferenceErrors, - linkWikiDataArrays, - loadAndProcessDataDocuments, - sortWikiDataArrays, - WIKI_INFO_FILE, + filterDuplicateDirectories, + filterReferenceErrors, + linkWikiDataArrays, + loadAndProcessDataDocuments, + sortWikiDataArrays, + WIKI_INFO_FILE, } from './data/yaml.js'; import { - fancifyFlashURL, - fancifyURL, - generateAdditionalFilesShortcut, - generateAdditionalFilesList, - generateChronologyLinks, - generateCoverLink, - generateInfoGalleryLinks, - generatePreviousNextLinks, - generateTrackListDividedByGroups, - getAlbumGridHTML, - getAlbumStylesheet, - getArtistString, - getFlashGridHTML, - getFooterLocalizationLinks, - getGridHTML, - getRevealStringFromTags, - getRevealStringFromWarnings, - getThemeString, - iconifyURL + fancifyFlashURL, + fancifyURL, + generateAdditionalFilesShortcut, + generateAdditionalFilesList, + generateChronologyLinks, + generateCoverLink, + generateInfoGalleryLinks, + generatePreviousNextLinks, + generateTrackListDividedByGroups, + getAlbumGridHTML, + getAlbumStylesheet, + getArtistString, + getFlashGridHTML, + getFooterLocalizationLinks, + getGridHTML, + getRevealStringFromTags, + getRevealStringFromWarnings, + getThemeString, + iconifyURL, } from './misc-templates.js'; import { - color, - decorateTime, - logWarn, - logInfo, - logError, - parseOptions, - progressPromiseAll, - ENABLE_COLOR + color, + decorateTime, + logWarn, + logInfo, + logError, + parseOptions, + progressPromiseAll, } from './util/cli.js'; -import { - validateReplacerSpec, - transformInline -} from './util/replacer.js'; +import {validateReplacerSpec, transformInline} from './util/replacer.js'; import { - chunkByConditions, - chunkByProperties, - getAlbumCover, - getAlbumListTag, - getAllTracks, - getArtistAvatar, - getArtistNumContributions, - getFlashCover, - getKebabCase, - getTotalDuration, - getTrackCover, + getAlbumCover, + getArtistAvatar, + getFlashCover, + getTrackCover, } from './util/wiki-data.js'; +/* import { - serializeContribs, - serializeCover, - serializeGroupsForAlbum, - serializeGroupsForTrack, - serializeImagePaths, - serializeLink + serializeContribs, + serializeCover, + serializeGroupsForAlbum, + serializeGroupsForTrack, + serializeImagePaths, + serializeLink, } from './util/serialize.js'; +*/ import { - bindOpts, - decorateErrorWithIndex, - filterAggregateAsync, - filterEmptyLines, - mapAggregate, - mapAggregateAsync, - openAggregate, - queue, - showAggregate, - splitArray, - unique, - withAggregate, - withEntries + bindOpts, + filterEmptyLines, + queue, + showAggregate, + withEntries, } from './util/sugar.js'; -import { - generateURLs, - thumb -} from './util/urls.js'; +import {generateURLs, thumb} from './util/urls.js'; // Pensive emoji! -import { - FANDOM_GROUP_DIRECTORY, - OFFICIAL_GROUP_DIRECTORY -} from './util/magic-constants.js'; +import { OFFICIAL_GROUP_DIRECTORY } from './util/magic-constants.js'; import FileSizePreloader from './file-size-preloader.js'; @@ -198,10 +171,6 @@ const OEMBED_JSON_FILE = 'oembed.json'; // Automatically copied (if present) from media directory to site root. const FAVICON_FILE = 'favicon.ico'; -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); -} - // Shared varia8les! These are more efficient to access than a shared varia8le // (or at least I h8pe so), and are easier to pass across functions than a // 8unch of specific arguments. @@ -223,556 +192,610 @@ let queueSize; const urls = generateURLs(urlSpec); function splitLines(text) { - return text.split(/\r\n|\r|\n/); + return text.split(/\r\n|\r|\n/); } const replacerSpec = { - 'album': { - find: 'album', - link: 'album' - }, - 'album-commentary': { - find: 'album', - link: 'albumCommentary' - }, - 'artist': { - find: 'artist', - link: 'artist' - }, - 'artist-gallery': { - find: 'artist', - link: 'artistGallery' - }, - 'commentary-index': { - find: null, - link: 'commentaryIndex' - }, - 'date': { - find: null, - value: ref => new Date(ref), - html: (date, {language}) => `<time datetime="${date.toString()}">${language.formatDate(date)}</time>` - }, - 'flash': { - find: 'flash', - link: 'flash', - transformName(name, node, input) { - const nextCharacter = input[node.iEnd]; - const lastCharacter = name[name.length - 1]; - if ( - ![' ', '\n', '<'].includes(nextCharacter) && - lastCharacter === '.' - ) { - return name.slice(0, -1); - } else { - return name; - } - } - }, - 'group': { - find: 'group', - link: 'groupInfo' - }, - 'group-gallery': { - find: 'group', - link: 'groupGallery' - }, - 'home': { - find: null, - link: 'home' - }, - 'listing-index': { - find: null, - link: 'listingIndex' - }, - 'listing': { - find: 'listing', - link: 'listing' - }, - 'media': { - find: null, - link: 'media' - }, - 'news-index': { - find: null, - link: 'newsIndex' - }, - 'news-entry': { - find: 'newsEntry', - link: 'newsEntry' + album: { + find: 'album', + link: 'album', + }, + 'album-commentary': { + find: 'album', + link: 'albumCommentary', + }, + artist: { + find: 'artist', + link: 'artist', + }, + 'artist-gallery': { + find: 'artist', + link: 'artistGallery', + }, + 'commentary-index': { + find: null, + link: 'commentaryIndex', + }, + date: { + find: null, + value: (ref) => new Date(ref), + html: (date, {language}) => + `<time datetime="${date.toString()}">${language.formatDate(date)}</time>`, + }, + flash: { + find: 'flash', + link: 'flash', + transformName(name, node, input) { + const nextCharacter = input[node.iEnd]; + const lastCharacter = name[name.length - 1]; + if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { + return name.slice(0, -1); + } else { + return name; + } }, - 'root': { - find: null, - link: 'root' - }, - 'site': { - find: null, - link: 'site' - }, - 'static': { - find: 'staticPage', - link: 'staticPage' - }, - 'string': { - find: null, - value: ref => ref, - html: (ref, {language, args}) => language.$(ref, args) - }, - 'tag': { - find: 'artTag', - link: 'tag' - }, - 'track': { - find: 'track', - link: 'track' - } + }, + group: { + find: 'group', + link: 'groupInfo', + }, + 'group-gallery': { + find: 'group', + link: 'groupGallery', + }, + home: { + find: null, + link: 'home', + }, + 'listing-index': { + find: null, + link: 'listingIndex', + }, + listing: { + find: 'listing', + link: 'listing', + }, + media: { + find: null, + link: 'media', + }, + 'news-index': { + find: null, + link: 'newsIndex', + }, + 'news-entry': { + find: 'newsEntry', + link: 'newsEntry', + }, + root: { + find: null, + link: 'root', + }, + site: { + find: null, + link: 'site', + }, + static: { + find: 'staticPage', + link: 'staticPage', + }, + string: { + find: null, + value: (ref) => ref, + html: (ref, {language, args}) => language.$(ref, args), + }, + tag: { + find: 'artTag', + link: 'tag', + }, + track: { + find: 'track', + link: 'track', + }, }; if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) { - process.exit(); + process.exit(); } function parseAttributes(string, {to}) { - const attributes = Object.create(null); - const skipWhitespace = i => { - const ws = /\s/; - if (ws.test(string[i])) { - const match = string.slice(i).match(/[^\s]/); - if (match) { - return i + match.index; - } else { - return string.length; - } - } else { - return i; - } - }; - - for (let i = 0; i < string.length;) { - i = skipWhitespace(i); - const aStart = i; - const aEnd = i + string.slice(i).match(/[\s=]|$/).index; - const attribute = string.slice(aStart, aEnd); - i = skipWhitespace(aEnd); - if (string[i] === '=') { - i = skipWhitespace(i + 1); - let end, endOffset; - if (string[i] === '"' || string[i] === "'") { - end = string[i]; - endOffset = 1; - i++; - } else { - end = '\\s'; - endOffset = 0; - } - const vStart = i; - const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; - const value = string.slice(vStart, vEnd); - i = vEnd + endOffset; - if (attribute === 'src' && value.startsWith('media/')) { - attributes[attribute] = to('media.path', value.slice('media/'.length)); - } else { - attributes[attribute] = value; - } - } else { - attributes[attribute] = attribute; - } + const attributes = Object.create(null); + const skipWhitespace = (i) => { + const ws = /\s/; + if (ws.test(string[i])) { + const match = string.slice(i).match(/[^\s]/); + if (match) { + return i + match.index; + } else { + return string.length; + } + } else { + return i; } - return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [ - key, - val === 'true' ? true : - val === 'false' ? false : - val === key ? true : - val - ])); + }; + + for (let i = 0; i < string.length; ) { + i = skipWhitespace(i); + const aStart = i; + const aEnd = i + string.slice(i).match(/[\s=]|$/).index; + const attribute = string.slice(aStart, aEnd); + i = skipWhitespace(aEnd); + if (string[i] === '=') { + i = skipWhitespace(i + 1); + let end, endOffset; + if (string[i] === '"' || string[i] === "'") { + end = string[i]; + endOffset = 1; + i++; + } else { + end = '\\s'; + endOffset = 0; + } + const vStart = i; + const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; + const value = string.slice(vStart, vEnd); + i = vEnd + endOffset; + if (attribute === 'src' && value.startsWith('media/')) { + attributes[attribute] = to('media.path', value.slice('media/'.length)); + } else { + attributes[attribute] = value; + } + } else { + attributes[attribute] = attribute; + } + } + return Object.fromEntries( + Object.entries(attributes).map(([key, val]) => [ + key, + val === 'true' + ? true + : val === 'false' + ? false + : val === key + ? true + : val, + ]) + ); } function joinLineBreaks(sourceLines) { - const outLines = []; - - let lineSoFar = ''; - for (let i = 0; i < sourceLines.length; i++) { - const line = sourceLines[i]; - lineSoFar += line; - if (!line.endsWith('<br>')) { - outLines.push(lineSoFar); - lineSoFar = ''; - } + const outLines = []; + + let lineSoFar = ''; + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i]; + lineSoFar += line; + if (!line.endsWith('<br>')) { + outLines.push(lineSoFar); + lineSoFar = ''; } + } - if (lineSoFar) { - outLines.push(lineSoFar); - } + if (lineSoFar) { + outLines.push(lineSoFar); + } - return outLines; + return outLines; } -function transformMultiline(text, { - parseAttributes, - transformInline -}) { - // Heck yes, HTML magics. - - text = transformInline(text.trim()); - - const outLines = []; - - const indentString = ' '.repeat(4); - - let levelIndents = []; - const openLevel = indent => { - // opening a sublist is a pain: to be semantically *and* visually - // correct, we have to append the <ul> at the end of the existing - // previous <li> - const previousLine = outLines[outLines.length - 1]; - if (previousLine?.endsWith('</li>')) { - // we will re-close the <li> later - outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>'; - } else { - // if the previous line isn't a list item, this is the opening of - // the first list level, so no need for indent - outLines.push('<ul>'); - } - levelIndents.push(indent); - }; - const closeLevel = () => { - levelIndents.pop(); - if (levelIndents.length) { - // closing a sublist, so close the list item containing it too - outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>'); - } else { - // closing the final list level! no need for indent here - outLines.push('</ul>'); - } - }; +function transformMultiline(text, {parseAttributes, transformInline}) { + // Heck yes, HTML magics. - // okay yes we should support nested formatting, more than one blockquote - // layer, etc, but hear me out here: making all that work would basically - // be the same as implementing an entire markdown converter, which im not - // interested in doing lol. sorry!!! - let inBlockquote = false; - - let lines = splitLines(text); - lines = joinLineBreaks(lines); - for (let line of lines) { - const imageLine = line.startsWith('<img'); - line = line.replace(/<img (.*?)>/g, (match, attributes) => img({ - lazy: true, - link: true, - thumb: 'medium', - ...parseAttributes(attributes) - })); - - let indentThisLine = 0; - let lineContent = line; - let lineTag = 'p'; - - const listMatch = line.match(/^( *)- *(.*)$/); - if (listMatch) { - // is a list item! - if (!levelIndents.length) { - // first level is always indent = 0, regardless of actual line - // content (this is to avoid going to a lesser indent than the - // initial level) - openLevel(0); - } else { - // find level corresponding to indent - const indent = listMatch[1].length; - let i; - for (i = levelIndents.length - 1; i >= 0; i--) { - if (levelIndents[i] <= indent) break; - } - // note: i cannot equal -1 because the first indentation level - // is always 0, and the minimum indentation is also 0 - if (levelIndents[i] === indent) { - // same indent! return to that level - while (levelIndents.length - 1 > i) closeLevel(); - // (if this is already the current level, the above loop - // will do nothing) - } else if (levelIndents[i] < indent) { - // lesser indent! branch based on index - if (i === levelIndents.length - 1) { - // top level is lesser: add a new level - openLevel(indent); - } else { - // lower level is lesser: return to that level - while (levelIndents.length - 1 > i) closeLevel(); - } - } - } - // finally, set variables for appending content line - indentThisLine = levelIndents.length; - lineContent = listMatch[2]; - lineTag = 'li'; - } else { - // not a list item! close any existing list levels - while (levelIndents.length) closeLevel(); - - // like i said, no nested shenanigans - quotes only appear outside - // of lists. sorry! - const quoteMatch = line.match(/^> *(.*)$/); - if (quoteMatch) { - // is a quote! open a blockquote tag if it doesnt already exist - if (!inBlockquote) { - inBlockquote = true; - outLines.push('<blockquote>'); - } - indentThisLine = 1; - lineContent = quoteMatch[1]; - } else if (inBlockquote) { - // not a quote! close a blockquote tag if it exists - inBlockquote = false; - outLines.push('</blockquote>'); - } + text = transformInline(text.trim()); - // let some escaped symbols display as the normal symbol, since the - // point of escaping them is just to avoid having them be treated as - // syntax markers! - if (lineContent.match(/( *)\\-/)) { - lineContent = lineContent.replace('\\-', '-'); - } else if (lineContent.match(/( *)\\>/)) { - lineContent = lineContent.replace('\\>', '>'); - } - } + const outLines = []; - if (lineTag === 'p') { - // certain inline element tags should still be postioned within a - // paragraph; other elements (e.g. headings) should be added as-is - const elementMatch = line.match(/^<(.*?)[ >]/); - if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) { - lineTag = ''; - } - } + const indentString = ' '.repeat(4); - let pushString = indentString.repeat(indentThisLine); - if (lineTag) { - pushString += `<${lineTag}>${lineContent}</${lineTag}>`; - } else { - pushString += lineContent; - } - outLines.push(pushString); + let levelIndents = []; + const openLevel = (indent) => { + // opening a sublist is a pain: to be semantically *and* visually + // correct, we have to append the <ul> at the end of the existing + // previous <li> + const previousLine = outLines[outLines.length - 1]; + if (previousLine?.endsWith('</li>')) { + // we will re-close the <li> later + outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>'; + } else { + // if the previous line isn't a list item, this is the opening of + // the first list level, so no need for indent + outLines.push('<ul>'); } + levelIndents.push(indent); + }; + const closeLevel = () => { + levelIndents.pop(); + if (levelIndents.length) { + // closing a sublist, so close the list item containing it too + outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>'); + } else { + // closing the final list level! no need for indent here + outLines.push('</ul>'); + } + }; + + // okay yes we should support nested formatting, more than one blockquote + // layer, etc, but hear me out here: making all that work would basically + // be the same as implementing an entire markdown converter, which im not + // interested in doing lol. sorry!!! + let inBlockquote = false; + + let lines = splitLines(text); + lines = joinLineBreaks(lines); + for (let line of lines) { + const imageLine = line.startsWith('<img'); + line = line.replace(/<img (.*?)>/g, (match, attributes) => + img({ + lazy: true, + link: true, + thumb: 'medium', + ...parseAttributes(attributes), + }) + ); - // after processing all lines... - - // if still in a list, close all levels - while (levelIndents.length) closeLevel(); - - // if still in a blockquote, close its tag - if (inBlockquote) { + let indentThisLine = 0; + let lineContent = line; + let lineTag = 'p'; + + const listMatch = line.match(/^( *)- *(.*)$/); + if (listMatch) { + // is a list item! + if (!levelIndents.length) { + // first level is always indent = 0, regardless of actual line + // content (this is to avoid going to a lesser indent than the + // initial level) + openLevel(0); + } else { + // find level corresponding to indent + const indent = listMatch[1].length; + let i; + for (i = levelIndents.length - 1; i >= 0; i--) { + if (levelIndents[i] <= indent) break; + } + // note: i cannot equal -1 because the first indentation level + // is always 0, and the minimum indentation is also 0 + if (levelIndents[i] === indent) { + // same indent! return to that level + while (levelIndents.length - 1 > i) closeLevel(); + // (if this is already the current level, the above loop + // will do nothing) + } else if (levelIndents[i] < indent) { + // lesser indent! branch based on index + if (i === levelIndents.length - 1) { + // top level is lesser: add a new level + openLevel(indent); + } else { + // lower level is lesser: return to that level + while (levelIndents.length - 1 > i) closeLevel(); + } + } + } + // finally, set variables for appending content line + indentThisLine = levelIndents.length; + lineContent = listMatch[2]; + lineTag = 'li'; + } else { + // not a list item! close any existing list levels + while (levelIndents.length) closeLevel(); + + // like i said, no nested shenanigans - quotes only appear outside + // of lists. sorry! + const quoteMatch = line.match(/^> *(.*)$/); + if (quoteMatch) { + // is a quote! open a blockquote tag if it doesnt already exist + if (!inBlockquote) { + inBlockquote = true; + outLines.push('<blockquote>'); + } + indentThisLine = 1; + lineContent = quoteMatch[1]; + } else if (inBlockquote) { + // not a quote! close a blockquote tag if it exists inBlockquote = false; outLines.push('</blockquote>'); + } + + // let some escaped symbols display as the normal symbol, since the + // point of escaping them is just to avoid having them be treated as + // syntax markers! + if (lineContent.match(/( *)\\-/)) { + lineContent = lineContent.replace('\\-', '-'); + } else if (lineContent.match(/( *)\\>/)) { + lineContent = lineContent.replace('\\>', '>'); + } } - return outLines.join('\n'); -} + if (lineTag === 'p') { + // certain inline element tags should still be postioned within a + // paragraph; other elements (e.g. headings) should be added as-is + const elementMatch = line.match(/^<(.*?)[ >]/); + if ( + elementMatch && + !imageLine && + ![ + 'a', + 'abbr', + 'b', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'datalist', + 'del', + 'dfn', + 'em', + 'i', + 'img', + 'ins', + 'kbd', + 'mark', + 'output', + 'picture', + 'q', + 'ruby', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'svg', + 'time', + 'var', + 'wbr', + ].includes(elementMatch[1]) + ) { + lineTag = ''; + } + } -function transformLyrics(text, { - transformInline, - transformMultiline -}) { - // Different from transformMultiline 'cuz it joins multiple lines together - // with line 8reaks (<br>); transformMultiline treats each line as its own - // complete paragraph (or list, etc). - - // If it looks like old data, then like, oh god. - // Use the normal transformMultiline tool. - if (text.includes('<br')) { - return transformMultiline(text); + let pushString = indentString.repeat(indentThisLine); + if (lineTag) { + pushString += `<${lineTag}>${lineContent}</${lineTag}>`; + } else { + pushString += lineContent; } + outLines.push(pushString); + } - text = transformInline(text.trim()); + // after processing all lines... - let buildLine = ''; - const addLine = () => outLines.push(`<p>${buildLine}</p>`); - const outLines = []; - for (const line of text.split('\n')) { - if (line.length) { - if (buildLine.length) { - buildLine += '<br>'; - } - buildLine += line; - } else if (buildLine.length) { - addLine(); - buildLine = ''; - } - } - if (buildLine.length) { - addLine(); + // if still in a list, close all levels + while (levelIndents.length) closeLevel(); + + // if still in a blockquote, close its tag + if (inBlockquote) { + inBlockquote = false; + outLines.push('</blockquote>'); + } + + return outLines.join('\n'); +} + +function transformLyrics(text, {transformInline, transformMultiline}) { + // Different from transformMultiline 'cuz it joins multiple lines together + // with line 8reaks (<br>); transformMultiline treats each line as its own + // complete paragraph (or list, etc). + + // If it looks like old data, then like, oh god. + // Use the normal transformMultiline tool. + if (text.includes('<br')) { + return transformMultiline(text); + } + + text = transformInline(text.trim()); + + let buildLine = ''; + const addLine = () => outLines.push(`<p>${buildLine}</p>`); + const outLines = []; + for (const line of text.split('\n')) { + if (line.length) { + if (buildLine.length) { + buildLine += '<br>'; + } + buildLine += line; + } else if (buildLine.length) { + addLine(); + buildLine = ''; } - return outLines.join('\n'); + } + if (buildLine.length) { + addLine(); + } + return outLines.join('\n'); } function stringifyThings(thingData) { - return JSON.stringify(serializeThings(thingData)); + return JSON.stringify(serializeThings(thingData)); } function img({ - src, - alt, - noSrcText = '', - thumb: thumbKey, - reveal, - id, + src, + alt, + noSrcText = '', + thumb: thumbKey, + reveal, + id, + class: className, + width, + height, + link = false, + lazy = false, + square = false, +}) { + const willSquare = square; + const willLink = typeof link === 'string' || link; + + const originalSrc = src; + const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src); + + const imgAttributes = html.attributes({ + id: link ? '' : id, class: className, + alt, width, height, - link = false, - lazy = false, - square = false -}) { - const willSquare = square; - const willLink = typeof link === 'string' || link; - - const originalSrc = src; - const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src); - - const imgAttributes = html.attributes({ - id: link ? '' : id, - class: className, - alt, - width, - height - }); - - const noSrcHTML = !src && wrap(`<div class="image-text-area">${noSrcText}</div>`); - const nonlazyHTML = src && wrap(`<img src="${thumbSrc}" ${imgAttributes}>`); - const lazyHTML = src && lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true); + }); + + const noSrcHTML = + !src && wrap(`<div class="image-text-area">${noSrcText}</div>`); + const nonlazyHTML = src && wrap(`<img src="${thumbSrc}" ${imgAttributes}>`); + const lazyHTML = + src && + lazy && + wrap( + `<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, + true + ); - if (!src) { - return noSrcHTML; - } else if (lazy) { - return fixWS` + if (!src) { + return noSrcHTML; + } else if (lazy) { + return fixWS` <noscript>${nonlazyHTML}</noscript> ${lazyHTML} `; - } else { - return nonlazyHTML; - } + } else { + return nonlazyHTML; + } - function wrap(input, hide = false) { - let wrapped = input; + function wrap(input, hide = false) { + let wrapped = input; - wrapped = `<div class="image-inner-area">${wrapped}</div>`; - wrapped = `<div class="image-container">${wrapped}</div>`; + wrapped = `<div class="image-inner-area">${wrapped}</div>`; + wrapped = `<div class="image-container">${wrapped}</div>`; - if (reveal) { - wrapped = fixWS` + if (reveal) { + wrapped = fixWS` <div class="reveal"> ${wrapped} <span class="reveal-text">${reveal}</span> </div> `; - } - - if (willSquare) { - wrapped = html.tag('div', {class: 'square-content'}, wrapped); - wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped); - } + } - if (willLink) { - wrapped = html.tag('a', { - id, - class: ['box', hide && 'js-hide'], - href: typeof link === 'string' ? link : originalSrc - }, wrapped); - } + if (willSquare) { + wrapped = html.tag('div', {class: 'square-content'}, wrapped); + wrapped = html.tag( + 'div', + {class: ['square', hide && !willLink && 'js-hide']}, + wrapped + ); + } - return wrapped; + if (willLink) { + wrapped = html.tag( + 'a', + { + id, + class: ['box', hide && 'js-hide'], + href: typeof link === 'string' ? link : originalSrc, + }, + wrapped + ); } + + return wrapped; + } } function validateWritePath(path, urlGroup) { - if (!Array.isArray(path)) { - return {error: `Expected array, got ${path}`}; - } + if (!Array.isArray(path)) { + return {error: `Expected array, got ${path}`}; + } - const { paths } = urlGroup; + const {paths} = urlGroup; - const definedKeys = Object.keys(paths); - const specifiedKey = path[0]; + const definedKeys = Object.keys(paths); + const specifiedKey = path[0]; - if (!definedKeys.includes(specifiedKey)) { - return {error: `Specified key ${specifiedKey} isn't defined`}; - } + if (!definedKeys.includes(specifiedKey)) { + return {error: `Specified key ${specifiedKey} isn't defined`}; + } - const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0; - const specifiedArgs = path.length - 1; + const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0; + const specifiedArgs = path.length - 1; - if (specifiedArgs !== expectedArgs) { - return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`}; - } + if (specifiedArgs !== expectedArgs) { + return { + error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`, + }; + } - return {success: true}; + return {success: true}; } function validateWriteObject(obj) { - if (typeof obj !== 'object') { - return {error: `Expected object, got ${typeof obj}`}; - } + if (typeof obj !== 'object') { + return {error: `Expected object, got ${typeof obj}`}; + } - if (typeof obj.type !== 'string') { - return {error: `Expected type to be string, got ${obj.type}`}; - } + if (typeof obj.type !== 'string') { + return {error: `Expected type to be string, got ${obj.type}`}; + } - switch (obj.type) { - case 'legacy': { - if (typeof obj.write !== 'function') { - return {error: `Expected write to be string, got ${obj.write}`}; - } + switch (obj.type) { + case 'legacy': { + if (typeof obj.write !== 'function') { + return {error: `Expected write to be string, got ${obj.write}`}; + } - break; - } + break; + } - case 'page': { - const path = validateWritePath(obj.path, urlSpec.localized); - if (path.error) { - return {error: `Path validation failed: ${path.error}`}; - } + case 'page': { + const path = validateWritePath(obj.path, urlSpec.localized); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } - if (typeof obj.page !== 'function') { - return {error: `Expected page to be function, got ${obj.content}`}; - } + if (typeof obj.page !== 'function') { + return {error: `Expected page to be function, got ${obj.content}`}; + } - break; - } + break; + } - case 'data': { - const path = validateWritePath(obj.path, urlSpec.data); - if (path.error) { - return {error: `Path validation failed: ${path.error}`}; - } + case 'data': { + const path = validateWritePath(obj.path, urlSpec.data); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } - if (typeof obj.data !== 'function') { - return {error: `Expected data to be function, got ${obj.data}`}; - } + if (typeof obj.data !== 'function') { + return {error: `Expected data to be function, got ${obj.data}`}; + } - break; - } + break; + } - case 'redirect': { - const fromPath = validateWritePath(obj.fromPath, urlSpec.localized); - if (fromPath.error) { - return {error: `Path (fromPath) validation failed: ${fromPath.error}`}; - } + case 'redirect': { + const fromPath = validateWritePath(obj.fromPath, urlSpec.localized); + if (fromPath.error) { + return { + error: `Path (fromPath) validation failed: ${fromPath.error}`, + }; + } - const toPath = validateWritePath(obj.toPath, urlSpec.localized); - if (toPath.error) { - return {error: `Path (toPath) validation failed: ${toPath.error}`}; - } + const toPath = validateWritePath(obj.toPath, urlSpec.localized); + if (toPath.error) { + return {error: `Path (toPath) validation failed: ${toPath.error}`}; + } - if (typeof obj.title !== 'function') { - return {error: `Expected title to be function, got ${obj.title}`}; - } + if (typeof obj.title !== 'function') { + return {error: `Expected title to be function, got ${obj.title}`}; + } - break; - } + break; + } - default: { - return {error: `Unknown type: ${obj.type}`}; - } + default: { + return {error: `Unknown type: ${obj.type}`}; } + } - return {success: true}; + return {success: true}; } /* @@ -787,12 +810,10 @@ async function writeData(subKey, directory, data) { // touching the original one (which had contained everything). const writePage = {}; -writePage.to = ({ - baseDirectory, - pageSubKey, - paths -}) => (targetFullKey, ...args) => { - const [ groupKey, subKey ] = targetFullKey.split('.'); +writePage.to = + ({baseDirectory, pageSubKey, paths}) => + (targetFullKey, ...args) => { + const [groupKey, subKey] = targetFullKey.split('.'); let path = paths.subdirectoryPrefix; let from; @@ -800,33 +821,39 @@ writePage.to = ({ // When linking to *outside* the localized area of the site, we need to // make sure the result is correctly relative to the 8ase directory. - if (groupKey !== 'localized' && groupKey !== 'localizedDefaultLanguage' && baseDirectory) { - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = targetFullKey; + if ( + groupKey !== 'localized' && + groupKey !== 'localizedDefaultLanguage' && + baseDirectory + ) { + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = targetFullKey; } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { - // Special case for specifically linking *from* a page with base - // directory *to* a page without! Used for the language switcher and - // hopefully nothing else oh god. - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = 'localized.' + subKey; + // Special case for specifically linking *from* a page with base + // directory *to* a page without! Used for the language switcher and + // hopefully nothing else oh god. + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = 'localized.' + subKey; } else if (groupKey === 'localizedDefaultLanguage') { - // Linking to the default, except surprise, we're already IN the default - // (no baseDirectory set). - from = 'localized.' + pageSubKey; - to = 'localized.' + subKey; + // Linking to the default, except surprise, we're already IN the default + // (no baseDirectory set). + from = 'localized.' + pageSubKey; + to = 'localized.' + subKey; } else { - // If we're linking inside the localized area (or there just is no - // 8ase directory), the 8ase directory doesn't matter. - from = 'localized.' + pageSubKey; - to = targetFullKey; + // If we're linking inside the localized area (or there just is no + // 8ase directory), the 8ase directory doesn't matter. + from = 'localized.' + pageSubKey; + to = targetFullKey; } path += urls.from(from).to(to, ...args); return path; -}; + }; -writePage.html = (pageInfo, { +writePage.html = ( + pageInfo, + { defaultLanguage, language, languages, @@ -835,482 +862,653 @@ writePage.html = (pageInfo, { oEmbedJSONHref, to, transformMultiline, - wikiData -}) => { - const { wikiInfo } = wikiData; - - let { - title = '', - meta = {}, - theme = '', - stylesheet = '', - - showWikiNameInTitle = true, - - // missing properties are auto-filled, see below! - body = {}, - banner = {}, - main = {}, - sidebarLeft = {}, - sidebarRight = {}, - nav = {}, - secondaryNav = {}, - footer = {}, - socialEmbed = {}, - } = pageInfo; - - body.style ??= ''; - - theme = theme || getThemeString(wikiInfo.color); - - banner ||= {}; - banner.classes ??= []; - banner.src ??= ''; - banner.position ??= ''; - banner.dimensions ??= [0, 0]; - - main.classes ??= []; - main.content ??= ''; - - sidebarLeft ??= {}; - sidebarRight ??= {}; - - for (const sidebar of [sidebarLeft, sidebarRight]) { - sidebar.classes ??= []; - sidebar.content ??= ''; - sidebar.collapse ??= true; - } - - nav.classes ??= []; - nav.content ??= ''; - nav.bottomRowContent ??= ''; - nav.links ??= []; - nav.linkContainerClasses ??= []; - - secondaryNav ??= {}; - secondaryNav.content ??= ''; - secondaryNav.content ??= ''; - - footer.classes ??= []; - footer.content ??= (wikiInfo.footerContent ? transformMultiline(wikiInfo.footerContent) : ''); - - footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, { - defaultLanguage, languages, paths, language, to + wikiData, + } +) => { + const {wikiInfo} = wikiData; + + let { + title = '', + meta = {}, + theme = '', + stylesheet = '', + + showWikiNameInTitle = true, + + // missing properties are auto-filled, see below! + body = {}, + banner = {}, + main = {}, + sidebarLeft = {}, + sidebarRight = {}, + nav = {}, + secondaryNav = {}, + footer = {}, + socialEmbed = {}, + } = pageInfo; + + body.style ??= ''; + + theme = theme || getThemeString(wikiInfo.color); + + banner ||= {}; + banner.classes ??= []; + banner.src ??= ''; + banner.position ??= ''; + banner.dimensions ??= [0, 0]; + + main.classes ??= []; + main.content ??= ''; + + sidebarLeft ??= {}; + sidebarRight ??= {}; + + for (const sidebar of [sidebarLeft, sidebarRight]) { + sidebar.classes ??= []; + sidebar.content ??= ''; + sidebar.collapse ??= true; + } + + nav.classes ??= []; + nav.content ??= ''; + nav.bottomRowContent ??= ''; + nav.links ??= []; + nav.linkContainerClasses ??= []; + + secondaryNav ??= {}; + secondaryNav.content ??= ''; + secondaryNav.content ??= ''; + + footer.classes ??= []; + footer.content ??= wikiInfo.footerContent + ? transformMultiline(wikiInfo.footerContent) + : ''; + + footer.content += + '\n' + + getFooterLocalizationLinks(paths.pathname, { + defaultLanguage, + languages, + paths, + language, + to, }); - const canonical = (wikiInfo.canonicalBase - ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname) - : ''); - - const localizedCanonical = (wikiInfo.canonicalBase - ? Object.entries(localizedPaths).map(([ code, { pathname } ]) => ({ - lang: code, - href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname) - })) - : []); - - const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false); - - const mainHTML = main.content && html.tag('main', { + const canonical = wikiInfo.canonicalBase + ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname) + : ''; + + const localizedCanonical = wikiInfo.canonicalBase + ? Object.entries(localizedPaths).map(([code, {pathname}]) => ({ + lang: code, + href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname), + })) + : []; + + const collapseSidebars = + sidebarLeft.collapse !== false && sidebarRight.collapse !== false; + + const mainHTML = + main.content && + html.tag( + 'main', + { id: 'content', - class: main.classes - }, main.content); + class: main.classes, + }, + main.content + ); - const footerHTML = footer.content && html.tag('footer', { + const footerHTML = + footer.content && + html.tag( + 'footer', + { id: 'footer', - class: footer.classes - }, footer.content); - - const generateSidebarHTML = (id, { - content, - multiple, - classes, - collapse = true, - wide = false - }) => (content - ? html.tag('div', - {id, class: [ - 'sidebar-column', - 'sidebar', - wide && 'wide', - !collapse && 'no-hide', - ...classes - ]}, - content) - : multiple ? html.tag('div', - {id, class: [ - 'sidebar-column', - 'sidebar-multiple', - wide && 'wide', - !collapse && 'no-hide' - ]}, - multiple.map(content => html.tag('div', - {class: ['sidebar', ...classes]}, - content))) - : ''); - - const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft); - const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight); - - if (nav.simple) { - nav.linkContainerClasses = ['nav-links-hierarchy']; - nav.links = [ - {toHome: true}, - {toCurrentPage: true} - ]; - } - - const links = (nav.links || []).filter(Boolean); - - const navLinkParts = []; - for (let i = 0; i < links.length; i++) { - let cur = links[i]; - const prev = links[i - 1]; - const next = links[i + 1]; - - let { title: linkTitle } = cur; + class: footer.classes, + }, + footer.content + ); - if (cur.toHome) { - linkTitle ??= wikiInfo.nameShort; - } else if (cur.toCurrentPage) { - linkTitle ??= title; - } + const generateSidebarHTML = ( + id, + {content, multiple, classes, collapse = true, wide = false} + ) => + content + ? html.tag( + 'div', + { + id, + class: [ + 'sidebar-column', + 'sidebar', + wide && 'wide', + !collapse && 'no-hide', + ...classes, + ], + }, + content + ) + : multiple + ? html.tag( + 'div', + { + id, + class: [ + 'sidebar-column', + 'sidebar-multiple', + wide && 'wide', + !collapse && 'no-hide', + ], + }, + multiple.map((content) => + html.tag('div', {class: ['sidebar', ...classes]}, content) + ) + ) + : ''; + + const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft); + const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight); + + if (nav.simple) { + nav.linkContainerClasses = ['nav-links-hierarchy']; + nav.links = [{toHome: true}, {toCurrentPage: true}]; + } + + const links = (nav.links || []).filter(Boolean); + + const navLinkParts = []; + for (let i = 0; i < links.length; i++) { + let cur = links[i]; + + let {title: linkTitle} = cur; + + if (cur.toHome) { + linkTitle ??= wikiInfo.nameShort; + } else if (cur.toCurrentPage) { + linkTitle ??= title; + } - let partContent; + let partContent; - if (typeof cur.html === 'string') { - if (!cur.html) { - logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`; - console.trace(); - } - partContent = cur.html; - } else { - const attributes = { - class: (cur.toCurrentPage || i === links.length - 1) && 'current', - href: ( - cur.toCurrentPage ? '' : - cur.toHome ? to('localized.home') : - cur.path ? to(...cur.path) : - cur.href ? (() => { - logWarn`Using legacy href format nav link in ${paths.pathname}`; - return cur.href; - })() : - null) - }; - if (attributes.href === null) { - throw new Error(`Expected some href specifier for link to ${linkTitle} (${JSON.stringify(cur)})`); - } - partContent = html.tag('a', attributes, linkTitle); - } + if (typeof cur.html === 'string') { + if (!cur.html) { + logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`; + console.trace(); + } + partContent = cur.html; + } else { + const attributes = { + class: (cur.toCurrentPage || i === links.length - 1) && 'current', + href: cur.toCurrentPage + ? '' + : cur.toHome + ? to('localized.home') + : cur.path + ? to(...cur.path) + : cur.href + ? (() => { + logWarn`Using legacy href format nav link in ${paths.pathname}`; + return cur.href; + })() + : null, + }; + if (attributes.href === null) { + throw new Error( + `Expected some href specifier for link to ${linkTitle} (${JSON.stringify( + cur + )})` + ); + } + partContent = html.tag('a', attributes, linkTitle); + } - const part = html.tag('span', - {class: cur.divider === false && 'no-divider'}, - partContent); + const part = html.tag( + 'span', + {class: cur.divider === false && 'no-divider'}, + partContent + ); - navLinkParts.push(part); - } + navLinkParts.push(part); + } - const navHTML = html.tag('nav', { - [html.onlyIfContent]: true, - id: 'header', - class: [ - ...nav.classes, - links.length && 'nav-has-main-links', - nav.content && 'nav-has-content', - nav.bottomRowContent && 'nav-has-bottom-row', - ], - }, [ - links.length && html.tag('div', - {class: ['nav-main-links', ...nav.linkContainerClasses]}, - navLinkParts), - nav.content && html.tag('div', {class: 'nav-content'}, nav.content), - nav.bottomRowContent && html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent), - ]); - - const secondaryNavHTML = html.tag('nav', { - [html.onlyIfContent]: true, - id: 'secondary-nav', - class: secondaryNav.classes - }, [ - secondaryNav.content - ]); - - const bannerSrc = ( - banner.src ? banner.src : - banner.path ? to(...banner.path) : - null); - - const bannerHTML = banner.position && bannerSrc && html.tag('div', - { - id: 'banner', - class: banner.classes - }, - html.tag('img', { - src: bannerSrc, - alt: banner.alt, - width: banner.dimensions[0] || 1100, - height: banner.dimensions[1] || 200 - }) + const navHTML = html.tag( + 'nav', + { + [html.onlyIfContent]: true, + id: 'header', + class: [ + ...nav.classes, + links.length && 'nav-has-main-links', + nav.content && 'nav-has-content', + nav.bottomRowContent && 'nav-has-bottom-row', + ], + }, + [ + links.length && + html.tag( + 'div', + {class: ['nav-main-links', ...nav.linkContainerClasses]}, + navLinkParts + ), + nav.content && html.tag('div', {class: 'nav-content'}, nav.content), + nav.bottomRowContent && + html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent), + ] + ); + + const secondaryNavHTML = html.tag( + 'nav', + { + [html.onlyIfContent]: true, + id: 'secondary-nav', + class: secondaryNav.classes, + }, + [secondaryNav.content] + ); + + const bannerSrc = banner.src + ? banner.src + : banner.path + ? to(...banner.path) + : null; + + const bannerHTML = + banner.position && + bannerSrc && + html.tag( + 'div', + { + id: 'banner', + class: banner.classes, + }, + html.tag('img', { + src: bannerSrc, + alt: banner.alt, + width: banner.dimensions[0] || 1100, + height: banner.dimensions[1] || 200, + }) ); - const layoutHTML = [ - navHTML, - banner.position === 'top' && bannerHTML, - secondaryNavHTML, - html.tag('div', - {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']}, - [ - sidebarLeftHTML, - mainHTML, - sidebarRightHTML - ]), - banner.position === 'bottom' && bannerHTML, - footerHTML - ].filter(Boolean).join('\n'); - - const infoCardHTML = fixWS` + const layoutHTML = [ + navHTML, + banner.position === 'top' && bannerHTML, + secondaryNavHTML, + html.tag( + 'div', + {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']}, + [sidebarLeftHTML, mainHTML, sidebarRightHTML] + ), + banner.position === 'bottom' && bannerHTML, + footerHTML, + ] + .filter(Boolean) + .join('\n'); + + const infoCardHTML = fixWS` <div id="info-card-container"> <div class="info-card-decor"> <div class="info-card"> <div class="info-card-art-container no-reveal"> ${img({ - class: 'info-card-art', - src: '', - link: true, - square: true + class: 'info-card-art', + src: '', + link: true, + square: true, })} </div> <div class="info-card-art-container reveal"> ${img({ - class: 'info-card-art', - src: '', - link: true, - square: true, - reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {language}) + class: 'info-card-art', + src: '', + link: true, + square: true, + reveal: getRevealStringFromWarnings( + '<span class="info-card-art-warnings"></span>', + {language} + ), })} </div> <h1 class="info-card-name"><a></a></h1> - <p class="info-card-album">${language.$('releaseInfo.from', {album: '<a></a>'})}</p> - <p class="info-card-artists">${language.$('releaseInfo.by', {artists: '<span></span>'})}</p> - <p class="info-card-cover-artists">${language.$('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p> + <p class="info-card-album">${language.$( + 'releaseInfo.from', + { + album: '<a></a>', + } + )}</p> + <p class="info-card-artists">${language.$( + 'releaseInfo.by', + { + artists: '<span></span>', + } + )}</p> + <p class="info-card-cover-artists">${language.$( + 'releaseInfo.coverArtBy', + { + artists: '<span></span>', + } + )}</p> </div> </div> </div> `; - const socialEmbedHTML = [ - socialEmbed.title && html.tag('meta', {property: 'og:title', content: socialEmbed.title}), - socialEmbed.description && html.tag('meta', {property: 'og:description', content: socialEmbed.description}), - socialEmbed.image && html.tag('meta', {property: 'og:image', content: socialEmbed.image}), - socialEmbed.color && html.tag('meta', {name: 'theme-color', content: socialEmbed.color}), - oEmbedJSONHref && html.tag('link', {type: 'application/json+oembed', href: oEmbedJSONHref}), - ].filter(Boolean).join('\n'); - - return filterEmptyLines(fixWS` + const socialEmbedHTML = [ + socialEmbed.title && + html.tag('meta', {property: 'og:title', content: socialEmbed.title}), + socialEmbed.description && + html.tag('meta', { + property: 'og:description', + content: socialEmbed.description, + }), + socialEmbed.image && + html.tag('meta', {property: 'og:image', content: socialEmbed.image}), + socialEmbed.color && + html.tag('meta', {name: 'theme-color', content: socialEmbed.color}), + oEmbedJSONHref && + html.tag('link', { + type: 'application/json+oembed', + href: oEmbedJSONHref, + }), + ] + .filter(Boolean) + .join('\n'); + + return filterEmptyLines(fixWS` <!DOCTYPE html> <html ${html.attributes({ - lang: language.intlCode, - 'data-language-code': language.code, - 'data-url-key': paths.toPath[0], - ...Object.fromEntries(paths.toPath.slice(1).map((v, i) => [['data-url-value' + i], v])), - 'data-rebase-localized': to('localized.root'), - 'data-rebase-shared': to('shared.root'), - 'data-rebase-media': to('media.root'), - 'data-rebase-data': to('data.root') + lang: language.intlCode, + 'data-language-code': language.code, + 'data-url-key': paths.toPath[0], + ...Object.fromEntries( + paths.toPath.slice(1).map((v, i) => [['data-url-value' + i], v]) + ), + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.root'), })}> <head> - <title>${(showWikiNameInTitle + <title>${ + showWikiNameInTitle ? language.formatString('misc.pageTitle.withWikiName', { title, - wikiName: wikiInfo.nameShort - }) - : language.formatString('misc.pageTitle', {title}))}</title> + wikiName: wikiInfo.nameShort, + }) + : language.formatString('misc.pageTitle', {title}) + }</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${html.escapeAttributeValue(value)}">`).join('\n')} + ${Object.entries(meta) + .filter(([key, value]) => value) + .map( + ([key, value]) => + `<meta ${key}="${html.escapeAttributeValue(value)}">` + ) + .join('\n')} ${canonical && `<link rel="canonical" href="${canonical}">`} - ${localizedCanonical.map(({ lang, href }) => `<link rel="alternate" hreflang="${lang}" href="${href}">`).join('\n')} + ${localizedCanonical + .map( + ({lang, href}) => + `<link rel="alternate" hreflang="${lang}" href="${href}">` + ) + .join('\n')} ${socialEmbedHTML} - <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}"> - ${(theme || stylesheet) && fixWS` + <link rel="stylesheet" href="${to( + 'shared.staticFile', + `site.css?${CACHEBUST}` + )}"> + ${ + (theme || stylesheet) && + fixWS` <style> ${theme} ${stylesheet} </style> - `} - <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script> + ` + } + <script src="${to( + 'shared.staticFile', + `lazy-loading.js?${CACHEBUST}` + )}"></script> </head> <body ${html.attributes({style: body.style || ''})}> <div id="page-container"> - ${mainHTML && fixWS` + ${ + mainHTML && + fixWS` <div id="skippers"> ${[ - ['#content', language.$('misc.skippers.skipToContent')], - sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML - ? language.$('misc.skippers.skipToSidebar.left') - : language.$('misc.skippers.skipToSidebar'))], - sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML - ? language.$('misc.skippers.skipToSidebar.right') - : language.$('misc.skippers.skipToSidebar'))], - footerHTML && ['#footer', language.$('misc.skippers.skipToFooter')] - ].filter(Boolean).map(([ href, title ]) => fixWS` + [ + '#content', + language.$('misc.skippers.skipToContent'), + ], + sidebarLeftHTML && [ + '#sidebar-left', + sidebarRightHTML + ? language.$( + 'misc.skippers.skipToSidebar.left' + ) + : language.$('misc.skippers.skipToSidebar'), + ], + sidebarRightHTML && [ + '#sidebar-right', + sidebarLeftHTML + ? language.$( + 'misc.skippers.skipToSidebar.right' + ) + : language.$('misc.skippers.skipToSidebar'), + ], + footerHTML && [ + '#footer', + language.$('misc.skippers.skipToFooter'), + ], + ] + .filter(Boolean) + .map( + ([href, title]) => fixWS` <span class="skipper"><a href="${href}">${title}</a></span> - `).join('\n')} + ` + ) + .join('\n')} </div> - `} + ` + } ${layoutHTML} </div> ${infoCardHTML} - <script type="module" src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script> + <script type="module" src="${to( + 'shared.staticFile', + `client.js?${CACHEBUST}` + )}"></script> </body> </html> `); }; -writePage.oEmbedJSON = (pageInfo, { - language, - wikiData, -}) => { - const { socialEmbed } = pageInfo; - const { wikiInfo } = wikiData; - const { canonicalBase, nameShort } = wikiInfo; - - if (!socialEmbed) return ''; - - const entries = [ - socialEmbed.heading && ['author_name', - language.$('misc.socialEmbed.heading', { - wikiName: nameShort, - heading: socialEmbed.heading - })], - socialEmbed.headingLink && canonicalBase && ['author_url', - canonicalBase.replace(/\/$/, '') + '/' + - socialEmbed.headingLink.replace(/^\//, '')], - ].filter(Boolean); - - if (!entries.length) return ''; - - return JSON.stringify(Object.fromEntries(entries)); +writePage.oEmbedJSON = (pageInfo, {language, wikiData}) => { + const {socialEmbed} = pageInfo; + const {wikiInfo} = wikiData; + const {canonicalBase, nameShort} = wikiInfo; + + if (!socialEmbed) return ''; + + const entries = [ + socialEmbed.heading && [ + 'author_name', + language.$('misc.socialEmbed.heading', { + wikiName: nameShort, + heading: socialEmbed.heading, + }), + ], + socialEmbed.headingLink && + canonicalBase && [ + 'author_url', + canonicalBase.replace(/\/$/, '') + + '/' + + socialEmbed.headingLink.replace(/^\//, ''), + ], + ].filter(Boolean); + + if (!entries.length) return ''; + + return JSON.stringify(Object.fromEntries(entries)); }; -writePage.write = async ({ - html, - oEmbedJSON = '', - paths, -}) => { - await mkdir(paths.outputDirectory, {recursive: true}); - await Promise.all([ - writeFile(paths.outputFile, html), - oEmbedJSON && writeFile(paths.oEmbedJSONFile, oEmbedJSON) - ].filter(Boolean)); +writePage.write = async ({html, oEmbedJSON = '', paths}) => { + await mkdir(paths.outputDirectory, {recursive: true}); + await Promise.all( + [ + writeFile(paths.outputFile, html), + oEmbedJSON && writeFile(paths.oEmbedJSONFile, oEmbedJSON), + ].filter(Boolean) + ); }; // TODO: This only supports one <>-style argument. -writePage.paths = (baseDirectory, fullKey, directory = '', { - file = 'index.html' -} = {}) => { - const [ groupKey, subKey ] = fullKey.split('.'); - - const pathname = (groupKey === 'localized' && baseDirectory - ? urls.from('shared.root').toDevice('localizedWithBaseDirectory.' + subKey, baseDirectory, directory) - : urls.from('shared.root').toDevice(fullKey, directory)); - - // Needed for the rare directory which itself contains a slash, e.g. for - // listings, with directories like 'albums/by-name'. - const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1); - - const outputDirectory = path.join(outputPath, pathname); - const outputFile = path.join(outputDirectory, file); - const oEmbedJSONFile = path.join(outputDirectory, OEMBED_JSON_FILE); - - return { - toPath: [fullKey, directory], - pathname, - subdirectoryPrefix, - outputDirectory, outputFile, - oEmbedJSONFile, - }; +writePage.paths = ( + baseDirectory, + fullKey, + directory = '', + {file = 'index.html'} = {} +) => { + const [groupKey, subKey] = fullKey.split('.'); + + const pathname = + groupKey === 'localized' && baseDirectory + ? urls + .from('shared.root') + .toDevice( + 'localizedWithBaseDirectory.' + subKey, + baseDirectory, + directory + ) + : urls.from('shared.root').toDevice(fullKey, directory); + + // Needed for the rare directory which itself contains a slash, e.g. for + // listings, with directories like 'albums/by-name'. + const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1); + + const outputDirectory = path.join(outputPath, pathname); + const outputFile = path.join(outputDirectory, file); + const oEmbedJSONFile = path.join(outputDirectory, OEMBED_JSON_FILE); + + return { + toPath: [fullKey, directory], + pathname, + subdirectoryPrefix, + outputDirectory, + outputFile, + oEmbedJSONFile, + }; }; async function writeFavicon() { + try { + await stat(path.join(mediaPath, FAVICON_FILE)); + } catch (error) { + return; + } + + try { + await copyFile( + path.join(mediaPath, FAVICON_FILE), + path.join(outputPath, FAVICON_FILE) + ); + } catch (error) { + logWarn`Failed to copy favicon! ${error.message}`; + return; + } + + logInfo`Copied favicon to site root.`; +} + +function writeSymlinks() { + return progressPromiseAll('Writing site symlinks.', [ + link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'), + link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'), + link(mediaPath, 'media.root'), + ]); + + async function link(directory, urlKey) { + const pathname = urls.from('shared.root').toDevice(urlKey); + const file = path.join(outputPath, pathname); try { - await stat(path.join(mediaPath, FAVICON_FILE)); + await unlink(file); } catch (error) { - return; + if (error.code !== 'ENOENT') { + throw error; + } } - try { - await copyFile( - path.join(mediaPath, FAVICON_FILE), - path.join(outputPath, FAVICON_FILE) - ); + await symlink(path.resolve(directory), file); } catch (error) { - logWarn`Failed to copy favicon! ${error.message}`; - return; - } - - logInfo`Copied favicon to site root.`; -} - -function writeSymlinks() { - return progressPromiseAll('Writing site symlinks.', [ - link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'), - link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'), - link(mediaPath, 'media.root') - ]); - - async function link(directory, urlKey) { - const pathname = urls.from('shared.root').toDevice(urlKey); - const file = path.join(outputPath, pathname); - try { - await unlink(file); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - } - try { - await symlink(path.resolve(directory), file); - } catch (error) { - if (error.code === 'EPERM') { - await symlink(path.resolve(directory), file, 'junction'); - } - } + if (error.code === 'EPERM') { + await symlink(path.resolve(directory), file, 'junction'); + } } + } } function writeSharedFilesAndPages({language, wikiData}) { - const { groupData, wikiInfo } = wikiData; - - const redirect = async (title, from, urlKey, directory) => { - const target = path.relative(from, urls.from('shared.root').to(urlKey, directory)); - const content = generateRedirectPage(title, target, {language}); - await mkdir(path.join(outputPath, from), {recursive: true}); - await writeFile(path.join(outputPath, from, 'index.html'), content); - }; - - return progressPromiseAll(`Writing files & pages shared across languages.`, [ - groupData?.some(group => group.directory === 'fandom') && - redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'), + const {groupData, wikiInfo} = wikiData; - groupData?.some(group => group.directory === 'official') && - redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'), - - wikiInfo.enableListings && - redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''), - - writeFile(path.join(outputPath, 'data.json'), fixWS` + const redirect = async (title, from, urlKey, directory) => { + const target = path.relative( + from, + urls.from('shared.root').to(urlKey, directory) + ); + const content = generateRedirectPage(title, target, {language}); + await mkdir(path.join(outputPath, from), {recursive: true}); + await writeFile(path.join(outputPath, from, 'index.html'), content); + }; + + return progressPromiseAll( + `Writing files & pages shared across languages.`, + [ + groupData?.some((group) => group.directory === 'fandom') && + redirect( + 'Fandom - Gallery', + 'albums/fandom', + 'localized.groupGallery', + 'fandom' + ), + + groupData?.some((group) => group.directory === 'official') && + redirect( + 'Official - Gallery', + 'albums/official', + 'localized.groupGallery', + 'official' + ), + + wikiInfo.enableListings && + redirect( + 'Album Commentary', + 'list/all-commentary', + 'localized.commentaryIndex', + '' + ), + + writeFile( + path.join(outputPath, 'data.json'), + fixWS` { "albumData": ${stringifyThings(wikiData.albumData)}, - ${wikiInfo.enableFlashesAndGames && `"flashData": ${stringifyThings(wikiData.flashData)},`} + ${ + wikiInfo.enableFlashesAndGames && + `"flashData": ${stringifyThings(wikiData.flashData)},` + } "artistData": ${stringifyThings(wikiData.artistData)} } - `) - ].filter(Boolean)); + ` + ), + ].filter(Boolean) + ); } function generateRedirectPage(title, target, {language}) { - return fixWS` + return fixWS` <!DOCTYPE html> <html> <head> @@ -1324,7 +1522,7 @@ function generateRedirectPage(title, target, {language}) { <main> <h1>${language.$('redirectPage.title', {title})}</h1> <p>${language.$('redirectPage.infoLine', { - target: `<a href="${target}">${target}</a>` + target: `<a href="${target}">${target}</a>`, })}</p> </main> </body> @@ -1335,621 +1533,662 @@ function generateRedirectPage(title, target, {language}) { // RIP toAnythingMan (previously getHrefOfAnythingMan), 2020-05-25<>2021-05-14. // ........Yet the function 8reathes life anew as linkAnythingMan! ::::) function linkAnythingMan(anythingMan, {link, wikiData, ...opts}) { - return ( - wikiData.albumData.includes(anythingMan) ? link.album(anythingMan, opts) : - wikiData.trackData.includes(anythingMan) ? link.track(anythingMan, opts) : - wikiData.flashData?.includes(anythingMan) ? link.flash(anythingMan, opts) : - 'idk bud' - ) + return wikiData.albumData.includes(anythingMan) + ? link.album(anythingMan, opts) + : wikiData.trackData.includes(anythingMan) + ? link.track(anythingMan, opts) + : wikiData.flashData?.includes(anythingMan) + ? link.flash(anythingMan, opts) + : 'idk bud'; } async function processLanguageFile(file) { - const contents = await readFile(file, 'utf-8'); - const json = JSON.parse(contents); - - const code = json['meta.languageCode']; - if (!code) { - throw new Error(`Missing language code (file: ${file})`); - } - delete json['meta.languageCode']; - - const intlCode = json['meta.languageIntlCode'] ?? null; - delete json['meta.languageIntlCode']; - - const name = json['meta.languageName']; - if (!name) { - throw new Error(`Missing language name (${code})`); - } - delete json['meta.languageName']; - - const hidden = json['meta.hidden'] ?? false; - delete json['meta.hidden']; - - if (json['meta.baseDirectory']) { - logWarn`(${code}) Language JSON still has unused meta.baseDirectory`; - delete json['meta.baseDirectory']; - } - - const language = new Language(); - language.code = code; - language.intlCode = intlCode; - language.name = name; - language.hidden = hidden; - language.escapeHTML = string => he.encode(string, {useNamedReferences: true}); - language.strings = json; - return language; + const contents = await readFile(file, 'utf-8'); + const json = JSON.parse(contents); + + const code = json['meta.languageCode']; + if (!code) { + throw new Error(`Missing language code (file: ${file})`); + } + delete json['meta.languageCode']; + + const intlCode = json['meta.languageIntlCode'] ?? null; + delete json['meta.languageIntlCode']; + + const name = json['meta.languageName']; + if (!name) { + throw new Error(`Missing language name (${code})`); + } + delete json['meta.languageName']; + + const hidden = json['meta.hidden'] ?? false; + delete json['meta.hidden']; + + if (json['meta.baseDirectory']) { + logWarn`(${code}) Language JSON still has unused meta.baseDirectory`; + delete json['meta.baseDirectory']; + } + + const language = new Language(); + language.code = code; + language.intlCode = intlCode; + language.name = name; + language.hidden = hidden; + language.escapeHTML = (string) => + he.encode(string, {useNamedReferences: true}); + language.strings = json; + return language; } // Wrapper function for running a function once for all languages. async function wrapLanguages(fn, {languages, writeOneLanguage = null}) { - const k = writeOneLanguage; - const languagesToRun = (k - ? {[k]: languages[k]} - : languages); + const k = writeOneLanguage; + const languagesToRun = k ? {[k]: languages[k]} : languages; - const entries = Object.entries(languagesToRun) - .filter(([ key ]) => key !== 'default'); + const entries = Object.entries(languagesToRun).filter( + ([key]) => key !== 'default' + ); - for (let i = 0; i < entries.length; i++) { - const [ key, language ] = entries[i]; + for (let i = 0; i < entries.length; i++) { + const [_key, language] = entries[i]; - await fn(language, i, entries); - } + await fn(language, i, entries); + } } async function main() { - Error.stackTraceLimit = Infinity; - - const WD = wikiData; + Error.stackTraceLimit = Infinity; - WD.listingSpec = listingSpec; - WD.listingTargetSpec = listingTargetSpec; + const WD = wikiData; - const miscOptions = await parseOptions(process.argv.slice(2), { - // Data files for the site, including flash, artist, and al8um data, - // and like a jillion other things too. Pretty much everything which - // makes an individual wiki what it is goes here! - 'data-path': { - type: 'value' - }, - - // Static media will 8e referenced in the site here! The contents are - // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants - // near the top of this file (upd8.js). - 'media-path': { - type: 'value' - }, + WD.listingSpec = listingSpec; + WD.listingTargetSpec = listingTargetSpec; - // String files! For the most part, this is used for translating the - // site to different languages, though you can also customize strings - // for your own 8uild of the site if you'd like. Files here should all - // match the format in strings-default.json in this repository. (If a - // language file is missing any strings, the site code will fall 8ack - // to what's specified in strings-default.json.) - // - // Unlike the other options here, this one's optional - the site will - // 8uild with the default (English) strings if this path is left - // unspecified. - 'lang-path': { - type: 'value' - }, + const miscOptions = await parseOptions(process.argv.slice(2), { + // Data files for the site, including flash, artist, and al8um data, + // and like a jillion other things too. Pretty much everything which + // makes an individual wiki what it is goes here! + 'data-path': { + type: 'value', + }, - // This is the output directory. It's the one you'll upload online with - // rsync or whatever when you're pushing an upd8, and also the one - // you'd archive if you wanted to make a 8ackup of the whole dang - // site. Just keep in mind that the gener8ted result will contain a - // couple symlinked directories, so if you're uploading, you're pro8a8ly - // gonna want to resolve those yourself. - 'out-path': { - type: 'value' - }, + // Static media will 8e referenced in the site here! The contents are + // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants + // near the top of this file (upd8.js). + 'media-path': { + type: 'value', + }, - // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e - // kinda a pain to run every time, since it does necessit8te reading - // every media file at run time. Pass this to skip it. - 'skip-thumbs': { - type: 'flag' - }, + // String files! For the most part, this is used for translating the + // site to different languages, though you can also customize strings + // for your own 8uild of the site if you'd like. Files here should all + // match the format in strings-default.json in this repository. (If a + // language file is missing any strings, the site code will fall 8ack + // to what's specified in strings-default.json.) + // + // Unlike the other options here, this one's optional - the site will + // 8uild with the default (English) strings if this path is left + // unspecified. + 'lang-path': { + type: 'value', + }, - // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can - // pass this flag! It exits 8efore 8uilding the rest of the site. - 'thumbs-only': { - type: 'flag' - }, + // This is the output directory. It's the one you'll upload online with + // rsync or whatever when you're pushing an upd8, and also the one + // you'd archive if you wanted to make a 8ackup of the whole dang + // site. Just keep in mind that the gener8ted result will contain a + // couple symlinked directories, so if you're uploading, you're pro8a8ly + // gonna want to resolve those yourself. + 'out-path': { + type: 'value', + }, - // Just working on data entries and not interested in actually - // generating site HTML yet? This flag will cut execution off right - // 8efore any site 8uilding actually happens. - 'no-build': { - type: 'flag' - }, + // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e + // kinda a pain to run every time, since it does necessit8te reading + // every media file at run time. Pass this to skip it. + 'skip-thumbs': { + type: 'flag', + }, - // Only want to 8uild one language during testing? This can chop down - // 8uild times a pretty 8ig chunk! Just pass a single language code. - 'lang': { - type: 'value' - }, + // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can + // pass this flag! It exits 8efore 8uilding the rest of the site. + 'thumbs-only': { + type: 'flag', + }, - // Working without a dev server and just using file:// URLs in your we8 - // 8rowser? This will automatically append index.html to links across - // the site. Not recommended for production, since it isn't guaranteed - // 100% error-free (and index.html-style links are less pretty anyway). - 'append-index-html': { - type: 'flag' - }, + // Just working on data entries and not interested in actually + // generating site HTML yet? This flag will cut execution off right + // 8efore any site 8uilding actually happens. + 'no-build': { + type: 'flag', + }, - // Want sweet, sweet trace8ack info in aggreg8te error messages? This - // will print all the juicy details (or at least the first relevant - // line) right to your output, 8ut also pro8a8ly give you a headache - // 8ecause wow that is a lot of visual noise. - 'show-traces': { - type: 'flag' - }, + // Only want to 8uild one language during testing? This can chop down + // 8uild times a pretty 8ig chunk! Just pass a single language code. + lang: { + type: 'value', + }, - 'queue-size': { - type: 'value', - validate(size) { - if (parseInt(size) !== parseFloat(size)) return 'an integer'; - if (parseInt(size) < 0) return 'a counting number or zero'; - return true; - } - }, - queue: {alias: 'queue-size'}, + // Working without a dev server and just using file:// URLs in your we8 + // 8rowser? This will automatically append index.html to links across + // the site. Not recommended for production, since it isn't guaranteed + // 100% error-free (and index.html-style links are less pretty anyway). + 'append-index-html': { + type: 'flag', + }, - // This option is super slow and has the potential for bugs! It puts - // CacheableObject in a mode where every instance is a Proxy which will - // keep track of invalid property accesses. - 'show-invalid-property-accesses': { - type: 'flag' - }, + // Want sweet, sweet trace8ack info in aggreg8te error messages? This + // will print all the juicy details (or at least the first relevant + // line) right to your output, 8ut also pro8a8ly give you a headache + // 8ecause wow that is a lot of visual noise. + 'show-traces': { + type: 'flag', + }, - [parseOptions.handleUnknown]: () => {} - }); + 'queue-size': { + type: 'value', + validate(size) { + if (parseInt(size) !== parseFloat(size)) return 'an integer'; + if (parseInt(size) < 0) return 'a counting number or zero'; + return true; + }, + }, + queue: {alias: 'queue-size'}, - dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; - mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; - langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! - outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT; + // This option is super slow and has the potential for bugs! It puts + // CacheableObject in a mode where every instance is a Proxy which will + // keep track of invalid property accesses. + 'show-invalid-property-accesses': { + type: 'flag', + }, - const writeOneLanguage = miscOptions['lang']; + [parseOptions.handleUnknown]: () => {}, + }); - { - let errored = false; - const error = (cond, msg) => { - if (cond) { - console.error(`\x1b[31;1m${msg}\x1b[0m`); - errored = true; - } - }; - error(!dataPath, `Expected --data-path option or HSMUSIC_DATA to be set`); - error(!mediaPath, `Expected --media-path option or HSMUSIC_MEDIA to be set`); - error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`); - if (errored) { - return; - } - } + dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; + mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; + langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! + outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT; - const appendIndexHTML = miscOptions['append-index-html'] ?? false; - if (appendIndexHTML) { - logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`; - unbound_link.globalOptions.appendIndexHTML = true; - } + const writeOneLanguage = miscOptions['lang']; - const skipThumbs = miscOptions['skip-thumbs'] ?? false; - const thumbsOnly = miscOptions['thumbs-only'] ?? false; - const noBuild = miscOptions['no-build'] ?? false; - const showAggregateTraces = miscOptions['show-traces'] ?? false; - - const niceShowAggregate = (error, ...opts) => { - showAggregate(error, { - showTraces: showAggregateTraces, - pathToFile: f => path.relative(__dirname, f), - ...opts - }); + { + let errored = false; + const error = (cond, msg) => { + if (cond) { + console.error(`\x1b[31;1m${msg}\x1b[0m`); + errored = true; + } }; - - if (skipThumbs && thumbsOnly) { - logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`; - return; + error(!dataPath, `Expected --data-path option or HSMUSIC_DATA to be set`); + error( + !mediaPath, + `Expected --media-path option or HSMUSIC_MEDIA to be set` + ); + error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`); + if (errored) { + return; } - - if (skipThumbs) { - logInfo`Skipping thumbnail generation.`; - } else { - logInfo`Begin thumbnail generation... -----+`; - const result = await genThumbs(mediaPath, {queueSize, quiet: true}); - logInfo`Done thumbnail generation! --------+`; - if (!result) return; - if (thumbsOnly) return; + } + + const appendIndexHTML = miscOptions['append-index-html'] ?? false; + if (appendIndexHTML) { + logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`; + unbound_link.globalOptions.appendIndexHTML = true; + } + + const skipThumbs = miscOptions['skip-thumbs'] ?? false; + const thumbsOnly = miscOptions['thumbs-only'] ?? false; + const noBuild = miscOptions['no-build'] ?? false; + const showAggregateTraces = miscOptions['show-traces'] ?? false; + + // NOT for ena8ling or disa8ling specific features of the site! + // This is only in charge of what general groups of files to 8uild. + // They're here to make development quicker when you're only working + // on some particular area(s) of the site rather than making changes + // across all of them. + const writeFlags = await parseOptions(process.argv.slice(2), { + all: {type: 'flag'}, // Defaults to true if none 8elow specified. + + // Kinda a hack t8h! + ...Object.fromEntries( + Object.keys(pageSpecs).map((key) => [key, {type: 'flag'}]) + ), + + [parseOptions.handleUnknown]: () => {}, + }); + + const writeAll = !Object.keys(writeFlags).length || writeFlags.all; + + logInfo`Writing site pages: ${ + writeAll ? 'all' : Object.keys(writeFlags).join(', ') + }`; + + const niceShowAggregate = (error, ...opts) => { + showAggregate(error, { + showTraces: showAggregateTraces, + pathToFile: (f) => path.relative(__dirname, f), + ...opts, + }); + }; + + if (skipThumbs && thumbsOnly) { + logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`; + return; + } + + if (skipThumbs) { + logInfo`Skipping thumbnail generation.`; + } else { + logInfo`Begin thumbnail generation... -----+`; + const result = await genThumbs(mediaPath, {queueSize, quiet: true}); + logInfo`Done thumbnail generation! --------+`; + if (!result) return; + if (thumbsOnly) return; + } + + const showInvalidPropertyAccesses = + miscOptions['show-invalid-property-accesses'] ?? false; + + if (showInvalidPropertyAccesses) { + CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; + } + + const {aggregate: processDataAggregate, result: wikiDataResult} = + await loadAndProcessDataDocuments({dataPath}); + + Object.assign(wikiData, wikiDataResult); + + { + const logThings = (thingDataProp, label) => + logInfo` - ${ + wikiData[thingDataProp]?.length ?? color.red('(Missing!)') + } ${color.normal(color.dim(label))}`; + try { + logInfo`Loaded data and processed objects:`; + logThings('albumData', 'albums'); + logThings('trackData', 'tracks'); + logThings('artistData', 'artists'); + if (wikiData.flashData) { + logThings('flashData', 'flashes'); + logThings('flashActData', 'flash acts'); + } + logThings('groupData', 'groups'); + logThings('groupCategoryData', 'group categories'); + logThings('artTagData', 'art tags'); + if (wikiData.newsData) { + logThings('newsData', 'news entries'); + } + logThings('staticPageData', 'static pages'); + if (wikiData.homepageLayout) { + logInfo` - ${1} homepage layout (${ + wikiData.homepageLayout.rows.length + } rows)`; + } + if (wikiData.wikiInfo) { + logInfo` - ${1} wiki config file`; + } + } catch (error) { + console.error(`Error showing data summary:`, error); } - const showInvalidPropertyAccesses = miscOptions['show-invalid-property-accesses'] ?? false; - - if (showInvalidPropertyAccesses) { - CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; + let errorless = true; + try { + processDataAggregate.close(); + } catch (error) { + niceShowAggregate(error); + logWarn`The above errors were detected while processing data files.`; + logWarn`If the remaining valid data is complete enough, the wiki will`; + logWarn`still build - but all errored data will be skipped.`; + logWarn`(Resolve errors for more complete output!)`; + errorless = false; } - const { - aggregate: processDataAggregate, - result: wikiDataResult - } = await loadAndProcessDataDocuments({dataPath}); + if (errorless) { + logInfo`All data processed without any errors - nice!`; + logInfo`(This means all source files will be fully accounted for during page generation.)`; + } + } - Object.assign(wikiData, wikiDataResult); + if (!WD.wikiInfo) { + logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`; + return; + } - { - const logThings = (thingDataProp, label) => logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`; - try { - logInfo`Loaded data and processed objects:`; - logThings('albumData', 'albums'); - logThings('trackData', 'tracks'); - logThings('artistData', 'artists'); - if (wikiData.flashData) { - logThings('flashData', 'flashes'); - logThings('flashActData', 'flash acts'); - } - logThings('groupData', 'groups'); - logThings('groupCategoryData', 'group categories'); - logThings('artTagData', 'art tags'); - if (wikiData.newsData) { - logThings('newsData', 'news entries'); - } - logThings('staticPageData', 'static pages'); - if (wikiData.homepageLayout) { - logInfo` - ${1} homepage layout (${wikiData.homepageLayout.rows.length} rows)`; - } - if (wikiData.wikiInfo) { - logInfo` - ${1} wiki config file`; - } - } catch (error) { - console.error(`Error showing data summary:`, error); - } - - let errorless = true; - try { - processDataAggregate.close(); - } catch (error) { - niceShowAggregate(error); - logWarn`The above errors were detected while processing data files.`; - logWarn`If the remaining valid data is complete enough, the wiki will`; - logWarn`still build - but all errored data will be skipped.`; - logWarn`(Resolve errors for more complete output!)`; - errorless = false; - } + let duplicateDirectoriesErrored = false; - if (errorless) { - logInfo`All data processed without any errors - nice!`; - logInfo`(This means all source files will be fully accounted for during page generation.)`; - } + function filterAndShowDuplicateDirectories() { + const aggregate = filterDuplicateDirectories(wikiData); + let errorless = true; + try { + aggregate.close(); + } catch (aggregate) { + niceShowAggregate(aggregate); + logWarn`The above duplicate directories were detected while reviewing data files.`; + logWarn`Each thing listed above will been totally excempt from this build of the site!`; + logWarn`Specify unique 'Directory' fields in data entries to resolve these.`; + logWarn`${`Note:`} This will probably result in reference errors below.`; + logWarn`${`. . .`} You should fix duplicate directories first!`; + logWarn`(Resolve errors for more complete output!)`; + duplicateDirectoriesErrored = true; + errorless = false; } - - if (!WD.wikiInfo) { - logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`; - return; + if (errorless) { + logInfo`No duplicate directories found - nice!`; } + } - let duplicateDirectoriesErrored = false; - - function filterAndShowDuplicateDirectories() { - const aggregate = filterDuplicateDirectories(wikiData); - let errorless = true; - try { - aggregate.close(); - } catch (aggregate) { - niceShowAggregate(aggregate); - logWarn`The above duplicate directories were detected while reviewing data files.`; - logWarn`Each thing listed above will been totally excempt from this build of the site!`; - logWarn`Specify unique 'Directory' fields in data entries to resolve these.`; - logWarn`${`Note:`} This will probably result in reference errors below.`; - logWarn`${`. . .`} You should fix duplicate directories first!`; - logWarn`(Resolve errors for more complete output!)`; - duplicateDirectoriesErrored = true; - errorless = false; - } - if (errorless) { - logInfo`No duplicate directories found - nice!`; - } + function filterAndShowReferenceErrors() { + const aggregate = filterReferenceErrors(wikiData); + let errorless = true; + try { + aggregate.close(); + } catch (error) { + niceShowAggregate(error); + logWarn`The above errors were detected while validating references in data files.`; + logWarn`If the remaining valid data is complete enough, the wiki will still build -`; + logWarn`but all errored references will be skipped.`; + if (duplicateDirectoriesErrored) { + logWarn`${`Note:`} Duplicate directories were found as well. Review those first,`; + logWarn`${`. . .`} as they may have caused some of the errors detected above.`; + } + logWarn`(Resolve errors for more complete output!)`; + errorless = false; } - - function filterAndShowReferenceErrors() { - const aggregate = filterReferenceErrors(wikiData); - let errorless = true; - try { - aggregate.close(); - } catch (error) { - niceShowAggregate(error); - logWarn`The above errors were detected while validating references in data files.`; - logWarn`If the remaining valid data is complete enough, the wiki will still build -`; - logWarn`but all errored references will be skipped.`; - if (duplicateDirectoriesErrored) { - logWarn`${`Note:`} Duplicate directories were found as well. Review those first,`; - logWarn`${`. . .`} as they may have caused some of the errors detected above.`; - } - logWarn`(Resolve errors for more complete output!)`; - errorless = false; - } - if (errorless) { - logInfo`All references validated without any errors - nice!`; - logInfo`(This means all references between things, such as leitmotif references` - logInfo` and artist credits, will be fully accounted for during page generation.)`; - } + if (errorless) { + logInfo`All references validated without any errors - nice!`; + logInfo`(This means all references between things, such as leitmotif references`; + logInfo` and artist credits, will be fully accounted for during page generation.)`; } + } - // Link data arrays so that all essential references between objects are - // complete, so properties (like dates!) are inherited where that's - // appropriate. - linkWikiDataArrays(wikiData); - - // Filter out any things with duplicate directories throughout the data, - // warning about them too. - filterAndShowDuplicateDirectories(); + // Link data arrays so that all essential references between objects are + // complete, so properties (like dates!) are inherited where that's + // appropriate. + linkWikiDataArrays(wikiData); - // Filter out any reference errors throughout the data, warning about them - // too. - filterAndShowReferenceErrors(); + // Filter out any things with duplicate directories throughout the data, + // warning about them too. + filterAndShowDuplicateDirectories(); - // Sort data arrays so that they're all in order! This may use properties - // which are only available after the initial linking. - sortWikiDataArrays(wikiData); + // Filter out any reference errors throughout the data, warning about them + // too. + filterAndShowReferenceErrors(); - const internalDefaultLanguage = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE)); + // Sort data arrays so that they're all in order! This may use properties + // which are only available after the initial linking. + sortWikiDataArrays(wikiData); - let languages; - if (langPath) { - const languageDataFiles = await findFiles(langPath, { - filter: f => path.extname(f) === '.json' - }); + const internalDefaultLanguage = await processLanguageFile( + path.join(__dirname, DEFAULT_STRINGS_FILE) + ); - const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles - .map(file => processLanguageFile(file))); + let languages; + if (langPath) { + const languageDataFiles = await findFiles(langPath, { + filter: (f) => path.extname(f) === '.json', + }); - languages = Object.fromEntries(results.map(language => [language.code, language])); - } else { - languages = {}; - } + const results = await progressPromiseAll( + `Reading & processing language files.`, + languageDataFiles.map((file) => processLanguageFile(file)) + ); - const customDefaultLanguage = languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; - let finalDefaultLanguage; - - if (customDefaultLanguage) { - logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`; - customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings; - finalDefaultLanguage = customDefaultLanguage; - } else if (WD.wikiInfo.defaultLanguage) { - logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`; - if (langPath) { - logError`Check if an appropriate file exists in ${langPath}?`; - } else { - logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`; - } - return; + languages = Object.fromEntries( + results.map((language) => [language.code, language]) + ); + } else { + languages = {}; + } + + const customDefaultLanguage = + languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; + let finalDefaultLanguage; + + if (customDefaultLanguage) { + logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`; + customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings; + finalDefaultLanguage = customDefaultLanguage; + } else if (WD.wikiInfo.defaultLanguage) { + logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`; + if (langPath) { + logError`Check if an appropriate file exists in ${langPath}?`; } else { - languages[internalDefaultLanguage.code] = internalDefaultLanguage; - finalDefaultLanguage = internalDefaultLanguage; + logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`; } - - for (const language of Object.values(languages)) { - if (language === finalDefaultLanguage) { - continue; - } - - language.inheritedStrings = finalDefaultLanguage.strings; + return; + } else { + languages[internalDefaultLanguage.code] = internalDefaultLanguage; + finalDefaultLanguage = internalDefaultLanguage; + } + + for (const language of Object.values(languages)) { + if (language === finalDefaultLanguage) { + continue; } - logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`; + language.inheritedStrings = finalDefaultLanguage.strings; + } + + logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`; + + if (noBuild) { + logInfo`Not generating any site or page files this run (--no-build passed).`; + } else if (writeOneLanguage && !(writeOneLanguage in languages)) { + logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`; + return; + } else if (writeOneLanguage) { + logInfo`Writing only language ${writeOneLanguage} this run.`; + } else { + logInfo`Writing all languages.`; + } + + { + const tagRefs = new Set( + [...WD.trackData, ...WD.albumData].flatMap( + (thing) => thing.artTagsByRef ?? [] + ) + ); - if (noBuild) { - logInfo`Not generating any site or page files this run (--no-build passed).`; - } else if (writeOneLanguage && !(writeOneLanguage in languages)) { - logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`; - return; - } else if (writeOneLanguage) { - logInfo`Writing only language ${writeOneLanguage} this run.`; - } else { - logInfo`Writing all languages.`; + for (const ref of tagRefs) { + if (find.artTag(ref, WD.artTagData)) { + tagRefs.delete(ref); + } } - { - const tagRefs = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTagsByRef ?? [])); - - for (const ref of tagRefs) { - if (find.artTag(ref, WD.artTagData)) { - tagRefs.delete(ref); - } - } - - if (tagRefs.size) { - for (const ref of Array.from(tagRefs).sort()) { - console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`); - } - return; - } + if (tagRefs.size) { + for (const ref of Array.from(tagRefs).sort()) { + console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`); + } + return; } + } + + WD.officialAlbumData = WD.albumData.filter((album) => + album.groups.some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY) + ); + WD.fandomAlbumData = WD.albumData.filter((album) => + album.groups.every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY) + ); + + const fileSizePreloader = new FileSizePreloader(); + + // File sizes of additional files need to be precalculated before we can + // actually reference 'em in site building, so get those loading right + // away. We actually need to keep track of two things here - the on-device + // file paths we're actually reading, and the corresponding on-site media + // paths that will be exposed in site build code. We'll build a mapping + // function between them so that when site code requests a site path, + // it'll get the size of the file at the corresponding device path. + const additionalFilePaths = [ + ...WD.albumData.flatMap((album) => + [ + ...(album.additionalFiles ?? []), + ...album.tracks.flatMap((track) => track.additionalFiles ?? []), + ] + .flatMap((fileGroup) => fileGroup.files) + .map((file) => ({ + device: path.join( + mediaPath, + urls + .from('media.root') + .toDevice('media.albumAdditionalFile', album.directory, file) + ), + media: urls + .from('media.root') + .to('media.albumAdditionalFile', album.directory, file), + })) + ), + ]; - WD.officialAlbumData = WD.albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY)); - WD.fandomAlbumData = WD.albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY)); - - const fileSizePreloader = new FileSizePreloader(); - - // File sizes of additional files need to be precalculated before we can - // actually reference 'em in site building, so get those loading right - // away. We actually need to keep track of two things here - the on-device - // file paths we're actually reading, and the corresponding on-site media - // paths that will be exposed in site build code. We'll build a mapping - // function between them so that when site code requests a site path, - // it'll get the size of the file at the corresponding device path. - const additionalFilePaths = [ - ...WD.albumData.flatMap(album => ( - [ - ...album.additionalFiles ?? [], - ...album.tracks.flatMap(track => track.additionalFiles ?? []) - ] - .flatMap(fileGroup => fileGroup.files) - .map(file => ({ - device: (path.join(mediaPath, urls - .from('media.root') - .toDevice('media.albumAdditionalFile', album.directory, file))), - media: (urls - .from('media.root') - .to('media.albumAdditionalFile', album.directory, file)) - })))), - ]; - - const getSizeOfAdditionalFile = mediaPath => { - const { device = null } = additionalFilePaths.find(({ media }) => media === mediaPath) || {}; - if (!device) return null; - return fileSizePreloader.getSizeOfPath(device); - }; - - logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`; - - fileSizePreloader.loadPaths(...additionalFilePaths.map(path => path.device)); - await fileSizePreloader.waitUntilDoneLoading(); - - logInfo`Done preloading filesizes!`; - - if (noBuild) return; - - // Makes writing a little nicer on CPU theoretically, 8ut also costs in - // performance right now 'cuz it'll w8 for file writes to 8e completed - // 8efore moving on to more data processing. So, defaults to zero, which - // disa8les the queue feature altogether. - queueSize = +(miscOptions['queue-size'] ?? 0); + const getSizeOfAdditionalFile = (mediaPath) => { + const {device = null} = + additionalFilePaths.find(({media}) => media === mediaPath) || {}; + if (!device) return null; + return fileSizePreloader.getSizeOfPath(device); + }; - const buildDictionary = pageSpecs; + logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`; - // NOT for ena8ling or disa8ling specific features of the site! - // This is only in charge of what general groups of files to 8uild. - // They're here to make development quicker when you're only working - // on some particular area(s) of the site rather than making changes - // across all of them. - const writeFlags = await parseOptions(process.argv.slice(2), { - all: {type: 'flag'}, // Defaults to true if none 8elow specified. + fileSizePreloader.loadPaths( + ...additionalFilePaths.map((path) => path.device) + ); + await fileSizePreloader.waitUntilDoneLoading(); - // Kinda a hack t8h! - ...Object.fromEntries(Object.keys(buildDictionary) - .map(key => [key, {type: 'flag'}])), + logInfo`Done preloading filesizes!`; - [parseOptions.handleUnknown]: () => {} - }); + if (noBuild) return; - const writeAll = !Object.keys(writeFlags).length || writeFlags.all; + // Makes writing a little nicer on CPU theoretically, 8ut also costs in + // performance right now 'cuz it'll w8 for file writes to 8e completed + // 8efore moving on to more data processing. So, defaults to zero, which + // disa8les the queue feature altogether. + queueSize = +(miscOptions['queue-size'] ?? 0); - logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`; + const buildDictionary = pageSpecs; - await writeFavicon(); - await writeSymlinks(); - await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData}); + await writeFavicon(); + await writeSymlinks(); + await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData}); - const buildSteps = (writeAll - ? Object.entries(buildDictionary) - : (Object.entries(buildDictionary) - .filter(([ flag ]) => writeFlags[flag]))); + const buildSteps = writeAll + ? Object.entries(buildDictionary) + : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]); - let writes; - { - let error = false; + let writes; + { + let error = false; - const buildStepsWithTargets = buildSteps.map(([ flag, pageSpec ]) => { - // Condition not met: skip this build step altogether. - if (pageSpec.condition && !pageSpec.condition({wikiData})) { - return null; - } - - // May still call writeTargetless if present. - if (!pageSpec.targets) { - return {flag, pageSpec, targets: []}; - } - - if (!pageSpec.write) { - logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`; - error = true; - return null; - } + const buildStepsWithTargets = buildSteps + .map(([flag, pageSpec]) => { + // Condition not met: skip this build step altogether. + if (pageSpec.condition && !pageSpec.condition({wikiData})) { + return null; + } - const targets = pageSpec.targets({wikiData}); - if (!Array.isArray(targets)) { - logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`; - error = true; - return null; - } + // May still call writeTargetless if present. + if (!pageSpec.targets) { + return {flag, pageSpec, targets: []}; + } - return {flag, pageSpec, targets}; - }).filter(Boolean); + if (!pageSpec.write) { + logError`${flag + '.targets'} is specified, but ${ + flag + '.write' + } is missing!`; + error = true; + return null; + } - if (error) { - return; + const targets = pageSpec.targets({wikiData}); + if (!Array.isArray(targets)) { + logError`${ + flag + '.targets' + } was called, but it didn't return an array! (${typeof targets})`; + error = true; + return null; } - const validateWrites = (writes, fnName) => { - // Do a quick valid8tion! If one of the writeThingPages functions go - // wrong, this will stall out early and tell us which did. + return {flag, pageSpec, targets}; + }) + .filter(Boolean); - if (!Array.isArray(writes)) { - logError`${fnName} didn't return an array!`; - error = true; - return false; - } + if (error) { + return; + } - if (!( - writes.every(obj => typeof obj === 'object') && - writes.every(obj => { - const result = validateWriteObject(obj); - if (result.error) { - logError`Validating write object failed: ${result.error}`; - return false; - } else { - return true; - } - }) - )) { - logError`${fnName} returned invalid entries!`; - error = true; - return false; + const validateWrites = (writes, fnName) => { + // Do a quick valid8tion! If one of the writeThingPages functions go + // wrong, this will stall out early and tell us which did. + + if (!Array.isArray(writes)) { + logError`${fnName} didn't return an array!`; + error = true; + return false; + } + + if ( + !( + writes.every((obj) => typeof obj === 'object') && + writes.every((obj) => { + const result = validateWriteObject(obj); + if (result.error) { + logError`Validating write object failed: ${result.error}`; + return false; + } else { + return true; } + }) + ) + ) { + logError`${fnName} returned invalid entries!`; + error = true; + return false; + } + + return true; + }; - return true; - }; - - // return; + // return; - writes = buildStepsWithTargets.flatMap(({ flag, pageSpec, targets }) => { - const writes = targets.flatMap(target => - pageSpec.write(target, {wikiData})?.slice() || []); + writes = buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => { + const writes = targets.flatMap( + (target) => pageSpec.write(target, {wikiData})?.slice() || [] + ); - if (!validateWrites(writes, flag + '.write')) { - return []; - } + if (!validateWrites(writes, flag + '.write')) { + return []; + } - if (pageSpec.writeTargetless) { - const writes2 = pageSpec.writeTargetless({wikiData}); + if (pageSpec.writeTargetless) { + const writes2 = pageSpec.writeTargetless({wikiData}); - if (!validateWrites(writes2, flag + '.writeTargetless')) { - return []; - } + if (!validateWrites(writes2, flag + '.writeTargetless')) { + return []; + } - writes.push(...writes2); - } + writes.push(...writes2); + } - return writes; - }); + return writes; + }); - if (error) { - return; - } + if (error) { + return; } + } - const pageWrites = writes.filter(({ type }) => type === 'page'); - const dataWrites = writes.filter(({ type }) => type === 'data'); - const redirectWrites = writes.filter(({ type }) => type === 'redirect'); + const pageWrites = writes.filter(({type}) => type === 'page'); + const dataWrites = writes.filter(({type}) => type === 'data'); + const redirectWrites = writes.filter(({type}) => type === 'redirect'); - if (writes.length) { - logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`; - } else { - logWarn`No writes returned at all, so exiting early. This is probably a bug!`; - return; - } + if (writes.length) { + logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`; + } else { + logWarn`No writes returned at all, so exiting early. This is probably a bug!`; + return; + } - /* + /* await progressPromiseAll(`Writing data files shared across languages.`, queue( dataWrites.map(({path, data}) => () => { const bound = {}; @@ -1985,272 +2224,328 @@ async function main() { )); */ - const perLanguageFn = async (language, i, entries) => { - const baseDirectory = (language === finalDefaultLanguage ? '' : language.code); - - console.log(`\x1b[34;1m${ - (`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) ` - .padEnd(60, '-')) - }\x1b[0m`); - - await progressPromiseAll(`Writing ${language.code}`, queue([ - ...pageWrites.map(({type, ...props}) => () => { - const { path, page } = props; + const perLanguageFn = async (language, i, entries) => { + const baseDirectory = + language === finalDefaultLanguage ? '' : language.code; - // TODO: This only supports one <>-style argument. - const pageSubKey = path[0]; - const directory = path[1]; + console.log( + `\x1b[34;1m${`[${i + 1}/${entries.length}] ${ + language.code + } (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m` + ); - const localizedPaths = Object.fromEntries(Object.entries(languages) - .filter(([ key, language ]) => key !== 'default' && !language.hidden) - .map(([ key, language ]) => [language.code, writePage.paths( - (language === finalDefaultLanguage ? '' : language.code), - 'localized.' + pageSubKey, - directory - )])); + await progressPromiseAll( + `Writing ${language.code}`, + queue( + [ + ...pageWrites.map((props) => () => { + const {path, page} = props; - const paths = writePage.paths( - baseDirectory, + // TODO: This only supports one <>-style argument. + const pageSubKey = path[0]; + const directory = path[1]; + + const localizedPaths = Object.fromEntries( + Object.entries(languages) + .filter( + ([key, language]) => key !== 'default' && !language.hidden + ) + .map(([_key, language]) => [ + language.code, + writePage.paths( + language === finalDefaultLanguage ? '' : language.code, 'localized.' + pageSubKey, directory - ); - - const to = writePage.to({ - baseDirectory, - pageSubKey, - paths - }); - - const absoluteTo = (targetFullKey, ...args) => { - const [ groupKey, subKey ] = targetFullKey.split('.'); - const from = urls.from('shared.root'); - return '/' + (groupKey === 'localized' && baseDirectory - ? from.to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args) - : from.to(targetFullKey, ...args)); - }; - - // TODO: Is there some nicer way to define these, - // may8e without totally re-8inding everything for - // each page? - const bound = {}; - - bound.link = withEntries(unbound_link, entries => entries - .map(([ key, fn ]) => [key, bindOpts(fn, {to})])); - - bound.linkAnythingMan = bindOpts(linkAnythingMan, { - link: bound.link, - wikiData - }); - - bound.parseAttributes = bindOpts(parseAttributes, { - to - }); - - bound.find = bindFind(wikiData, {mode: 'warn'}); - - bound.transformInline = bindOpts(transformInline, { - find: bound.find, - link: bound.link, - replacerSpec, - language, - to, - wikiData - }); - - bound.transformMultiline = bindOpts(transformMultiline, { - transformInline: bound.transformInline, - parseAttributes: bound.parseAttributes - }); - - bound.transformLyrics = bindOpts(transformLyrics, { - transformInline: bound.transformInline, - transformMultiline: bound.transformMultiline - }); - - bound.iconifyURL = bindOpts(iconifyURL, { - language, - to - }); - - bound.fancifyURL = bindOpts(fancifyURL, { - language - }); - - bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { - [bindOpts.bindIndex]: 2, - language - }); - - bound.getLinkThemeString = getLinkThemeString; - - bound.getThemeString = getThemeString; - - bound.getArtistString = bindOpts(getArtistString, { - iconifyURL: bound.iconifyURL, - link: bound.link, - language - }); - - bound.getAlbumCover = bindOpts(getAlbumCover, { - to - }); - - bound.getTrackCover = bindOpts(getTrackCover, { - to - }); - - bound.getFlashCover = bindOpts(getFlashCover, { - to - }); - - bound.getArtistAvatar = bindOpts(getArtistAvatar, { - to - }); - - bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, { - language - }); - - bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, { - language - }); - - bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { - link: bound.link, - linkAnythingMan: bound.linkAnythingMan, - language, - wikiData - }); - - bound.generateCoverLink = bindOpts(generateCoverLink, { - [bindOpts.bindIndex]: 0, - img, - link: bound.link, - language, - to, - wikiData - }); - - bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, { - [bindOpts.bindIndex]: 2, - link: bound.link, - language - }); - - bound.generatePreviousNextLinks = bindOpts(generatePreviousNextLinks, { - link: bound.link, - language - }); - - bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, { - language, - wikiData, - }); - - bound.getGridHTML = bindOpts(getGridHTML, { - [bindOpts.bindIndex]: 0, - img, - language - }); - - bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, { - [bindOpts.bindIndex]: 0, - getAlbumCover: bound.getAlbumCover, - getGridHTML: bound.getGridHTML, - link: bound.link, - language - }); - - bound.getFlashGridHTML = bindOpts(getFlashGridHTML, { - [bindOpts.bindIndex]: 0, - getFlashCover: bound.getFlashCover, - getGridHTML: bound.getGridHTML, - link: bound.link - }); - - bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, { - language - }); - - bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, { - language - }); - - bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { - to - }); - - const pageInfo = page({ - ...bound, - - language, - - absoluteTo, - relativeTo: to, - to, - urls, - - getSizeOfAdditionalFile, - }); - - const oEmbedJSON = writePage.oEmbedJSON(pageInfo, { - language, - wikiData, - }); - - const oEmbedJSONHref = (oEmbedJSON && wikiData.wikiInfo.canonicalBase) && ( - wikiData.wikiInfo.canonicalBase + urls.from('shared.root').to('shared.path', paths.pathname + OEMBED_JSON_FILE)); - - const html = writePage.html(pageInfo, { - defaultLanguage: finalDefaultLanguage, - language, - languages, - localizedPaths, - oEmbedJSONHref, - paths, - to, - transformMultiline: bound.transformMultiline, - wikiData - }); - - return writePage.write({ - html, - oEmbedJSON, - paths, - }); - }), - ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => { - const title = titleFn({ - language - }); - - // TODO: This only supports one <>-style argument. - const fromPaths = writePage.paths(baseDirectory, 'localized.' + fromPath[0], fromPath[1]); - const to = writePage.to({baseDirectory, pageSubKey: fromPath[0], paths: fromPaths}); - - const target = to('localized.' + toPath[0], ...toPath.slice(1)); - const html = generateRedirectPage(title, target, {language}); - return writePage.write({html, paths: fromPaths}); - }) - ], queueSize)); - }; + ), + ]) + ); + + const paths = writePage.paths( + baseDirectory, + 'localized.' + pageSubKey, + directory + ); + + const to = writePage.to({ + baseDirectory, + pageSubKey, + paths, + }); - await wrapLanguages(perLanguageFn, { - languages, - writeOneLanguage, - }); + const absoluteTo = (targetFullKey, ...args) => { + const [groupKey, subKey] = targetFullKey.split('.'); + const from = urls.from('shared.root'); + return ( + '/' + + (groupKey === 'localized' && baseDirectory + ? from.to( + 'localizedWithBaseDirectory.' + subKey, + baseDirectory, + ...args + ) + : from.to(targetFullKey, ...args)) + ); + }; + + // TODO: Is there some nicer way to define these, + // may8e without totally re-8inding everything for + // each page? + const bound = {}; + + bound.link = withEntries(unbound_link, (entries) => + entries.map(([key, fn]) => [key, bindOpts(fn, {to})]) + ); + + bound.linkAnythingMan = bindOpts(linkAnythingMan, { + link: bound.link, + wikiData, + }); + + bound.parseAttributes = bindOpts(parseAttributes, { + to, + }); + + bound.find = bindFind(wikiData, {mode: 'warn'}); + + bound.transformInline = bindOpts(transformInline, { + find: bound.find, + link: bound.link, + replacerSpec, + language, + to, + wikiData, + }); + + bound.transformMultiline = bindOpts(transformMultiline, { + transformInline: bound.transformInline, + parseAttributes: bound.parseAttributes, + }); + + bound.transformLyrics = bindOpts(transformLyrics, { + transformInline: bound.transformInline, + transformMultiline: bound.transformMultiline, + }); + + bound.iconifyURL = bindOpts(iconifyURL, { + language, + to, + }); + + bound.fancifyURL = bindOpts(fancifyURL, { + language, + }); + + bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { + [bindOpts.bindIndex]: 2, + language, + }); + + bound.getLinkThemeString = getLinkThemeString; + + bound.getThemeString = getThemeString; + + bound.getArtistString = bindOpts(getArtistString, { + iconifyURL: bound.iconifyURL, + link: bound.link, + language, + }); + + bound.getAlbumCover = bindOpts(getAlbumCover, { + to, + }); + + bound.getTrackCover = bindOpts(getTrackCover, { + to, + }); + + bound.getFlashCover = bindOpts(getFlashCover, { + to, + }); + + bound.getArtistAvatar = bindOpts(getArtistAvatar, { + to, + }); + + bound.generateAdditionalFilesShortcut = bindOpts( + generateAdditionalFilesShortcut, + { + language, + } + ); + + bound.generateAdditionalFilesList = bindOpts( + generateAdditionalFilesList, + { + language, + } + ); + + bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { + link: bound.link, + linkAnythingMan: bound.linkAnythingMan, + language, + wikiData, + }); + + bound.generateCoverLink = bindOpts(generateCoverLink, { + [bindOpts.bindIndex]: 0, + img, + link: bound.link, + language, + to, + wikiData, + }); + + bound.generateInfoGalleryLinks = bindOpts( + generateInfoGalleryLinks, + { + [bindOpts.bindIndex]: 2, + link: bound.link, + language, + } + ); + + bound.generatePreviousNextLinks = bindOpts( + generatePreviousNextLinks, + { + link: bound.link, + language, + } + ); + + bound.generateTrackListDividedByGroups = bindOpts( + generateTrackListDividedByGroups, + { + language, + wikiData, + } + ); + + bound.getGridHTML = bindOpts(getGridHTML, { + [bindOpts.bindIndex]: 0, + img, + language, + }); + + bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, { + [bindOpts.bindIndex]: 0, + getAlbumCover: bound.getAlbumCover, + getGridHTML: bound.getGridHTML, + link: bound.link, + language, + }); + + bound.getFlashGridHTML = bindOpts(getFlashGridHTML, { + [bindOpts.bindIndex]: 0, + getFlashCover: bound.getFlashCover, + getGridHTML: bound.getGridHTML, + link: bound.link, + }); + + bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, { + language, + }); + + bound.getRevealStringFromWarnings = bindOpts( + getRevealStringFromWarnings, + { + language, + } + ); + + bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { + to, + }); + + const pageInfo = page({ + ...bound, + + language, + + absoluteTo, + relativeTo: to, + to, + urls, + + getSizeOfAdditionalFile, + }); + + const oEmbedJSON = writePage.oEmbedJSON(pageInfo, { + language, + wikiData, + }); + + const oEmbedJSONHref = + oEmbedJSON && + wikiData.wikiInfo.canonicalBase && + wikiData.wikiInfo.canonicalBase + + urls + .from('shared.root') + .to('shared.path', paths.pathname + OEMBED_JSON_FILE); + + const html = writePage.html(pageInfo, { + defaultLanguage: finalDefaultLanguage, + language, + languages, + localizedPaths, + oEmbedJSONHref, + paths, + to, + transformMultiline: bound.transformMultiline, + wikiData, + }); + + return writePage.write({ + html, + oEmbedJSON, + paths, + }); + }), + ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => { + const title = titleFn({ + language, + }); + + // TODO: This only supports one <>-style argument. + const fromPaths = writePage.paths( + baseDirectory, + 'localized.' + fromPath[0], + fromPath[1] + ); + const to = writePage.to({ + baseDirectory, + pageSubKey: fromPath[0], + paths: fromPaths, + }); + + const target = to('localized.' + toPath[0], ...toPath.slice(1)); + const html = generateRedirectPage(title, target, {language}); + return writePage.write({html, paths: fromPaths}); + }), + ], + queueSize + ) + ); + }; + + await wrapLanguages(perLanguageFn, { + languages, + writeOneLanguage, + }); - // The single most important step. - logInfo`Written!`; + // The single most important step. + logInfo`Written!`; } -main().catch(error => { +main() + .catch((error) => { if (error instanceof AggregateError) { - showAggregate(error); + showAggregate(error); } else { - console.error(error); + console.error(error); } -}).then(() => { + }) + .then(() => { decorateTime.displayTime(); CacheableObject.showInvalidAccesses(); -}); + }); diff --git a/src/url-spec.js b/src/url-spec.js index 5c599416..bab97efa 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,93 +1,94 @@ +/** @format */ + import {withEntries} from './util/sugar.js'; const urlSpec = { - data: { - prefix: 'data/', + data: { + prefix: 'data/', - paths: { - root: '', - path: '<>', + paths: { + root: '', + path: '<>', - album: 'album/<>', - artist: 'artist/<>', - track: 'track/<>' - } + album: 'album/<>', + artist: 'artist/<>', + track: 'track/<>', }, + }, - localized: { - // TODO: Implement this. - // prefix: '_languageCode', + localized: { + // TODO: Implement this. + // prefix: '_languageCode', - paths: { - root: '', - path: '<>', + paths: { + root: '', + path: '<>', - home: '', + home: '', - album: 'album/<>/', - albumCommentary: 'commentary/album/<>/', + album: 'album/<>/', + albumCommentary: 'commentary/album/<>/', - artist: 'artist/<>/', - artistGallery: 'artist/<>/gallery/', + artist: 'artist/<>/', + artistGallery: 'artist/<>/gallery/', - commentaryIndex: 'commentary/', + commentaryIndex: 'commentary/', - flashIndex: 'flash/', - flash: 'flash/<>/', + flashIndex: 'flash/', + flash: 'flash/<>/', - groupInfo: 'group/<>/', - groupGallery: 'group/<>/gallery/', + groupInfo: 'group/<>/', + groupGallery: 'group/<>/gallery/', - listingIndex: 'list/', - listing: 'list/<>/', + listingIndex: 'list/', + listing: 'list/<>/', - newsIndex: 'news/', - newsEntry: 'news/<>/', + newsIndex: 'news/', + newsEntry: 'news/<>/', - staticPage: '<>/', - tag: 'tag/<>/', - track: 'track/<>/' - } + staticPage: '<>/', + tag: 'tag/<>/', + track: 'track/<>/', }, + }, - shared: { - paths: { - root: '', - path: '<>', + shared: { + paths: { + root: '', + path: '<>', - utilityRoot: 'util', - staticRoot: 'static', + utilityRoot: 'util', + staticRoot: 'static', - utilityFile: 'util/<>', - staticFile: 'static/<>' - } + utilityFile: 'util/<>', + staticFile: 'static/<>', }, - - media: { - prefix: 'media/', - - paths: { - root: '', - path: '<>', - - albumCover: 'album-art/<>/cover.<>', - albumWallpaper: 'album-art/<>/bg.<>', - albumBanner: 'album-art/<>/banner.<>', - trackCover: 'album-art/<>/<>.<>', - artistAvatar: 'artist-avatar/<>.<>', - flashArt: 'flash-art/<>.<>', - albumAdditionalFile: 'album-additional/<>/<>', - } - } + }, + + media: { + prefix: 'media/', + + paths: { + root: '', + path: '<>', + + albumCover: 'album-art/<>/cover.<>', + albumWallpaper: 'album-art/<>/bg.<>', + albumBanner: 'album-art/<>/banner.<>', + trackCover: 'album-art/<>/<>.<>', + artistAvatar: 'artist-avatar/<>.<>', + flashArt: 'flash-art/<>.<>', + albumAdditionalFile: 'album-additional/<>/<>', + }, + }, }; // This gets automatically switched in place when working from a baseDirectory, // so it should never be referenced manually. urlSpec.localizedWithBaseDirectory = { - paths: withEntries( - urlSpec.localized.paths, - entries => entries.map(([key, path]) => [key, '<>/' + path]) - ) + paths: withEntries(urlSpec.localized.paths, (entries) => + entries.map(([key, path]) => [key, '<>/' + path]) + ), }; export default urlSpec; 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})), + ]; } |