diff options
Diffstat (limited to 'src')
44 files changed, 13304 insertions, 11204 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 4afb0368..76efbd83 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -74,21 +74,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 +99,238 @@ 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; + const allowNull = update?.allowNull; + + 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; + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } - 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`); - } + #getUpdatePropertyValidateFunction(property) { + const descriptor = this.#getPropertyDescriptor(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..0ff56ad0 100644 --- a/src/data/patches.js +++ b/src/data/patches.js @@ -1,291 +1,309 @@ // --> Patch export class Patch { - static INPUT_NONE = 0; - static INPUT_CONSTANT = 1; - static INPUT_DIRECT_CONNECTION = 2; - static INPUT_MANAGED_CONNECTION = 3; + 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_UNAVAILABLE = 0; + static INPUT_AVAILABLE = 1; - static OUTPUT_UNAVAILABLE = 0; - static OUTPUT_AVAILABLE = 1; + static OUTPUT_UNAVAILABLE = 0; + static OUTPUT_AVAILABLE = 1; - static inputNames = []; inputNames = null; - static outputNames = []; outputNames = null; + static inputNames = []; + inputNames = null; + static outputNames = []; + outputNames = null; - manager = null; - inputs = Object.create(null); + manager = null; + inputs = Object.create(null); - constructor({ - manager, + constructor({ + manager, - inputNames, - outputNames, + inputNames, + outputNames, - inputs, - } = {}) { - this.inputNames = inputNames ?? this.constructor.inputNames; - this.outputNames = outputNames ?? this.constructor.outputNames; + inputs, + } = {}) { + this.inputNames = inputNames ?? this.constructor.inputNames; + this.outputNames = outputNames ?? this.constructor.outputNames; - manager?.addManagedPatch(this); + manager?.addManagedPatch(this); - if (inputs) { - Object.assign(this.inputs, inputs); - } - - 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; + } - attachToManager(manager) { - manager.addManagedPatch(this); + compute(inputs, outputs) { + // No-op. Return all outputs as unavailable. This should be overridden + // in subclasses. + + 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]; - } + patch.manager = null; - return true; + if (patch.manager === this) { + return false; } - dropManagedInput(identifier) { - return delete this.managedInputs[key]; + 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]; + } } - 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, 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[key]; + } + + 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, 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]]; } + } + + #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 @@ -295,84 +313,84 @@ 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}); -const P2 = new Patch[common].Echo({manager: PM}); +const P1 = new Patch[common].Stringify({ manager: PM }); +const P2 = new Patch[common].Echo({ manager: PM }); -PM.addExternalInput(P1, 'value', 'externalInput'); -PM.addManagedInput(P2, 'value', P1, 'value'); -PM.setExternalOutput('externalOutput', P2, 'value'); +PM.addExternalInput(P1, "value", "externalInput"); +PM.addManagedInput(P2, "value", P1, "value"); +PM.setExternalOutput("externalOutput", P2, "value"); PM.inputs.externalInput = [Patch.INPUT_CONSTANT, 123]; console.log(PM.computeOutputs()); diff --git a/src/data/serialize.js b/src/data/serialize.js index 9d4e8885..fc84d1ef 100644 --- a/src/data/serialize.js +++ b/src/data/serialize.js @@ -4,19 +4,19 @@ // 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 +24,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..62c01411 100644 --- a/src/data/things.js +++ b/src/data/things.js @@ -1,45 +1,45 @@ // 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 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, -} from './validators.js'; - -import * as S from './serialize.js'; + isAdditionalFileList, + isBoolean, + isColor, + isCommentary, + isCountingNumber, + isContributionList, + isDate, + isDimensions, + isDirectory, + isDuration, + isInstance, + isFileExtension, + isLanguageCode, + isName, + isNumber, + isURL, + isString, + isWholeNumber, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, +} from "./validators.js"; + +import * as S from "./serialize.js"; import { - getKebabCase, - sortAlbumsTracksChronologically, -} from '../util/wiki-data.js'; + getKebabCase, + sortAlbumsTracksChronologically, +} from "../util/wiki-data.js"; -import find from '../util/find.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 @@ -94,16 +94,16 @@ export class Language extends CacheableObject {} // Before initializing property descriptors, set additional independent // constants on the classes (which are referenced later). -Thing.referenceType = Symbol('Thing.referenceType'); +Thing.referenceType = Symbol("Thing.referenceType"); -Album[Thing.referenceType] = 'album'; -Track[Thing.referenceType] = 'track'; -Artist[Thing.referenceType] = 'artist'; -Group[Thing.referenceType] = 'group'; -ArtTag[Thing.referenceType] = 'tag'; -NewsEntry[Thing.referenceType] = 'news-entry'; -StaticPage[Thing.referenceType] = 'static'; -Flash[Thing.referenceType] = 'flash'; +Album[Thing.referenceType] = "album"; +Track[Thing.referenceType] = "track"; +Artist[Thing.referenceType] = "artist"; +Group[Thing.referenceType] = "group"; +ArtTag[Thing.referenceType] = "tag"; +NewsEntry[Thing.referenceType] = "news-entry"; +StaticPage[Thing.referenceType] = "static"; +Flash[Thing.referenceType] = "flash"; // -> Thing: base class for wiki data types, providing wiki-specific utility // functions on top of essential CacheableObject behavior. @@ -112,551 +112,580 @@ 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'), + 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} - }, + bannerStyle: Thing.common.simpleString(), + bannerFileExtension: Thing.common.fileExtension("jpg"), + bannerDimensions: { + flags: { update: true, expose: true }, + update: { validate: isDimensions }, + }, - 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), + 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), - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + commentary: Thing.common.commentary(), + additionalFiles: Thing.common.additionalFiles(), - // Update only + // Update only - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + groupData: Thing.common.wikiData(Group), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - 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'), + 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" + ), - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: Thing.common.commentatorArtists(), - tracks: { - flags: {expose: true}, + tracks: { + flags: { expose: true }, - 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), + 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 +694,1191 @@ 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}, - - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({ artistData, aliasedArtistRef }) => ( - (aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null) - ) - } - }, + isAlias: Thing.common.flag(), + aliasedArtistRef: Thing.common.singleReference(Artist), - 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(value) { + 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, - - // Update & expose + ...HomepageLayoutRow.propertyDescriptors, - type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } + // Update & expose - 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); - }, + $(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; - }, - - 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..714dc3a0 100644 --- a/src/data/validators.js +++ b/src/data/validators.js @@ -1,367 +1,387 @@ -import { withAggregate } from '../util/sugar.js'; +import { withAggregate } from "../util/sugar.js"; -import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js'; +import { color, ENABLE_COLOR, decorateTime } 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 }); } // 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'); +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); - 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 (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; } - }); + }); + } - 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); +export function validateReference(type = "track") { + 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)); +export function validateReferenceList(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..cfbb985a 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1,74 +1,69 @@ // 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 * 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, -} from './things.js'; + Album, + Artist, + ArtTag, + Flash, + FlashAct, + Group, + GroupCategory, + HomepageLayout, + HomepageLayoutAlbumsRow, + HomepageLayoutRow, + 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'; + decorateErrorWithIndex, + mapAggregate, + openAggregate, + showAggregate, + withAggregate, +} from "../util/sugar.js"; import { - decorateErrorWithIndex, - mapAggregate, - openAggregate, - showAggregate, - withAggregate, -} from '../util/sugar.js'; + sortAlbumsTracksChronologically, + sortAlphabetically, + sortChronologically, +} from "../util/wiki-data.js"; -import { - 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 -export const WIKI_INFO_FILE = 'wiki-info.yaml'; -export const BUILD_DIRECTIVE_DATA_FILE = 'build-directives.yaml'; -export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; -export const ARTIST_DATA_FILE = 'artists.yaml'; -export const FLASH_DATA_FILE = 'flashes.yaml'; -export const NEWS_DATA_FILE = 'news.yaml'; -export const ART_TAG_DATA_FILE = 'tags.yaml'; -export const GROUP_DATA_FILE = 'groups.yaml'; -export const STATIC_PAGE_DATA_FILE = 'static-pages.yaml'; +export const WIKI_INFO_FILE = "wiki-info.yaml"; +export const BUILD_DIRECTIVE_DATA_FILE = "build-directives.yaml"; +export const HOMEPAGE_LAYOUT_DATA_FILE = "homepage.yaml"; +export const ARTIST_DATA_FILE = "artists.yaml"; +export const FLASH_DATA_FILE = "flashes.yaml"; +export const NEWS_DATA_FILE = "news.yaml"; +export const ART_TAG_DATA_FILE = "tags.yaml"; +export const GROUP_DATA_FILE = "groups.yaml"; +export const STATIC_PAGE_DATA_FILE = "static-pages.yaml"; -export const DATA_ALBUM_DIRECTORY = 'album'; +export const DATA_ALBUM_DIRECTORY = "album"; // --> Document processing functions @@ -78,7 +73,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 +98,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 +578,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 +648,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; + function closeCurrentTrackGroup() { + if (currentTracksByRef) { + let trackGroup; - const trackRef = Thing.getReference(entry); - if (currentTracksByRef) { - currentTracksByRef.push(trackRef); - } else { - currentTracksByRef = [trackRef]; - } - } - - closeCurrentTrackGroup(); - - album.trackGroups = trackGroups; - albumData.push(album); + if (currentTrackGroup) { + trackGroup = currentTrackGroup; + } else { + trackGroup = new TrackGroup(); + trackGroup.name = `Default Track Group`; + trackGroup.isDefaultTrackGroup = true; } - return {albumData, trackData}; + trackGroup.album = album; + trackGroup.tracksByRef = currentTracksByRef; + trackGroups.push(trackGroup); + } } - }, - { - 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; - }) ?? []); - }); + for (const entry of entries) { + if (entry instanceof TrackGroup) { + closeCurrentTrackGroup(); + currentTracksByRef = []; + currentTrackGroup = entry; + continue; + } - return {artistData, artistAliasData}; - } - }, + trackData.push(entry); - // TODO: WD.wikiInfo.enableFlashesAndGames && - { - title: `Process flashes file`, - file: FLASH_DATA_FILE, + entry.dataSourceAlbumByRef = albumRef; - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Act' in document - ? processFlashActDocument(document) - : processFlashDocument(document)); - }, + const trackRef = Thing.getReference(entry); + if (currentTracksByRef) { + currentTracksByRef.push(trackRef); + } else { + currentTracksByRef = [trackRef]; + } + } - save(results) { - let flashAct; - let flashesByRef = []; + closeCurrentTrackGroup(); - if (results[0] && !(results[0] instanceof FlashAct)) { - throw new Error(`Expected an act at top of flash data file`); - } + album.trackGroups = trackGroups; + albumData.push(album); + } - for (const thing of results) { - if (thing instanceof FlashAct) { - if (flashAct) { - Object.assign(flashAct, {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); + }, - flashAct = thing; - flashesByRef = []; - } else { - flashesByRef.push(Thing.getReference(thing)); - } - } + save(results) { + let flashAct; + let flashesByRef = []; - 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`); + } - const flashData = results.filter(x => x instanceof Flash); - const flashActData = results.filter(x => x instanceof FlashAct); + for (const thing of results) { + if (thing instanceof FlashAct) { + if (flashAct) { + Object.assign(flashAct, { flashesByRef }); + } - return {flashData, flashActData}; + flashAct = thing; + flashesByRef = []; + } else { + flashesByRef.push(Thing.getReference(thing)); } - }, + } - { - title: `Process groups file`, - file: GROUP_DATA_FILE, + if (flashAct) { + Object.assign(flashAct, { flashesByRef }); + } - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Category' in document - ? processGroupCategoryDocument(document) - : processGroupDocument(document)); - }, + const flashData = results.filter((x) => x instanceof Flash); + const flashActData = results.filter((x) => x instanceof FlashAct); - save(results) { - let groupCategory; - let groupsByRef = []; + return { flashData, flashActData }; + }, + }, - if (results[0] && !(results[0] instanceof GroupCategory)) { - throw new Error(`Expected a category at top of group data file`); - } + { + title: `Process groups file`, + file: GROUP_DATA_FILE, - for (const thing of results) { - if (thing instanceof GroupCategory) { - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + documentMode: documentModes.allInOne, + processDocument(document) { + return "Category" in document + ? processGroupCategoryDocument(document) + : processGroupDocument(document); + }, - groupCategory = thing; - groupsByRef = []; - } else { - groupsByRef.push(Thing.getReference(thing)); - } - } + save(results) { + let groupCategory; + let groupsByRef = []; - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } - const groupData = results.filter(x => x instanceof Group); - const groupCategoryData = results.filter(x => x instanceof GroupCategory); + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, { groupsByRef }); + } - return {groupData, groupCategoryData}; + groupCategory = thing; + groupsByRef = []; + } else { + groupsByRef.push(Thing.getReference(thing)); } + } + + if (groupCategory) { + Object.assign(groupCategory, { groupsByRef }); + } + + const groupData = results.filter((x) => x instanceof Group); + const groupCategoryData = results.filter( + (x) => x instanceof GroupCategory + ); + + 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, 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; } - 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 +1210,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 +1288,166 @@ 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 +1456,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..d179e569 100644 --- a/src/file-size-preloader.js +++ b/src/file-size-preloader.js @@ -17,84 +17,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; + #paths = []; + #sizes = []; + #loadedPathIndex = -1; - #loadingPromise = null; - #resolveLoadingPromise = null; + #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(); - } - - #startLoadingPaths() { - if (this.#loadingPromise) { - return this.#loadingPromise; - } - - 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(); - } + this.#loadingPromise = new Promise((resolve) => { + this.#resolveLoadingPromise = resolve; + }); - let size; + this.#loadNextPath(); - const path = this.#paths[this.#loadedPathIndex + 1]; + return this.#loadingPromise; + } - 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(); + 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..9e78d38d 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -72,321 +72,336 @@ // unused). This is just to make the code more porta8le and sta8le, long-term, // since it avoids a lot of otherwise implic8ted maintenance. -'use strict'; +"use strict"; -const CACHE_FILE = 'thumbnail-cache.json'; +const CACHE_FILE = "thumbnail-cache.json"; const WARNING_DELAY_TIME = 10000; -import { spawn } from 'child_process'; -import { createHash } from 'crypto'; -import * as path from 'path'; +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 { - createReadStream -} from 'fs'; // Still gotta import from 8oth tho, for createReadStream. +import { readdir, readFile, writeFile } from "fs/promises"; // Whatcha know! Nice. -import { - logError, - logInfo, - logWarn, - parseOptions, - progressPromiseAll -} from './util/cli.js'; +import { createReadStream } from "fs"; // Still gotta import from 8oth tho, for createReadStream. import { - commandExists, - isMain, - promisifyProcess, -} from './util/node-utils.js'; + logError, + logInfo, + logWarn, + parseOptions, + progressPromiseAll, +} from "./util/cli.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)) + : [], + (err) => (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", (data) => 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]; - } + 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), + ]); - version = await getImageMagickVersion(fn); + return Promise.all([ + promisifyProcess(convert(".medium", { size: 400, quality: 95 }), false), + promisifyProcess(convert(".small", { size: 250, quality: 85 }), false), + ]); - if (version === null) { - return [`binary --version output didn't indicate it's ImageMagick`]; + return new Promise((resolve, reject) => { + if (Math.random() < 0.2) { + reject(new Error(`Them's the 8r8ks, kiddo!`)); + } else { + resolve(); } - - 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) - ]); +export default async function genThumbs( + mediaPath, + { queueSize = 0, quiet = false } = {} +) { + if (!mediaPath) { + throw new Error("Expected mediaPath to be passed"); + } - return Promise.all([ - promisifyProcess(convert('.medium', {size: 400, quality: 95}), false), - promisifyProcess(convert('.small', {size: 250, quality: 85}), false) - ]); + const quietInfo = quiet ? () => null : logInfo; - return new Promise((resolve, reject) => { - if (Math.random() < 0.2) { - reject(new Error(`Them's the 8r8ks, kiddo!`)); - } else { - resolve(); - } - }); -} + const filterFile = (name) => { + // TODO: Why is this not working???????? + // thumbnail-cache.json is 8eing passed through, for some reason. -export default async function genThumbs(mediaPath, { - queueSize = 0, - quiet = false -} = {}) { - if (!mediaPath) { - throw new Error('Expected mediaPath to be passed'); - } + const ext = path.extname(name); + if (ext !== ".jpg" && ext !== ".png") return false; - 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; + 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}`; + } + + 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 { - logInfo`Found ImageMagick binary: ${convertInfo}`; + 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); } - - 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); - } + } + + 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.`; } - - 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); + 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; + } } - - 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.`; - } + 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.`; } + } - // Technically we could pro8a8ly mut8te the cache varia8le in-place? - // 8ut that seems kinda iffy. - const updatedCache = Object.assign({}, cache); + // Technically we could pro8a8ly mut8te the cache varia8le in-place? + // 8ut that seems kinda iffy. + const updatedCache = Object.assign({}, cache); - 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 entriesToGenerate = imageToMD5Entries.filter( + ([filePath, md5]) => md5 !== cache[filePath] + ); - 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( - () => { + 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..92b9d9db 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,771 +1,968 @@ -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; import { - chunkByProperties, - getArtistNumContributions, - getTotalDuration, - sortAlphabetically, - sortChronologically, -} from './util/wiki-data.js'; + 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, language } + ) => 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 +977,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}; +export { listingSpec, listingTargetSpec }; diff --git a/src/misc-templates.js b/src/misc-templates.js index 61afa710..4f3a75d0 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -2,220 +2,283 @@ // These are made available right on a page spec's ({wikiData, language, ...}) // args object! -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from './util/html.js'; +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, -} from './util/wiki-data.js'; + 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 ''; +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)) - }); + 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), - size: language.formatFileSize(getFileSize(file)) - })}</li>` - : `<li>${language.$('releaseInfo.additionalFiles.file', { - file: linkFile(file) - })}</li>`); - }).join('\n')} + } + )}</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, 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(" "); + }) + ); } // Chronology links -export function generateChronologyLinks(currentThing, { - dateKey = 'date', +export function generateChronologyLinks( + currentThing, + { + dateKey = "date", contribKey, getThings, headingString, 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); - - if (!parts.length) { - return ''; - } - - const stringOpts = { - index: language.formatIndex(index + 1, {language}), - artist: link.artist(artist) - }; - - return fixWS` + 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); + + if (!parts.length) { + return ""; + } + + const stringOpts = { + index: language.formatIndex(index + 1, { language }), + artist: link.artist(artist), + }; + + 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>` +export function getRevealStringFromWarnings(warnings, { language }) { + 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})); +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 } + ) + ); } // 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'))} + ${language.$("releaseInfo.artTags")} + ${tags + .filter((tag) => !tag.isContentWarning) + .map(link.tag) + .join(",\n")} </p> - `} + ` + } </div> `; } @@ -223,288 +286,364 @@ 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` +export function getAlbumStylesheet(album, { to }) { + 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>`; +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>`; } -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>`; +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>`; } -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>`; +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>`; } // 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 + getFlashCover, + getGridHTML, + link, + ...props }) { - return getGridHTML({ - srcFn: getFlashCover, - linkFn: link.flash, - ...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 - ? to('localizedDefaultLanguage' + keySuffix, ...toArgs) - : to('localizedWithBaseDirectory' + keySuffix, language.code, ...toArgs)) - }, language.name))); - - return html.tag('div', - {class: 'footer-localization-links'}, - language.$('misc.uiLanguage', {languages: links.join('\n')})); +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 + ) + ) + ); + + 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..3c197239 100644 --- a/src/page/album-commentary.js +++ b/src/page/album-commentary.js @@ -2,143 +2,182 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import { - filterAlbumsByCommentary -} from '../util/wiki-data.js'; +import { filterAlbumsByCommentary } from "../util/wiki-data.js"; // Page exports -export function condition({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData).length; +export function condition({ wikiData }) { + return filterAlbumsByCommentary(wikiData.albumData).length; } -export function targets({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData); +export function targets({ wikiData }) { + 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` +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>${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>${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> + ${ + 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)}"> + ` + } + ${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')} + ` + ) + .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]; + `, + }, + + 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` +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>` + <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> + <p>${language.$("commentaryIndex.albumList.title")}</p> <ul> ${data - .map(({ album, entries, words }) => fixWS` - <li>${language.$('commentaryIndex.albumList.item', { + .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')} + words: language.formatWordCount(words, { + unit: true, + }), + entries: + language.countCommentaryEntries( + entries.length, + { unit: true } + ), + } + )}</li> + ` + ) + .join("\n")} </ul> </div> - ` - }, + `, + }, - nav: {simple: true} - }) - }; + nav: { simple: true }, + }), + }; - return [page]; + return [page]; } diff --git a/src/page/album.js b/src/page/album.js index c265fdc6..48747d7f 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -2,297 +2,394 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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, -} from '../util/wiki-data.js'; + getAlbumCover, + getAlbumListTag, + getTotalDuration, +} from "../util/wiki-data.js"; // Page exports -export function targets({wikiData}) { - return wikiData.albumData; +export function targets({ wikiData }) { + return wikiData.albumData; } -export function write(album, {wikiData}) { - const { wikiInfo } = wikiData; +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> + alt: language.$("misc.alt.albumCover"), + 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) - })), - language.$('releaseInfo.duration', { - duration: language.formatDuration(albumDuration, {approximate: album.tracks.length > 1}) - }) - ].filter(Boolean).join('<br>\n')} + 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")} </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, + 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")} </${listTag}></dd> - `).join('\n')} + ` + ) + .join("\n")} </dl> - ` : fixWS` + ` + : fixWS` <${listTag}> - ${album.tracks.map(trackToListItem).join('\n')} + ${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` - <p>${language.$('releaseInfo.artistCommentary')}</p> + 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, + }), - return [page, data]; + 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]; } // Utility functions -export function generateAlbumSidebar(album, currentTrack, { +export function generateAlbumSidebar( + album, + currentTrack, + { fancifyURL, getLinkThemeString, link, language, transformMultiline, - wikiData -}) { - const listTag = getAlbumListTag(album); + wikiData, + } +) { + const listTag = getAlbumListTag(album); - /* + /* const trackGroups = album.trackGroups || [{ name: language.$('albumSidebar.trackList.fallbackGroupName'), color: album.color, @@ -301,185 +398,254 @@ export function generateAlbumSidebar(album, currentTrack, { }]; */ - const { trackGroups } = album; + const { trackGroups } = album; - const trackToListItem = track => html.tag('li', - {class: track === currentTrack && 'current'}, - language.$('albumSidebar.trackList.item', { - track: link.track(track) - })); + 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 nameOrDefault = (isDefaultTrackGroup, name) => + isDefaultTrackGroup + ? language.$("albumSidebar.trackList.fallbackGroupName") + : name; - const trackListPart = fixWS` + 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}> - ${tracks.map(trackToListItem).join('\n')} + <${ + 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..4fc129e9 100644 --- a/src/page/artist-alias.js +++ b/src/page/artist-alias.js @@ -1,22 +1,21 @@ // Artist alias redirect pages. // (Makes old permalinks bring visitors to the up-to-date page.) -export function targets({wikiData}) { - return wikiData.artistAliasData; +export function targets({ wikiData }) { + return wikiData.artistAliasData; } -export function write(aliasArtist, {wikiData}) { - // This function doesn't actually use wikiData, 8ut, um, consistency? +export function write(aliasArtist, { wikiData }) { + // This function doesn't actually use wikiData, 8ut, um, consistency? - const { aliasedArtist } = 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..8be20106 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -4,515 +4,753 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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, -} from '../util/wiki-data.js'; + chunkByProperties, + getTotalDuration, + sortAlbumsTracksChronologically, + sortByDate, + sortByDirectory, + sortChronologically, +} from "../util/wiki-data.js"; // Page exports -export function targets({wikiData}) { - return wikiData.artistData; +export function targets({ wikiData }) { + 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) +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), + }, + })), + ["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, + to, + 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` - <p>${language.$('releaseInfo.note')}</p> + 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>`} - <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)) + 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) + ), })}</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> - <p>${language.$('artistPage.musicGroupsLine', { - groups: language.formatUnitList(musicGroups - .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: language.countContributions(contributions) - }))) + 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 + ), + }) + ) + ), })}</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>`} - <p>${language.$('artistPage.artGroupsLine', { - groups: language.formatUnitList(artGroups - .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: language.countContributions(contributions) - }))) + ` + } + ${ + 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 + ), + }) + ) + ), })}</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( + ({ + 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")} </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(({ 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")} </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, + 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> <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..4d8b9f11 100644 --- a/src/page/flash.js +++ b/src/page/flash.js @@ -2,251 +2,329 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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; +export function condition({ wikiData }) { + return wikiData.wikiInfo.enableFlashesAndGames; } -export function targets({wikiData}) { - return wikiData.flashData; +export function targets({ wikiData }) { + 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> +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> ${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` - <p>${language.$('releaseInfo.contributors')}</p> + ` + } + ${ + 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` - <h1>${language.$('flashIndex.title')}</h1> +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` + <h1>${language.$("flashIndex.title")}</h1> <div class="long-content"> - <p class="quick-info">${language.$('misc.jumpTo')}</p> + <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, { +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})`, + wikiData, + } +) { + const { flashData, wikiInfo } = 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> +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> <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', - {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', - {class: act === currentAct && 'current'}, - link.flash(act.flashes[0], {text: act.name})), - act === currentAct && fixWS` + ${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", + { class: act === currentAct && "current" }, + 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..bea9ecc7 100644 --- a/src/page/group.js +++ b/src/page/group.js @@ -2,268 +2,326 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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; +export function targets({ wikiData }) { + return wikiData.groupData; } -export function write(group, {wikiData}) { - const { listingSpec, wikiInfo } = wikiData; +export function write(group, { 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> + <h2>${language.$("groupInfoPage.albumList.title")}</h2> + <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', - {class: 'other-group-accent'}, - language.$('groupInfoPage.albumList.item.otherGroupAccent', { - group: link.groupInfo(otherGroup, {color: false}) - })) - }) - : item)); - }).join('\n')} + : 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")} </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', - {class: 'quick-info'}, - language.$('groupGalleryPage.anotherGroupLine', { - link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), { - text: language.$('groupGalleryPage.anotherGroupLine.link') - }) + 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" + ), + } + ), }) - )} + ) + } <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` - <h1>${language.$('groupSidebar.title')}</h1> - ${groupCategoryData.map(category => - html.tag('details', { + return { + content: fixWS` + <h1>${language.$("groupSidebar.title")}</h1> + ${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 urlKey = isGallery ? "localized.groupGallery" : "localized.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..ebe3a8d3 100644 --- a/src/page/homepage.js +++ b/src/page/homepage.js @@ -2,123 +2,184 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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` +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` <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` - <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> + ` + ) + .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> ${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..50fbd7a4 100644 --- a/src/page/index.js +++ b/src/page/index.js @@ -39,15 +39,15 @@ // These functions should be referenced only from adjacent modules, as they // pertain only to site page generation. -export * as album from './album.js'; -export * as albumCommentary from './album-commentary.js'; -export * as artist from './artist.js'; -export * as artistAlias from './artist-alias.js'; -export * as flash from './flash.js'; -export * as group from './group.js'; -export * as homepage from './homepage.js'; -export * as listing from './listing.js'; -export * as news from './news.js'; -export * as static from './static.js'; -export * as tag from './tag.js'; -export * as track from './track.js'; +export * as album from "./album.js"; +export * as albumCommentary from "./album-commentary.js"; +export * as artist from "./artist.js"; +export * as artistAlias from "./artist-alias.js"; +export * as flash from "./flash.js"; +export * as group from "./group.js"; +export * as homepage from "./homepage.js"; +export * as listing from "./listing.js"; +export * as news from "./news.js"; +export * as static from "./static.js"; +export * as tag from "./tag.js"; +export * as track from "./track.js"; diff --git a/src/page/listing.js b/src/page/listing.js index 447a0c8f..886c8a9d 100644 --- a/src/page/listing.js +++ b/src/page/listing.js @@ -10,193 +10,218 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; -import * as html from '../util/html.js'; +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; +export function condition({ wikiData }) { + return wikiData.wikiInfo.enableListings; } -export function targets({wikiData}) { - return wikiData.listingSpec; +export function targets({ wikiData }) { + return wikiData.listingSpec; } -export function write(listing, {wikiData}) { - if (listing.condition && !listing.condition({wikiData})) { - return null; - } +export function write(listing, { wikiData }) { + if (listing.condition && !listing.condition({ wikiData })) { + return null; + } - const { wikiInfo } = wikiData; + const { wikiInfo } = wikiData; - 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]; -} - -export function writeTargetless({wikiData}) { - const { albumData, trackData, wikiInfo } = wikiData; + ` + } + `, + }, - const totalDuration = getTotalDuration(trackData); - - const page = { - type: 'page', - path: ['listingIndex'], - page: ({ + sidebarLeft: { + content: generateSidebarForListings(listing, { getLinkThemeString, + link, language, - link - }) => ({ - title: language.$('listingIndex.title'), - - 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>` - })}</p> - <hr> - <p>${language.$('listingIndex.exploreList')}</p> - ${generateLinkIndexForListings(null, false, {link, language, wikiData})} - ` - }, - - sidebarLeft: { - content: generateSidebarForListings(null, { - getLinkThemeString, - link, - language, - wikiData - }) + wikiData, + }), + }, + + nav: { + linkContainerClasses: ["nav-links-hierarchy"], + links: [ + { toHome: true }, + { + path: ["localized.listingIndex"], + title: language.$("listingIndex.title"), }, + { toCurrentPage: true }, + ], + }, + }; + }, + }; + + return [page]; +} - nav: {simple: true} - }) - }; - - return [page]; -}; +export function writeTargetless({ wikiData }) { + const { albumData, trackData, wikiInfo } = wikiData; + + const totalDuration = getTotalDuration(trackData); + + const page = { + type: "page", + path: ["listingIndex"], + page: ({ getLinkThemeString, language, link }) => ({ + title: language.$("listingIndex.title"), + + 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>`, + })}</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]; +} // 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..2fc5d7b0 100644 --- a/src/page/news.js +++ b/src/page/news.js @@ -2,126 +2,135 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; // Page exports -export function condition({wikiData}) { - return wikiData.wikiInfo.enableNews; +export function condition({ wikiData }) { + return wikiData.wikiInfo.enableNews; } -export function targets({wikiData}) { - return wikiData.newsData; +export function targets({ wikiData }) { + 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` +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` <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` +export function writeTargetless({ wikiData }) { + 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` + <h1>${language.$("newsIndex.title")}</h1> + ${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 { 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, - 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..39acd64e 100644 --- a/src/page/static.js +++ b/src/page/static.js @@ -4,37 +4,34 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; // Page exports -export function targets({wikiData}) { - return wikiData.staticPageData; +export function targets({ wikiData }) { + 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, { wikiData }) { + const page = { + type: "page", + path: ["staticPage", staticPage.directory], + page: ({ language, 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..98b552b3 100644 --- a/src/page/tag.js +++ b/src/page/tag.js @@ -2,110 +2,111 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; // Page exports -export function condition({wikiData}) { - return wikiData.wikiInfo.enableArtTagUI; +export function condition({ wikiData }) { + return wikiData.wikiInfo.enableArtTagUI; } -export function targets({wikiData}) { - return wikiData.artTagData.filter(tag => !tag.isContentWarning); +export function targets({ wikiData }) { + return wikiData.artTagData.filter((tag) => !tag.isContentWarning); } -export function write(tag, {wikiData}) { - const { wikiInfo } = wikiData; - const { taggedInThings: things } = tag; +export function write(tag, { wikiData }) { + const { wikiInfo } = wikiData; + 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: ({ + generatePreviousNextLinks, + getAlbumCover, + getGridHTML, + getThemeString, + getTrackCover, + link, + language, + to, + }) => ({ + title: language.$("tagPage.title", { tag: tag.name }), + theme: getThemeString(tag.color), - 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}) + 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, + }), })}</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, { + generatePreviousNextLinks, + 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' - }); +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'}) - }) - }, - /* + 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})` } */ - ] - }; + ], + }; } diff --git a/src/page/track.js b/src/page/track.js index c4ec6c59..15316e8f 100644 --- a/src/page/track.js +++ b/src/page/track.js @@ -2,186 +2,221 @@ // Imports -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; import { - generateAlbumChronologyLinks, - generateAlbumNavLinks, - generateAlbumSecondaryNav, - generateAlbumSidebar -} from './album.js'; + generateAlbumChronologyLinks, + generateAlbumNavLinks, + generateAlbumSecondaryNav, + generateAlbumSidebar, +} from "./album.js"; -import * as html from '../util/html.js'; +import * as html from "../util/html.js"; -import { - bindOpts -} from '../util/sugar.js'; +import { bindOpts } from "../util/sugar.js"; import { - getTrackCover, - getAlbumListTag, - sortChronologically, -} from '../util/wiki-data.js'; + getTrackCover, + getAlbumListTag, + sortChronologically, +} from "../util/wiki-data.js"; // Page exports -export function targets({wikiData}) { - return wikiData.trackData; +export function targets({ wikiData }) { + return wikiData.trackData; } -export function write(track, {wikiData}) { - const { groupData, wikiInfo } = wikiData; - const { album, referencedByTracks, referencedTracks, otherReleases } = track; +export function write(track, { wikiData }) { + const { groupData, wikiInfo } = wikiData; + const { album, referencedByTracks, referencedTracks, otherReleases } = track; - const listTag = getAlbumListTag(album); + const listTag = getAlbumListTag(album); - 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 - })))); - } + 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_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 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 unbound_generateTrackList = (tracks, {getArtistString, link, language}) => html.tag('ul', - tracks.map(track => unbound_getTrackItem(track, {getArtistString, link, language})) + const unbound_generateTrackList = ( + tracks, + { getArtistString, link, language } + ) => + html.tag( + "ul", + tracks.map((track) => + unbound_getTrackItem(track, { getArtistString, link, language }) + ) ); - 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, + transformInline, + 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 +226,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> + alt: language.$("misc.alt.trackCover"), + 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.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.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` - <p>${language.$('releaseInfo.alsoReleasedAs')}</p> + ${ + 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` - <p>${language.$('releaseInfo.contributors')}</p> + ` + } + ${ + 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` - <p>${language.$('releaseInfo.lyrics')}</p> + ` + } + ${ + track.lyrics && + fixWS` + <p>${language.$("releaseInfo.lyrics")}</p> <blockquote> ${transformLyrics(track.lyrics)} </blockquote> - `} - ${hasCommentary && fixWS` - <p>${language.$('releaseInfo.artistCommentary')}</p> + ` + } + ${ + 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..1a694d7e 100644 --- a/src/repl.js +++ b/src/repl.js @@ -1,70 +1,70 @@ -import * as os from 'os'; -import * as path from 'path'; -import * as repl from 'repl'; -import { fileURLToPath } from 'url'; -import { promisify } from 'util'; +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'; +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)); async function main() { - const miscOptions = await parseOptions(process.argv.slice(2), { - 'data-path': { - type: 'value' - }, + const miscOptions = await parseOptions(process.argv.slice(2), { + "data-path": { + type: "value", + }, - 'show-traces': { - type: 'flag' - }, + "show-traces": { + type: "flag", + }, - 'no-history': { - type: 'flag' - }, - }); + "no-history": { + type: "flag", + }, + }); - const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; - const showAggregateTraces = miscOptions['show-traces'] ?? false; - const disableHistory = miscOptions['no-history'] ?? false; + 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; - } + if (!dataPath) { + logError`Expected --data-path option or HSMUSIC_DATA to be set`; + return; + } - console.log('HSMusic data REPL'); + console.log("HSMusic data REPL"); - const wikiData = await quickLoadAllFromYAML(dataPath); - const replServer = repl.start(); + const wikiData = await quickLoadAllFromYAML(dataPath); + const replServer = repl.start(); - Object.assign( - replServer.context, - wikiData, - {wikiData, WD: wikiData} - ); + 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); - }); - } + 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..72fa9cc2 100644 --- a/src/static/client.js +++ b/src/static/client.js @@ -5,13 +5,9 @@ // // 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; @@ -20,190 +16,235 @@ let ready = false; // Localiz8tion nonsense ---------------------------------- -const language = document.documentElement.getAttribute('lang'); +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; - } +function rebase(href, rebaseKey = "rebaseLocalized") { + 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); + 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, "--flash-directory"); + return flashData.find((flash) => flash.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}`); +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}; + 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)); + 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 list = flashData.filter((flash) => !flash.act8r8k); + const flash = getFlash(document.body); + if (!flash) return { list }; + const flashIndex = list.indexOf(flash); + return { list, index: flashIndex }; } // 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); - } +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 + )); + } + }); } -const next = document.getElementById('next-button'); -const previous = document.getElementById('previous-button'); -const random = document.getElementById('random-button'); +const next = document.getElementById("next-button"); +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(); - } +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(); } + } }); -for (const reveal of document.querySelectorAll('.reveal')) { - reveal.addEventListener('click', event => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - } - }); +for (const reveal of document.querySelectorAll(".reveal")) { + 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'); -const elements2 = document.getElementsByClassName('js-show-once-data'); +const elements1 = document.getElementsByClassName("js-hide-once-data"); +const elements2 = document.getElementsByClassName("js-show-once-data"); -for (const element of elements1) element.style.display = 'block'; +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") + ); + artistNames = artistData + .filter((artist) => !artist.alias) + .map((artist) => artist.name); - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; + 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 +257,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); +function link(a, type, { name, directory, color }) { + 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(evt) { + 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 +469,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..230dad21 100644 --- a/src/static/lazy-loading.js +++ b/src/static/lazy-loading.js @@ -7,45 +7,45 @@ 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"); + 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); } + } } -document.addEventListener('DOMContentLoaded', lazyLoadMain); +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..576166aa 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -31,153 +31,145 @@ // Oh yeah, like. Just run this through some relatively recent version of // 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 * as path from "path"; +import { promisify } from "util"; +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. -import fixWS from 'fix-whitespace'; +import fixWS from "fix-whitespace"; // Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast- // crunch. THAT is my 8est li8rary. // It stands for "HTML Entities", apparently. Cursed. -import he from 'he'; +import he from "he"; import { - copyFile, - mkdir, - readFile, - stat, - symlink, - writeFile, - unlink, -} from 'fs/promises'; + copyFile, + mkdir, + readFile, + stat, + symlink, + writeFile, + unlink, +} from "fs/promises"; -import { inspect as nodeInspect } from 'util'; +import { inspect as nodeInspect } from "util"; -import genThumbs from './gen-thumbs.js'; -import { listingSpec, listingTargetSpec } from './listing-spec.js'; -import urlSpec from './url-spec.js'; -import * as pageSpecs from './page/index.js'; +import genThumbs from "./gen-thumbs.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 * as html from './util/html.js'; -import unbound_link, {getLinkThemeString} from './util/link.js'; -import { findFiles } from './util/io.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 CacheableObject from './data/cacheable-object.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 { - filterDuplicateDirectories, - filterReferenceErrors, - linkWikiDataArrays, - loadAndProcessDataDocuments, - sortWikiDataArrays, - WIKI_INFO_FILE, -} from './data/yaml.js'; +import { Language } from "./data/things.js"; import { - fancifyFlashURL, - fancifyURL, - generateAdditionalFilesShortcut, - generateAdditionalFilesList, - generateChronologyLinks, - generateCoverLink, - generateInfoGalleryLinks, - generatePreviousNextLinks, - generateTrackListDividedByGroups, - getAlbumGridHTML, - getAlbumStylesheet, - getArtistString, - getFlashGridHTML, - getFooterLocalizationLinks, - getGridHTML, - getRevealStringFromTags, - getRevealStringFromWarnings, - getThemeString, - iconifyURL -} from './misc-templates.js'; + filterDuplicateDirectories, + filterReferenceErrors, + linkWikiDataArrays, + loadAndProcessDataDocuments, + sortWikiDataArrays, + WIKI_INFO_FILE, +} from "./data/yaml.js"; import { - color, - decorateTime, - logWarn, - logInfo, - logError, - parseOptions, - progressPromiseAll, - ENABLE_COLOR -} from './util/cli.js'; + fancifyFlashURL, + fancifyURL, + generateAdditionalFilesShortcut, + generateAdditionalFilesList, + generateChronologyLinks, + generateCoverLink, + generateInfoGalleryLinks, + generatePreviousNextLinks, + generateTrackListDividedByGroups, + getAlbumGridHTML, + getAlbumStylesheet, + getArtistString, + getFlashGridHTML, + getFooterLocalizationLinks, + getGridHTML, + getRevealStringFromTags, + getRevealStringFromWarnings, + getThemeString, + iconifyURL, +} from "./misc-templates.js"; import { - validateReplacerSpec, - transformInline -} from './util/replacer.js'; + color, + decorateTime, + logWarn, + logInfo, + logError, + parseOptions, + progressPromiseAll, + ENABLE_COLOR, +} from "./util/cli.js"; + +import { validateReplacerSpec, transformInline } from "./util/replacer.js"; import { - chunkByConditions, - chunkByProperties, - getAlbumCover, - getAlbumListTag, - getAllTracks, - getArtistAvatar, - getArtistNumContributions, - getFlashCover, - getKebabCase, - getTotalDuration, - getTrackCover, -} from './util/wiki-data.js'; + chunkByConditions, + chunkByProperties, + getAlbumCover, + getAlbumListTag, + getAllTracks, + getArtistAvatar, + getArtistNumContributions, + getFlashCover, + getKebabCase, + getTotalDuration, + getTrackCover, +} from "./util/wiki-data.js"; import { - serializeContribs, - serializeCover, - serializeGroupsForAlbum, - serializeGroupsForTrack, - serializeImagePaths, - serializeLink -} from './util/serialize.js'; + serializeContribs, + serializeCover, + serializeGroupsForAlbum, + serializeGroupsForTrack, + serializeImagePaths, + serializeLink, +} from "./util/serialize.js"; import { - bindOpts, - decorateErrorWithIndex, - filterAggregateAsync, - filterEmptyLines, - mapAggregate, - mapAggregateAsync, - openAggregate, - queue, - showAggregate, - splitArray, - unique, - withAggregate, - withEntries -} from './util/sugar.js'; - -import { - generateURLs, - thumb -} from './util/urls.js'; + bindOpts, + decorateErrorWithIndex, + filterAggregateAsync, + filterEmptyLines, + mapAggregate, + mapAggregateAsync, + openAggregate, + queue, + showAggregate, + splitArray, + unique, + withAggregate, + withEntries, +} from "./util/sugar.js"; + +import { generateURLs, thumb } from "./util/urls.js"; // Pensive emoji! import { - FANDOM_GROUP_DIRECTORY, - OFFICIAL_GROUP_DIRECTORY -} from './util/magic-constants.js'; + FANDOM_GROUP_DIRECTORY, + OFFICIAL_GROUP_DIRECTORY, +} from "./util/magic-constants.js"; -import FileSizePreloader from './file-size-preloader.js'; +import FileSizePreloader from "./file-size-preloader.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CACHEBUST = 10; -const DEFAULT_STRINGS_FILE = 'strings-default.json'; +const DEFAULT_STRINGS_FILE = "strings-default.json"; // Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted // site code should 8e put here. Which, uh, ~~only really means this one @@ -186,20 +178,20 @@ const DEFAULT_STRINGS_FILE = 'strings-default.json'; // Rather than hard code it, anything in this directory can 8e shared across // 8oth ends of the code8ase. // (This gets symlinked into the --data-path directory.) -const UTILITY_DIRECTORY = 'util'; +const UTILITY_DIRECTORY = "util"; // Code that's used only in the static site! CSS, cilent JS, etc. // (This gets symlinked into the --data-path directory.) -const STATIC_DIRECTORY = 'static'; +const STATIC_DIRECTORY = "static"; // This exists adjacent to index.html for any page with oEmbed metadata. -const OEMBED_JSON_FILE = 'oembed.json'; +const OEMBED_JSON_FILE = "oembed.json"; // Automatically copied (if present) from media directory to site root. -const FAVICON_FILE = 'favicon.ico'; +const FAVICON_FILE = "favicon.ico"; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, { colors: ENABLE_COLOR }); } // Shared varia8les! These are more efficient to access than a shared varia8le @@ -223,556 +215,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' - }, - '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) + 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; + } }, - '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(); +if (!validateReplacerSpec(replacerSpec, { find, link: unbound_link })) { + 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; - } +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; } - 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>'); + 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 +833,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 +844,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; - } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { - // Special case for specifically linking *from* a page with base - // directory *to* a page without! Used for the language switcher and - // hopefully nothing else oh god. - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = 'localized.' + subKey; - } else if (groupKey === 'localizedDefaultLanguage') { - // Linking to the default, except surprise, we're already IN the default - // (no baseDirectory set). - from = 'localized.' + pageSubKey; - to = 'localized.' + subKey; + if ( + groupKey !== "localized" && + groupKey !== "localizedDefaultLanguage" && + baseDirectory + ) { + from = "localizedWithBaseDirectory." + pageSubKey; + to = targetFullKey; + } else if (groupKey === "localizedDefaultLanguage" && baseDirectory) { + // Special case for specifically linking *from* a page with base + // directory *to* a page without! Used for the language switcher and + // hopefully nothing else oh god. + from = "localizedWithBaseDirectory." + pageSubKey; + to = "localized." + subKey; + } else if (groupKey === "localizedDefaultLanguage") { + // Linking to the default, except surprise, we're already IN the default + // (no baseDirectory set). + from = "localized." + pageSubKey; + to = "localized." + subKey; } else { - // If we're linking inside the localized area (or there just is no - // 8ase directory), the 8ase directory doesn't matter. - from = 'localized.' + pageSubKey; - to = targetFullKey; + // 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,486 +885,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', { - id: 'content', - class: main.classes - }, main.content); - - 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]; + 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 + ); - let { title: linkTitle } = cur; + const footerHTML = + footer.content && + html.tag( + "footer", + { + id: "footer", + 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]; + const prev = links[i - 1]; + const next = links[i + 1]; + + 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 - ? language.formatString('misc.pageTitle.withWikiName', { + <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 || ''})}> + <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; + if (error.code === "EPERM") { + await symlink(path.resolve(directory), file, "junction"); + } } - - 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'); - } - } - } -} - -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'), - - groupData?.some(group => group.directory === 'official') && - redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'), +function writeSharedFilesAndPages({ language, wikiData }) { + const { groupData, wikiInfo } = wikiData; - 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` +function generateRedirectPage(title, target, { language }) { + return fixWS` <!DOCTYPE html> <html> <head> - <title>${language.$('redirectPage.title', {title})}</title> + <title>${language.$("redirectPage.title", { title })}</title> <meta charset="utf-8"> <meta http-equiv="refresh" content="0;url=${target}"> <link rel="canonical" href="${target}"> @@ -1322,9 +1539,9 @@ function generateRedirectPage(title, target, {language}) { </head> <body> <main> - <h1>${language.$('redirectPage.title', {title})}</h1> - <p>${language.$('redirectPage.infoLine', { - target: `<a href="${target}">${target}</a>` + <h1>${language.$("redirectPage.title", { title })}</h1> + <p>${language.$("redirectPage.infoLine", { + target: `<a href="${target}">${target}</a>`, })}</p> </main> </body> @@ -1334,622 +1551,663 @@ 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' - ) +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"; } 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); +async function wrapLanguages(fn, { languages, writeOneLanguage = null }) { + 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; + Error.stackTraceLimit = Infinity; - const WD = wikiData; + const WD = wikiData; - WD.listingSpec = listingSpec; - WD.listingTargetSpec = listingTargetSpec; - - 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' - }, + WD.listingSpec = listingSpec; + WD.listingTargetSpec = listingTargetSpec; - // 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' - }, - - // 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; + + 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; + 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; + } - if (showInvalidPropertyAccesses) { - CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; + if (errorless) { + logInfo`All data processed without any errors - nice!`; + logInfo`(This means all source files will be fully accounted for during page generation.)`; } + } - const { - aggregate: processDataAggregate, - result: wikiDataResult - } = await loadAndProcessDataDocuments({dataPath}); + if (!WD.wikiInfo) { + logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`; + return; + } - Object.assign(wikiData, wikiDataResult); + let duplicateDirectoriesErrored = false; - { - 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; - } - - 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); + // 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(); + // Filter out any things with duplicate directories throughout the data, + // warning about them too. + filterAndShowDuplicateDirectories(); - // Filter out any reference errors throughout the data, warning about them - // too. - filterAndShowReferenceErrors(); + // Filter out any reference errors throughout the data, warning about them + // too. + filterAndShowReferenceErrors(); - // Sort data arrays so that they're all in order! This may use properties - // which are only available after the initial linking. - sortWikiDataArrays(wikiData); + // Sort data arrays so that they're all in order! This may use properties + // which are only available after the initial linking. + sortWikiDataArrays(wikiData); - const internalDefaultLanguage = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE)); + const internalDefaultLanguage = await processLanguageFile( + path.join(__dirname, DEFAULT_STRINGS_FILE) + ); - let languages; - if (langPath) { - const languageDataFiles = await findFiles(langPath, { - filter: f => path.extname(f) === '.json' - }); + let languages; + if (langPath) { + const languageDataFiles = await findFiles(langPath, { + filter: (f) => path.extname(f) === ".json", + }); - const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles - .map(file => processLanguageFile(file))); + const results = await progressPromiseAll( + `Reading & processing language files.`, + languageDataFiles.map((file) => processLanguageFile(file)) + ); - languages = Object.fromEntries(results.map(language => [language.code, language])); + 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 = {}; + logError`Be sure to specify ${"--lang"} or ${"HSMUSIC_LANG"} with the path to language files.`; } - - 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; - } else { - languages[internalDefaultLanguage.code] = internalDefaultLanguage; - finalDefaultLanguage = internalDefaultLanguage; + return; + } else { + languages[internalDefaultLanguage.code] = internalDefaultLanguage; + finalDefaultLanguage = internalDefaultLanguage; + } + + for (const language of Object.values(languages)) { + if (language === finalDefaultLanguage) { + continue; } - for (const language of Object.values(languages)) { - if (language === finalDefaultLanguage) { - continue; - } + 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 ?? [] + ) + ); - language.inheritedStrings = finalDefaultLanguage.strings; + for (const ref of tagRefs) { + if (find.artTag(ref, WD.artTagData)) { + tagRefs.delete(ref); + } } - 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.`; + if (tagRefs.size) { + for (const ref of Array.from(tagRefs).sort()) { + console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`); + } + return; } - - { - 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); - } + } + + 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 buildDictionary = pageSpecs; + + // 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(buildDictionary).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(", ") + }`; + + await writeFavicon(); + await writeSymlinks(); + await writeSharedFilesAndPages({ language: finalDefaultLanguage, wikiData }); + + const buildSteps = writeAll + ? Object.entries(buildDictionary) + : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]); + + 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; } - if (tagRefs.size) { - for (const ref of Array.from(tagRefs).sort()) { - console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`); - } - return; + // May still call writeTargetless if present. + if (!pageSpec.targets) { + return { flag, pageSpec, targets: [] }; } - } - - 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 buildDictionary = pageSpecs; - - // 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(buildDictionary) - .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(', ')}`; - - await writeFavicon(); - await writeSymlinks(); - await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData}); - - const buildSteps = (writeAll - ? Object.entries(buildDictionary) - : (Object.entries(buildDictionary) - .filter(([ flag ]) => writeFlags[flag]))); - - 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 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; - } - - 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 +2243,331 @@ async function main() { )); */ - const perLanguageFn = async (language, i, entries) => { - const baseDirectory = (language === finalDefaultLanguage ? '' : language.code); + 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`); + 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; + await progressPromiseAll( + `Writing ${language.code}`, + queue( + [ + ...pageWrites.map(({ type, ...props }) => () => { + const { path, page } = props; - // 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 paths = writePage.paths( - baseDirectory, - 'localized.' + pageSubKey, + // 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 - }); + ), + ]) + ); + + const paths = writePage.paths( + baseDirectory, + "localized." + pageSubKey, + directory + ); + + const to = writePage.to({ + baseDirectory, + pageSubKey, + paths, + }); - bound.parseAttributes = bindOpts(parseAttributes, { - to - }); + 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)) + ); + }; - bound.find = bindFind(wikiData, {mode: 'warn'}); + // TODO: Is there some nicer way to define these, + // may8e without totally re-8inding everything for + // each page? + const bound = {}; - bound.transformInline = bindOpts(transformInline, { - find: bound.find, - link: bound.link, - replacerSpec, - language, - to, - wikiData - }); + bound.link = withEntries(unbound_link, (entries) => + entries.map(([key, fn]) => [key, bindOpts(fn, { to })]) + ); - bound.transformMultiline = bindOpts(transformMultiline, { - transformInline: bound.transformInline, - parseAttributes: bound.parseAttributes - }); - - bound.transformLyrics = bindOpts(transformLyrics, { - transformInline: bound.transformInline, - transformMultiline: bound.transformMultiline - }); + bound.linkAnythingMan = bindOpts(linkAnythingMan, { + link: bound.link, + wikiData, + }); - bound.iconifyURL = bindOpts(iconifyURL, { - language, - to - }); + bound.parseAttributes = bindOpts(parseAttributes, { + to, + }); - bound.fancifyURL = bindOpts(fancifyURL, { - language - }); + bound.find = bindFind(wikiData, { mode: "warn" }); - bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { - [bindOpts.bindIndex]: 2, - language - }); + bound.transformInline = bindOpts(transformInline, { + find: bound.find, + link: bound.link, + replacerSpec, + language, + to, + wikiData, + }); - bound.getLinkThemeString = getLinkThemeString; + bound.transformMultiline = bindOpts(transformMultiline, { + transformInline: bound.transformInline, + parseAttributes: bound.parseAttributes, + }); - bound.getThemeString = getThemeString; + bound.transformLyrics = bindOpts(transformLyrics, { + transformInline: bound.transformInline, + transformMultiline: bound.transformMultiline, + }); - bound.getArtistString = bindOpts(getArtistString, { - iconifyURL: bound.iconifyURL, - link: bound.link, - language - }); + bound.iconifyURL = bindOpts(iconifyURL, { + language, + to, + }); - bound.getAlbumCover = bindOpts(getAlbumCover, { - to - }); + bound.fancifyURL = bindOpts(fancifyURL, { + language, + }); - bound.getTrackCover = bindOpts(getTrackCover, { - to - }); + bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { + [bindOpts.bindIndex]: 2, + language, + }); - bound.getFlashCover = bindOpts(getFlashCover, { - to - }); + bound.getLinkThemeString = getLinkThemeString; - bound.getArtistAvatar = bindOpts(getArtistAvatar, { - to - }); + bound.getThemeString = getThemeString; - bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, { - language - }); + bound.getArtistString = bindOpts(getArtistString, { + iconifyURL: bound.iconifyURL, + link: bound.link, + language, + }); - bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, { - language - }); + bound.getAlbumCover = bindOpts(getAlbumCover, { + to, + }); - bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { - link: bound.link, - linkAnythingMan: bound.linkAnythingMan, - language, - wikiData - }); + bound.getTrackCover = bindOpts(getTrackCover, { + to, + }); - bound.generateCoverLink = bindOpts(generateCoverLink, { - [bindOpts.bindIndex]: 0, - img, - link: bound.link, - language, - to, - wikiData - }); + bound.getFlashCover = bindOpts(getFlashCover, { + to, + }); - bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, { - [bindOpts.bindIndex]: 2, - link: bound.link, - language - }); + bound.getArtistAvatar = bindOpts(getArtistAvatar, { + to, + }); - bound.generatePreviousNextLinks = bindOpts(generatePreviousNextLinks, { - link: bound.link, - language - }); + bound.generateAdditionalFilesShortcut = bindOpts( + generateAdditionalFilesShortcut, + { + language, + } + ); + + bound.generateAdditionalFilesList = bindOpts( + generateAdditionalFilesList, + { + language, + } + ); + + bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { + link: bound.link, + linkAnythingMan: bound.linkAnythingMan, + language, + wikiData, + }); - bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, { - language, - wikiData, - }); + bound.generateCoverLink = bindOpts(generateCoverLink, { + [bindOpts.bindIndex]: 0, + img, + link: bound.link, + language, + to, + wikiData, + }); - bound.getGridHTML = bindOpts(getGridHTML, { - [bindOpts.bindIndex]: 0, - img, - language - }); + 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.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.getFlashGridHTML = bindOpts(getFlashGridHTML, { + [bindOpts.bindIndex]: 0, + getFlashCover: bound.getFlashCover, + getGridHTML: bound.getGridHTML, + link: bound.link, + }); - bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, { - language - }); + bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, { + language, + }); - bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, { - language - }); + bound.getRevealStringFromWarnings = bindOpts( + getRevealStringFromWarnings, + { + language, + } + ); - bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { - to - }); + bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { + to, + }); - const pageInfo = page({ - ...bound, + const pageInfo = page({ + ...bound, - language, + language, - absoluteTo, - relativeTo: to, - to, - urls, + absoluteTo, + relativeTo: to, + to, + urls, - getSizeOfAdditionalFile, - }); + getSizeOfAdditionalFile, + }); - const oEmbedJSON = writePage.oEmbedJSON(pageInfo, { - language, - wikiData, - }); + 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 - }); + 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}) => () => { + return writePage.write({ + html, + oEmbedJSON, + paths, + }); + }), + ...redirectWrites.map( + ({ fromPath, toPath, title: titleFn }) => + () => { const title = titleFn({ - language + 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 fromPaths = writePage.paths( + baseDirectory, + "localized." + fromPath[0], + fromPath[1] + ); + const to = writePage.to({ + baseDirectory, + pageSubKey: fromPath[0], + paths: fromPaths, + }); - await wrapLanguages(perLanguageFn, { - languages, - writeOneLanguage, - }); + 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..cd35abed 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,93 +1,92 @@ -import {withEntries} from './util/sugar.js'; +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..e073bed8 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -5,47 +5,52 @@ 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'); -}; + w("\n"); + }; export const logInfo = logColor(2); export const logWarn = logColor(33); @@ -53,205 +58,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..4450a49f 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -3,23 +3,31 @@ // 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..49a3a19a 100644 --- a/src/util/find.js +++ b/src/util/find.js @@ -1,126 +1,131 @@ -import { - color, - logError, - logWarn -} from './cli.js'; +import { color, logError, logWarn } from "./cli.js"; -import { inspect } from 'util'; +import { inspect } from "util"; function warnOrThrow(mode, message) { - switch (mode) { - case 'error': - throw new Error(message); - case 'warn': - logWarn(message); - default: - return null; - } + switch (mode) { + case "error": + throw new Error(message); + case "warn": + logWarn(message); + default: + return null; + } } function findHelper(keys, findFns = {}) { - // Note: This cache explicitly *doesn't* support mutable data arrays. If the - // data array is modified, make sure it's actually a new array object, not - // the original, or the cache here will break and act as though the data - // hasn't changed! - const cache = new WeakMap(); - - const byDirectory = findFns.byDirectory || matchDirectory; - const byName = findFns.byName || matchName; - - const keyRefRegex = new RegExp(String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`); - - // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws - // errors for null matches (with details about the error), while 'warn' and - // 'quiet' both return null, with 'warn' logging details directly to the - // console. - return (fullRef, data, {mode = 'warn'} = {}) => { - if (!fullRef) return null; - if (typeof fullRef !== 'string') { - throw new Error(`Got a reference that is ${typeof fullRef}, not string: ${fullRef}`); - } - - if (!data) { - throw new Error(`Expected data to be present`); - } - - if (!Array.isArray(data) && data.wikiData) { - throw new Error(`Old {wikiData: {...}} format provided`); - } - - let cacheForThisData = cache.get(data); - const cachedValue = cacheForThisData?.[fullRef]; - if (cachedValue) { - globalThis.NUM_CACHE = (globalThis.NUM_CACHE || 0) + 1; - return cachedValue; - } - if (!cacheForThisData) { - cacheForThisData = Object.create(null); - cache.set(data, cacheForThisData); - } - - const match = fullRef.match(keyRefRegex); - if (!match) { - return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`); - } - - const key = match[1]; - const ref = match[2]; - - const found = (key - ? byDirectory(ref, data, mode) - : byName(ref, data, mode)); - - if (!found) { - warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); - } - - cacheForThisData[fullRef] = found; - - return found; - }; -} + // 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]; - if (ref !== thing.name) { - warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`); + const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode); + + if (!found) { + warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); } - return thing; + cacheForThisData[fullRef] = found; + + return found; + }; +} + +function matchDirectory(ref, data, mode) { + return data.find(({ directory }) => directory === ref); +} + +function matchName(ref, data, mode) { + const matches = data.filter( + ({ name }) => name.toLowerCase() === ref.toLowerCase() + ); + + if (matches.length > 1) { + return warnOrThrow( + mode, + `Multiple matches for reference "${ref}". Please resolve:\n` + + matches.map((match) => `- ${inspect(match)}\n`).join("") + + `Returning null for this reference.` + ); + } + + if (matches.length === 0) { + return null; + } + + const thing = matches[0]; + + if (ref !== thing.name) { + warnOrThrow( + mode, + `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}` + ); + } + + return thing; } function matchTagName(ref, data, quiet) { - return matchName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data, quiet); + 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 +136,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 - ? findFn(ref, thingData, {...opts1, ...opts2}) - : findFn(ref, thingData, opts1)) - : (ref, opts2) => (opts2 - ? findFn(ref, thingData, opts2) - : findFn(ref, thingData)))]; - })); + return Object.fromEntries( + Object.entries({ + album: "albumData", + artist: "artistData", + artTag: "artTagData", + flash: "flashData", + group: "groupData", + listing: "listingSpec", + newsEntry: "newsData", + staticPage: "staticPageData", + track: "trackData", + }).map(([key, value]) => { + const findFn = find[key]; + const thingData = wikiData[value]; + return [ + key, + opts1 + ? (ref, opts2) => + opts2 + ? findFn(ref, thingData, { ...opts1, ...opts2 }) + : findFn(ref, thingData, opts1) + : (ref, opts2) => + opts2 ? findFn(ref, thingData, opts2) : findFn(ref, thingData), + ]; + }) + ); } diff --git a/src/util/html.js b/src/util/html.js index a9b4bb9b..ceca5966 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -3,19 +3,19 @@ // 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 +24,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..c17e2633 100644 --- a/src/util/io.js +++ b/src/util/io.js @@ -1,14 +1,14 @@ // Utility functions for interacting with files and other external data // interfacey constructs. -import { readdir } from 'fs/promises'; -import * as path from 'path'; +import { readdir } from "fs/promises"; +import * as path from "path"; -export async function findFiles(dataPath, { - filter = f => true, - joinParentDirectory = true, -} = {}) { - return (await readdir(dataPath)) - .filter(file => filter(file)) - .map(file => joinParentDirectory ? path.join(dataPath, file) : file); +export async function findFiles( + dataPath, + { filter = (f) => true, joinParentDirectory = true } = {} +) { + return (await readdir(dataPath)) + .filter((file) => filter(file)) + .map((file) => (joinParentDirectory ? path.join(dataPath, file) : file)); } diff --git a/src/util/link.js b/src/util/link.js index 68539621..0e3be3e5 100644 --- a/src/util/link.js +++ b/src/util/link.js @@ -9,108 +9,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}); +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 (link.globalOptions.appendIndexHTML) { + if (appendIndexHTMLRegex.test(href)) { + href += "index.html"; + } + } - if (hash) { - href += (hash.startsWith('#') ? '' : '#') + hash; - } + 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) - }; + return html.tag( + "a", + { + ...(attr ? attr(thing) : {}), + ...(attributes ? attributes : {}), + href, + style: + typeof color2 === "string" + ? getLinkThemeString(color2) + : color2 && color + ? getLinkThemeString(thing.color) + : "", + class: className, + }, + text || thing.name + ); + }; -const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => - linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { - attr: thing => ({ - ...attr ? attr(thing) : {}, - ...expose ? {[expose]: thing.directory} : {} - }), - ...conf - }); +const linkDirectory = (key, { expose = null, attr = null, ...conf } = {}) => + linkHelper((thing, { to }) => to("localized." + key, thing.directory), { + attr: (thing) => ({ + ...(attr ? attr(thing) : {}), + ...(expose ? { [expose]: thing.directory } : {}), + }), + ...conf, + }); -const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); -const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf); +const 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 - }, + 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'}), + 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({ + // 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..c59e14aa 100644 --- a/src/util/magic-constants.js +++ b/src/util/magic-constants.js @@ -6,5 +6,5 @@ // All such uses should eventually be replaced with better code in due time // (TM). -export const OFFICIAL_GROUP_DIRECTORY = 'official'; -export const FANDOM_GROUP_DIRECTORY = 'fandom'; +export const OFFICIAL_GROUP_DIRECTORY = "official"; +export const FANDOM_GROUP_DIRECTORY = "fandom"; diff --git a/src/util/node-utils.js b/src/util/node-utils.js index ad87cae3..889a276c 100644 --- a/src/util/node-utils.js +++ b/src/util/node-utils.js @@ -1,40 +1,43 @@ // Utility functions which are only relevant to particular Node.js constructs. -import { fileURLToPath } from 'url'; +import { fileURLToPath } from "url"; -import _commandExists from 'command-exists'; +import _commandExists from "command-exists"; // This package throws an error instead of returning false when the command // doesn't exist, for some reason. Yay for making logic more difficult! // Here's a straightforward workaround. export function commandExists(command) { - return _commandExists(command).then(() => true, () => false); + 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) + // 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); - } + 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); - } - }) - }) + 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 +45,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..311f7633 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -1,429 +1,460 @@ -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; - } +import { logError, logWarn } from "./cli.js"; +import { escapeRegex } from "./sugar.js"; + +export function validateReplacerSpec(replacerSpec, { find, link }) { + let success = true; + + for (const [ + key, + { link: linkKey, find: findKey, value, html }, + ] of Object.entries(replacerSpec)) { + if (!html && !link[linkKey]) { + logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`; + success = false; } + if (findKey && !find[findKey]) { + logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`; + success = false; + } + } - return success; + return success; } // Syntax literals. -const tagBeginning = '[['; -const tagEnding = ']]'; -const tagReplacerValue = ':'; -const tagHash = '#'; -const tagArgument = '*'; -const tagArgumentValue = '='; -const tagLabel = '|'; +const tagBeginning = "[["; +const tagEnding = "]]"; +const tagReplacerValue = ":"; +const tagHash = "#"; +const tagArgument = "*"; +const tagArgumentValue = "="; +const tagLabel = "|"; -const noPrecedingWhitespace = '(?<!\\s)'; +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 makeError = (i, message) => ({ i, type: "error", data: { message } }); +const endOfInput = (i, comment) => + makeError(i, `Unexpected end of input (${comment}).`); // These are 8asically stored on the glo8al scope, which might seem odd // for a recursive function, 8ut the values are only ever used immediately // after they're set. -let stopped, - stop_iMatch, - stop_iParse, - stop_literal; +let stopped, stop_iMatch, 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; - - stopped = false; + let nodes = []; + let escapeNext = false; + let string = ""; + let iString = 0; - const pushTextNode = (isLast) => { - string = input.slice(iString, i); + stopped = false; - // 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(); - } + const pushTextNode = (isLast) => { + string = input.slice(iString, i); - 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 (regexpCache.hasOwnProperty(regexpSource)) { + regexp = regexpCache[regexpSource]; + } else { + regexp = new RegExp(regexpSource); + regexpCache[regexpSource] = regexp; + } + + // Skip whitespace at the start of parsing. This is run every time + // parseNodes is called (and thus parseOneTextNode too), so spaces + // at the start of syntax elements will always 8e skipped. We don't + // skip whitespace that shows up inside content (i.e. once we start + // parsing below), though! + const whitespaceOffset = input.slice(i).search(/[^\s]/); + + // If the string is all whitespace, that's just zero content, so + // return the empty nodes array. + if (whitespaceOffset === -1) { + return nodes; + } - i += whitespaceOffset; + 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_iMatch = closestMatchIndex; + 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; -}; + return nodes; +} export function parseInput(input) { - try { - return parseNodes(input, 0); - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; - } + try { + return parseNodes(input, 0); + } catch (errorNode) { + if (errorNode.type !== "error") { + throw errorNode; + } - const { i, data: { message } } = errorNode; + const { + i, + data: { message }, + } = errorNode; - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; - } + 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; - } + let lineEnd = input.slice(i).indexOf("\n"); + if (lineEnd >= 0) { + lineEnd += i; + } else { + lineEnd = input.length; + } - const line = input.slice(lineStart, lineEnd); + const line = input.slice(lineStart, lineEnd); - const cursor = i - lineStart; + const cursor = i - lineStart; - throw new SyntaxError(fixWS` + throw new SyntaxError(fixWS` Parse error (at pos ${i}): ${message} ${line} - ${'-'.repeat(cursor) + '^'} + ${"-".repeat(cursor) + "^"} `); - } + } } 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; - } - - 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; - } + 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; + } + + 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..57736cf4 100644 --- a/src/util/serialize.js +++ b/src/util/serialize.js @@ -1,71 +1,70 @@ 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) - }; +export function serializeImagePaths(original, { thumb }) { + return { + original, + medium: thumb.medium(original), + small: thumb.small(original), + }; } -export function serializeCover(thing, pathFunction, { - serializeImagePaths, - urls -}) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to - }); +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..70672bfd 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -6,69 +6,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 +88,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 +120,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); - }; - - aggregate.nest = (...args) => { - return aggregate.call(() => withAggregate(...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.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'); +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 +234,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 +276,174 @@ 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; + } + + 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 }; }); - - 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, lines) => + 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..8fc2aba7 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -8,117 +8,133 @@ // actual path strings. More a8stract operations using wiki data o8jects is // the domain of link.js. -import * as path from 'path'; -import { withEntries } from './sugar.js'; +import * as path from "path"; +import { withEntries } from "./sugar.js"; export function generateURLs(urlSpec) { - const getValueForFullKey = (obj, fullKey, prop = null) => { - const [ groupKey, subKey ] = fullKey.split('.'); - if (!groupKey || !subKey) { - throw new Error(`Expected group key and subkey (got ${fullKey})`); - } - - if (!obj.hasOwnProperty(groupKey)) { - throw new Error(`Expected valid group key (got ${groupKey})`); - } - - const group = obj[groupKey]; - - if (!group.hasOwnProperty(subKey)) { - throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); - } - - return { - value: group[subKey], - group - }; + const 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, }; + }; - // This should be called on values which are going to be passed to - // path.relative, because relative will resolve a leading slash as the root - // directory of the working device, which we aren't looking for here. - const trimLeadingSlash = P => P.startsWith('/') ? P.slice(1) : P; + // This should be called on values which are going to be passed to + // path.relative, because relative will resolve a leading slash as the root + // directory of the working device, which we aren't looking for here. + const trimLeadingSlash = (P) => (P.startsWith("/") ? P.slice(1) : P); - const generateTo = (fromPath, fromGroup) => { - const A = trimLeadingSlash(fromPath); + const generateTo = (fromPath, fromGroup) => { + const A = trimLeadingSlash(fromPath); - const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + const rebasePrefix = "../".repeat( + (fromGroup.prefix || "").split("/").filter(Boolean).length + ); - const pathHelper = (toPath, toGroup) => { - let B = trimLeadingSlash(toPath); + const pathHelper = (toPath, toGroup) => { + let B = trimLeadingSlash(toPath); - let argIndex = 0; - B = B.replaceAll('<>', () => `<${argIndex++}>`); + let argIndex = 0; + B = B.replaceAll("<>", () => `<${argIndex++}>`); - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - B = rebasePrefix + (toGroup.prefix || '') + B; - } + if (toGroup.prefix !== fromGroup.prefix) { + // TODO: Handle differing domains in prefixes. + B = rebasePrefix + (toGroup.prefix || "") + B; + } - const suffix = (toPath.endsWith('/') ? '/' : ''); + const suffix = toPath.endsWith("/") ? "/" : ""; - return { - posix: path.posix.relative(A, B) + suffix, - device: path.relative(A, B) + suffix - }; - }; - - 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..f7610fdb 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -3,63 +3,64 @@ // 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 +72,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 +138,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 @@ -249,20 +252,23 @@ export function sortByConditions(data, conditions) { // Expects thing properties: // * directory (or override getDirectory) // * name (or override getName) -export function sortAlphabetically(data, {getDirectory, getName} = {}) { - sortByDirectory(data, {getDirectory}); - sortByName(data, {getName}); - return data; +export function sortAlphabetically(data, { getDirectory, getName } = {}) { + sortByDirectory(data, { getDirectory }); + sortByName(data, { getName }); + return data; } // Expects thing properties: // * directory (or override getDirectory) // * name (or override getName) // * date (or override getDate) -export function sortChronologically(data, {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 @@ -273,44 +279,46 @@ export function sortChronologically(data, {getDirectory, getName, getDate} = {}) // release date but can be overridden) above all else. // // 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]); +export function sortAlbumsTracksChronologically(data, { getDate } = {}) { + // Sort albums before tracks... + sortByConditions(data, [(t) => t.album === undefined]); - // Group tracks by album... - sortByDirectory(data, { - getDirectory: t => (t.album ? t.album.directory : t.directory) - }); + // 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; - } +export function getAlbumCover(album, { to }) { + // Some albums don't have art! This function returns null in that case. + if (album.hasCoverArt) { + return to("media.albumCover", album.directory, album.coverArtFileExtension); + } else { + return null; + } } export function getAlbumListTag(album) { - return (album.hasTrackNumbers ? 'ol' : 'ul'); + return album.hasTrackNumbers ? "ol" : "ul"; } // This gets all the track o8jects defined in every al8um, and sorts them 8y @@ -331,157 +339,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); +export function getFlashCover(flash, { to }) { + 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); - } +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 + ); + } } -export function getArtistAvatar(artist, {to}) { - return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); +export function getArtistAvatar(artist, { to }) { + return to("media.artistAvatar", artist.directory, artist.avatarFileExtension); } // Big-ass homepage row functions -export function getNewAdditions(numAlbums, {wikiData}) { - const { albumData } = wikiData; - - // Sort al8ums, in descending order of priority, 8y... - // - // * D8te of addition to the wiki (descending). - // * Major releases first. - // * D8te of release (descending). - // - // Major releases go first to 8etter ensure they show up in the list (and - // are usually at the start of the final output for a given d8 of release - // too). - const sortedAlbums = albumData.filter(album => album.isListedOnHomepage).sort((a, b) => { - if (a.dateAddedToWiki > b.dateAddedToWiki) return -1; - if (a.dateAddedToWiki < b.dateAddedToWiki) return 1; - if (a.isMajorRelease && !b.isMajorRelease) return -1; - if (!a.isMajorRelease && b.isMajorRelease) return 1; - if (a.date > b.date) return -1; - if (a.date < b.date) return 1; +export function getNewAdditions(numAlbums, { 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; }); - // 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; +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 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); + 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})) - ]; + return [ + ...majorReleases.map((album) => ({ large: true, item: album })), + ...otherReleases.map((album) => ({ large: false, item: album })), + ]; } |