diff options
Diffstat (limited to 'src')
417 files changed, 10359 insertions, 5427 deletions
diff --git a/src/aggregate.js b/src/aggregate.js index cb806e89..3ff1846b 100644 --- a/src/aggregate.js +++ b/src/aggregate.js @@ -371,11 +371,12 @@ export function _withAggregate(mode, aggregateOpts, fn) { } export const unhelpfulTraceLines = [ - /sugar/, - /sort/, - /aggregate/, - /composite/, - /cacheable-object/, + /sugar\.js/, + /sort\.js/, + /aggregate\.js/, + /composite\.js/, + /cacheable-object\.js/, + /html\.js/, /node:/, /<anonymous>/, ]; @@ -604,7 +605,7 @@ export function showAggregate(topError, { headerPart += ` ${colors.dim(tracePart)}`; } - const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa'); + const head1 = '\u21aa'; const bar1 = ' '; const causePart = diff --git a/src/cli.js b/src/cli.js index bd4ec685..ec72a625 100644 --- a/src/cli.js +++ b/src/cli.js @@ -376,77 +376,79 @@ decorateTime.displayTime = function () { } }; -export function progressPromiseAll(msgOrMsgFn, array) { +const progressUpdateInterval = 1000 / 60; + +function progressShow(message, total) { + let start = Date.now(), last = 0, done = 0; + + const progress = () => { + const messagePart = + (typeof message === 'function' + ? message() + : message); + + const percent = + Math.round((done / total) * 1000) / 10 + '%'; + + const percentPart = + percent.padEnd('99.9%'.length, ' '); + + return `${messagePart} [${percentPart}]`; + }; + + process.stdout.write(`\r` + progress()); + + return () => { + done++; + + if (done === total) { + process.stdout.write( + `\r\x1b[2m` + progress() + + `\x1b[0;32m Done! ` + + `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` + + `\x1b[0m\n` + ); + } else if (Date.now() - last >= progressUpdateInterval) { + process.stdout.write('\r' + progress()); + last = Date.now(); + } + }; +} + +export function progressPromiseAll(message, array) { if (!array.length) { return Promise.resolve([]); } - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; - - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - return Promise.all( - array.map((promise) => - Promise.resolve(promise).then((val) => { - done++; - // const pc = `${done}/${total}`; - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd( - '99.9%'.length, - ' ' - ); - if (done === total) { - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else { - process.stdout.write(`\r${msgFn()} [${pc}] `); - } - return val; - }) - ) - ); + const show = progressShow(message, array.length); + + const next = value => { + show(); + + return value; + }; + + const promises = + array.map(promise => Promise.resolve(promise).then(next)); + + return Promise.all(promises); } -export function progressCallAll(msgOrMsgFn, array) { +export function progressCallAll(message, array) { if (!array.length) { return []; } - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; + const show = progressShow(message, array.length); - const updateInterval = 1000 / 60; - - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - const vals = []; - let lastTime = 0; + const values = []; for (const fn of array) { - const val = fn(); - done++; - - if (done === total) { - const pc = '100%'.padEnd('99.9%'.length, ' '); - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else if (Date.now() - lastTime >= updateInterval) { - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); - process.stdout.write(`\r${msgFn()} [${pc}] `); - lastTime = Date.now(); - } - vals.push(val); + values.push(fn()); + show(); } - return vals; + return values; } export function fileIssue({ @@ -459,6 +461,24 @@ export function fileIssue({ console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } +// Quick'n dirty function to present a duration nicely for command-line use. +export function formatDuration(timeDelta) { + const seconds = timeDelta / 1000; + + if (seconds > 90) { + const modSeconds = Math.floor(seconds % 60); + const minutes = Math.floor(seconds - seconds % 60) / 60; + return `${minutes}m${modSeconds}s`; + } + + if (seconds < 0.1) { + return 'instant'; + } + + const precision = (seconds > 1 ? 3 : 2); + return `${seconds.toPrecision(precision)}s`; +} + export async function logicalCWD() { if (process.env.PWD) { return process.env.PWD; @@ -469,7 +489,7 @@ export async function logicalCWD() { try { await stat('/bin/sh'); - } catch (error) { + } catch { // Not logical, so sad. return process.cwd(); } diff --git a/src/common-util/search-shape.js b/src/common-util/search-shape.js new file mode 100644 index 00000000..e0819ed6 --- /dev/null +++ b/src/common-util/search-shape.js @@ -0,0 +1,58 @@ +// Index structures shared by client and server, and relevant interfaces. +// First and foremost, this is complemented by src/search-select.js, which +// actually fills the search indexes up with stuff. During build this all +// gets consumed by src/search.js to make an index, fill it with stuff +// (as described by search-select.js), and export it to disk; then on +// the client that export is consumed by src/static/js/search-worker.js, +// which builds an index in the same shape and imports the data for query. + +const baselineStore = [ + 'primaryName', + 'disambiguator', + 'artwork', + 'color', +]; + +const genericStore = baselineStore; + +const searchShape = { + generic: { + index: [ + 'primaryName', + 'parentName', + 'artTags', + 'additionalNames', + 'contributors', + 'groups', + ].map(field => ({field, tokenize: 'forward'})), + + store: genericStore, + }, + + verbatim: { + index: [ + 'primaryName', + 'parentName', + 'artTags', + 'additionalNames', + 'contributors', + 'groups', + ], + + store: genericStore, + }, +}; + +export default searchShape; + +export function makeSearchIndex(descriptor, {FlexSearch}) { + return new FlexSearch.Document({ + id: 'reference', + index: descriptor.index, + store: descriptor.store, + + // Disable scoring, always return results according to provided order + // (specified above in `genericQuery`, etc). + resolution: 1, + }); +} diff --git a/src/common-util/search-spec.js b/src/common-util/search-spec.js deleted file mode 100644 index af5ec201..00000000 --- a/src/common-util/search-spec.js +++ /dev/null @@ -1,280 +0,0 @@ -// Index structures shared by client and server, and relevant interfaces. - -function getArtworkPath(thing) { - switch (thing.constructor[Symbol.for('Thing.referenceType')]) { - case 'album': { - return [ - 'media.albumCover', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'flash': { - return [ - 'media.flashArt', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'track': { - if (thing.hasUniqueCoverArt) { - return [ - 'media.trackCover', - thing.album.directory, - thing.directory, - thing.coverArtFileExtension, - ]; - } else if (thing.album.hasCoverArt) { - return [ - 'media.albumCover', - thing.album.directory, - thing.album.coverArtFileExtension, - ]; - } else { - return null; - } - } - - default: - return null; - } -} - -function prepareArtwork(thing, { - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - urls, -}) { - const hasWarnings = - thing.artTags?.some(artTag => artTag.isContentWarning); - - const artworkPath = - getArtworkPath(thing); - - if (!artworkPath) { - return undefined; - } - - const mediaSrc = - urls - .from('media.root') - .to(...artworkPath); - - if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) { - return undefined; - } - - const selectedSize = - getThumbnailEqualOrSmaller( - (hasWarnings ? 'mini' : 'adorb'), - mediaSrc); - - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); - - const displaySrc = - urls - .from('thumb.root') - .to('thumb.path', mediaSrcJpeg); - - const serializeSrc = - displaySrc.replace(thing.directory, '<>'); - - return serializeSrc; -} - -function baselineProcess(thing, opts) { - const fields = {}; - - fields.primaryName = - thing.name; - - fields.artwork = - prepareArtwork(thing, opts); - - fields.color = - thing.color; - - return fields; -} - -const baselineStore = [ - 'primaryName', - 'artwork', - 'color', -]; - -function genericQuery(wikiData) { - return [ - wikiData.albumData, - - wikiData.artTagData, - - wikiData.artistData - .filter(artist => !artist.isAlias), - - wikiData.flashData, - - wikiData.groupData, - - wikiData.trackData - // Exclude rereleases - there's no reasonable way to differentiate - // them from the main release as part of this query. - .filter(track => !track.mainReleaseTrack), - ].flat(); -} - -function genericProcess(thing, opts) { - const fields = baselineProcess(thing, opts); - - const kind = - thing.constructor[Symbol.for('Thing.referenceType')]; - - fields.parentName = - (kind === 'track' - ? thing.album.name - : kind === 'group' - ? thing.category.name - : kind === 'flash' - ? thing.act.name - : null); - - fields.artTags = - (thing.constructor.hasPropertyDescriptor('artTags') - ? thing.artTags.map(artTag => artTag.nameShort) - : []); - - fields.additionalNames = - (thing.constructor.hasPropertyDescriptor('additionalNames') - ? thing.additionalNames.map(entry => entry.name) - : thing.constructor.hasPropertyDescriptor('aliasNames') - ? thing.aliasNames - : []); - - const contribKeys = [ - 'artistContribs', - 'contributorContribs', - ]; - - const contributions = - contribKeys - .filter(key => Object.hasOwn(thing, key)) - .flatMap(key => thing[key]); - - fields.contributors = - contributions - .flatMap(({artist}) => [ - artist.name, - ...artist.aliasNames, - ]); - - const groups = - (Object.hasOwn(thing, 'groups') - ? thing.groups - : Object.hasOwn(thing, 'album') - ? thing.album.groups - : []); - - const mainContributorNames = - contributions - .map(({artist}) => artist.name); - - fields.groups = - groups - .filter(group => !mainContributorNames.includes(group.name)) - .map(group => group.name); - - return fields; -} - -const genericStore = baselineStore; - -export const searchSpec = { - generic: { - query: genericQuery, - process: genericProcess, - - index: [ - 'primaryName', - 'parentName', - 'artTags', - 'additionalNames', - 'contributors', - 'groups', - ].map(field => ({field, tokenize: 'forward'})), - - store: genericStore, - }, - - verbatim: { - query: genericQuery, - process: genericProcess, - - index: [ - 'primaryName', - 'parentName', - 'artTags', - 'additionalNames', - 'contributors', - 'groups', - ], - - store: genericStore, - }, -}; - -export function makeSearchIndex(descriptor, {FlexSearch}) { - return new FlexSearch.Document({ - id: 'reference', - index: descriptor.index, - store: descriptor.store, - }); -} - -// TODO: This function basically mirrors bind-utilities.js, which isn't -// exactly robust, but... binding might need some more thought across the -// codebase in *general.* -function bindSearchUtilities({ - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - thumbsCache, - urls, -}) { - const bound = { - urls, - }; - - bound.checkIfImagePathHasCachedThumbnails = - (imagePath) => - checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); - - bound.getThumbnailEqualOrSmaller = - (preferred, imagePath) => - getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); - - return bound; -} - -export function populateSearchIndex(index, descriptor, opts) { - const {wikiData} = opts; - const bound = bindSearchUtilities(opts); - - const collection = descriptor.query(wikiData); - - for (const thing of collection) { - const reference = thing.constructor.getReference(thing); - - let processed; - try { - processed = descriptor.process(thing, bound); - } catch (caughtError) { - throw new Error( - `Failed to process searchable thing ${reference}`, - {cause: caughtError}); - } - - index.add({reference, ...processed}); - } -} diff --git a/src/common-util/sort.js b/src/common-util/sort.js index d93d94c1..bbe4e551 100644 --- a/src/common-util/sort.js +++ b/src/common-util/sort.js @@ -370,11 +370,12 @@ export function sortAlbumsTracksChronologically(data, { getDate, } = {}) { // Sort albums before tracks... - sortByConditions(data, [(t) => t.album === undefined]); + sortByConditions(data, [t => t.isAlbum]); - // Group tracks by album... - sortByDirectory(data, { - getDirectory: (t) => (t.album ? t.album.directory : t.directory), + // Put albums alphabetically, and group with them... + sortAlphabetically(data, { + getDirectory: t => t.isTrack ? t.album.directory : t.directory, + getName: t => t.isTrack ? t.album.name : t.name, }); // Sort tracks by position in album... diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js index 9e344816..354cf5cc 100644 --- a/src/common-util/sugar.js +++ b/src/common-util/sugar.js @@ -70,6 +70,16 @@ export function pick(array) { return array[Math.floor(Math.random() * array.length)]; } +// Gets the only item in a single-item array (strictly, length === 1). +// If the array has more than one item, or is empty, this is null. +export function onlyItem(array) { + if (array.length === 1) { + return array[0]; + } else { + return null; + } +} + // Gets the item at an index relative to another index. export function atOffset(array, index, offset, { wrap = false, @@ -116,10 +126,14 @@ export function findIndexOrEnd(array, fn) { // returns null (or values in the array are nullish), they'll just be skipped in // the sum. export function accumulateSum(array, fn = x => x) { + if (!Array.isArray(array)) { + return accumulateSum(Array.from(array, fn)); + } + return array.reduce( (accumulator, value, index, array) => accumulator + - fn(value, index, array) ?? 0, + (fn(value, index, array) ?? 0), 0); } @@ -254,11 +268,20 @@ export function compareObjects(obj1, obj2, { // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. export const withEntries = (obj, fn) => { - const result = fn(Object.entries(obj)); - if (result instanceof Promise) { - return result.then(entries => Object.fromEntries(entries)); + if (obj instanceof Map) { + const result = fn(Array.from(obj.entries())); + if (result instanceof Promise) { + return result.then(entries => new Map(entries)); + } else { + return new Map(result); + } } else { - return Object.fromEntries(result); + const result = fn(Object.entries(obj)); + if (result instanceof Promise) { + return result.then(entries => Object.fromEntries(entries)); + } else { + return Object.fromEntries(result); + } } } @@ -302,34 +325,74 @@ export function filterProperties(object, properties, { return filteredObject; } -export function queue(array, max = 50) { - if (max === 0) { - return array.map((fn) => fn()); +export function queue(functionList, queueSize = 50) { + if (queueSize === 0) { + return functionList.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); - }); - }) - ); + const promiseList = []; + const resolveList = []; + const rejectList = []; - for (let i = 0; i < max && begin.length; i++) { - begin.shift()(); + for (let i = 0; i < functionList.length; i++) { + const promiseWithResolvers = Promise.withResolvers(); + promiseList.push(promiseWithResolvers.promise); + resolveList.push(promiseWithResolvers.resolve); + rejectList.push(promiseWithResolvers.reject); } - return ret; + let cursor = 0; + let running = 0; + + const next = async () => { + if (running >= queueSize) { + return; + } + + if (cursor === functionList.length) { + return; + } + + const thisFunction = functionList[cursor]; + const thisResolve = resolveList[cursor]; + const thisReject = rejectList[cursor]; + + delete functionList[cursor]; + delete resolveList[cursor]; + delete rejectList[cursor]; + + cursor++; + running++; + + try { + thisResolve(await thisFunction()); + } catch (error) { + thisReject(error); + } finally { + running--; + + // If the cursor is at 1, this is the first promise that resolved, + // so we're now done the "kick start", and can start the remaining + // promises (up to queueSize). + if (cursor === 1) { + // Since only one promise is used for the "kick start", and that one + // has just resolved, we know there's none running at all right now, + // and can start as many as specified in the queueSize right away. + for (let i = 0; i < queueSize; i++) { + next(); + } + } else { + next(); + } + } + }; + + // Only start a single promise, as a "kick start", so that it resolves as + // early as possible (it will resolve before we use CPU to start the rest + // of the promises, up to queueSize). + next(); + + return promiseList; } export function delay(ms) { @@ -360,15 +423,23 @@ export function splitKeys(key) { // Follows a key path like 'foo.bar.baz' to get an item nested deeply inside // an object. If a value partway through the chain is an array, the values -// down the rest of the chain are gotten for each item in the array. +// down the rest of the chain are gotten for each item in the array. If a value +// partway through the chain is missing the next key, the chain stops and is +// undefined (or null) at that point. // // obj: {x: [{y: ['a']}, {y: ['b', 'c']}]} // key: 'x.y' // -> [['a'], ['b', 'c']] // +// obj: {x: [{y: ['a']}, {y: ['b', 'c']}, {z: ['d', 'e']}]} +// key: 'x.z' +// -> [undefined, undefined, ['d', 'e']] +// export function getNestedProp(obj, key) { const recursive = (o, k) => - (k.length === 1 + (o === undefined || o === null + ? o + : k.length === 1 ? o[k[0]] : Array.isArray(o[k[0]]) ? o[k[0]].map(v => recursive(v, k.slice(1))) diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js index 546f1ad9..1668f110 100644 --- a/src/common-util/wiki-data.js +++ b/src/common-util/wiki-data.js @@ -11,7 +11,7 @@ export {filterMultipleArrays} from './sugar.js'; // Generic value operations -export function getKebabCase(name) { +export function getCaseSensitiveKebabCase(name) { return name // Spaces to dashes @@ -34,6 +34,9 @@ export function getKebabCase(name) { // General punctuation which always separates surrounding words .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') + // More punctuation which always separates surrounding words + .replace(/[\u{2013}-\u{2014}]/u, '-') // En Dash, Em Dash + // Accented characters .replace(/[áâäàå]/gi, 'a') .replace(/[çč]/gi, 'c') @@ -50,9 +53,10 @@ export function getKebabCase(name) { // Trim dashes on boundaries .replace(/^-+|-+$/g, '') +} - // Always lowercase - .toLowerCase(); +export function getKebabCase(name) { + return getCaseSensitiveKebabCase(name).toLowerCase(); } // Specific data utilities @@ -102,6 +106,8 @@ export const commentaryRegexCaseSensitive = export const commentaryRegexCaseSensitiveOneShot = new RegExp(commentaryRegexRaw); +export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; + // The #validators function isOldStyleLyrics() describes // what this regular expression detects against. export const multipleLyricsDetectionRegex = @@ -113,10 +119,16 @@ export function matchContentEntries(sourceText) { let previousMatchEntry = null; let previousEndIndex = null; + const trimBody = body => + body + .replace(/^\n*/, '') + .replace(/\n*$/, ''); + for (const {0: matchText, index: startIndex, groups: matchEntry} of sourceText.matchAll(commentaryRegexCaseSensitive)) { if (previousMatchEntry) { - previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex); + previousMatchEntry.body = + trimBody(sourceText.slice(previousEndIndex, startIndex)); } matchEntries.push(matchEntry); @@ -126,7 +138,8 @@ export function matchContentEntries(sourceText) { } if (previousMatchEntry) { - previousMatchEntry.body = sourceText.slice(previousEndIndex); + previousMatchEntry.body = + trimBody(sourceText.slice(previousEndIndex)); } return matchEntries; @@ -526,26 +539,51 @@ export function combineWikiDataArrays(arrays) { // Markdown stuff export function* matchMarkdownLinks(markdownSource, {marked}) { - const plausibleLinkRegexp = /\[.*?\)/g; + const plausibleLinkRegexp = /\[(?=.*?\))/g; + + const lexer = new marked.Lexer(); + + // This is just an optimization. Don't let Marked try to process tokens + // recursively, i.e. within the text/label of the link. We only care about + // the text itself, as a string. + lexer.inlineTokens = x => []; + + // This is cheating, because the lexer's tokenizer is a private property, + // but we can apparently access it anyway. + const {tokenizer} = lexer; let plausibleMatch = null; while (plausibleMatch = plausibleLinkRegexp.exec(markdownSource)) { - // Pedantic rules use more particular parentheses detection in link - // destinations - they allow one level of balanced parentheses, and - // otherwise, parentheses must be escaped. This allows for entire links - // to be wrapped in parentheses, e.g below: - // - // This is so cool. ([You know??](https://example.com)) - // + const {index} = plausibleMatch; + const definiteMatch = - marked.Lexer.rules.inline.pedantic.link - .exec(markdownSource.slice(plausibleMatch.index)); + tokenizer.link(markdownSource.slice(index)); + + if (!definiteMatch) { + continue; + } - if (definiteMatch) { - const [{length}, label, href] = definiteMatch; - const index = plausibleMatch.index + definiteMatch.index; + const {raw: {length}, text: label, href} = definiteMatch; - yield {label, href, index, length}; + yield {label, href, index, length}; + } +} + +export function* matchInlineLinks(source) { + const plausibleLinkRegexp = /\b[a-z]*:\/\/[^ ]*?(?=(?:[,.!?]*)(?:\s|$))/gm; + + let plausibleMatch = null; + while (plausibleMatch = plausibleLinkRegexp.exec(source)) { + const [href] = plausibleMatch; + const {index} = plausibleMatch; + const [{length}] = plausibleMatch; + + try { + new URL(href); + } catch { + continue; } + + yield {href, length, index}; } } diff --git a/src/content-function.js b/src/content-function.js index 44f8b842..04f2ce90 100644 --- a/src/content-function.js +++ b/src/content-function.js @@ -2,8 +2,8 @@ import {inspect as nodeInspect} from 'node:util'; import {decorateError} from '#aggregate'; import {colors, decorateTime, ENABLE_COLOR} from '#cli'; -import {Template} from '#html'; -import {annotateFunction, empty, setIntersection} from '#sugar'; +import {Tag, Template} from '#html'; +import {empty} from '#sugar'; function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); @@ -13,167 +13,117 @@ const DECORATE_TIME = process.env.HSMUSIC_DEBUG_CONTENT_PERF === '1'; export class ContentFunctionSpecError extends Error {} -export default function contentFunction({ - contentDependencies = [], - extraDependencies = [], - - slots, - sprawl, - query, - relations, - data, - generate, -}) { - const expectedContentDependencyKeys = new Set(contentDependencies); - const expectedExtraDependencyKeys = new Set(extraDependencies); - - // Initial checks. These only need to be run once per description of a - // content function, and don't depend on any mutable context (e.g. which - // dependencies have been fulfilled so far). - - const overlappingContentExtraDependencyKeys = - setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys); - - if (!empty(overlappingContentExtraDependencyKeys)) { - throw new ContentFunctionSpecError(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`); +function optionalDecorateTime(prefix, dependency, fn) { + if (DECORATE_TIME) { + return decorateTime(`${prefix}/${dependency}`, fn); + } else { + return fn; } +} - if (!generate) { +export default function contentFunction(spec) { + if (!spec.generate) { throw new ContentFunctionSpecError(`Expected generate function`); } - if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) { - throw new ContentFunctionSpecError(`Content functions which sprawl must specify wikiData in extraDependencies`); + if (spec.slots) { + Template.validateSlotsDescription(spec.slots); } - if (slots && !expectedExtraDependencyKeys.has('html')) { - throw new ContentFunctionSpecError(`Content functions with slots must specify html in extraDependencies`); - } + return expectExtraDependencies(spec, null); +} - if (slots) { - Template.validateSlotsDescription(slots); +contentFunction.identifyingSymbol = Symbol(`Is a content function?`); + +export function expectExtraDependencies(spec, boundExtraDependencies) { + const generate = + (boundExtraDependencies + ? prepareWorkingGenerateFunction(spec, boundExtraDependencies) + : () => { + throw new Error(`Not bound with extraDependencies yet`); + }); + + generate[contentFunction.identifyingSymbol] = true; + + const dependency = spec.generate.name; + for (const key of ['sprawl', 'query', 'relations', 'data']) { + if (spec[key]) { + generate[key] = optionalDecorateTime(key, dependency, spec[key]); + } } - // Pass all the details to expectDependencies, which will recursively build - // up a set of fulfilled dependencies and make functions like `relations` - // and `generate` callable only with sufficient fulfilled dependencies. - - return expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys: new Set(expectedContentDependencyKeys), - missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys), - invalidatingDependencyKeys: new Set(), - fulfilledDependencyKeys: new Set(), - fulfilledDependencies: {}, - }); + generate.bindExtraDependencies = (extraDependencies) => + expectExtraDependencies(spec, extraDependencies); + + return generate; } -contentFunction.identifyingSymbol = Symbol(`Is a content function?`); +function prepareWorkingGenerateFunction(spec, boundExtraDependencies) { + const dependency = spec.generate.name; -export function expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys, - missingExtraDependencyKeys, - invalidatingDependencyKeys, - fulfilledDependencyKeys, - fulfilledDependencies, -}) { - const hasSprawlFunction = !!sprawl; - const hasQueryFunction = !!query; - const hasRelationsFunction = !!relations; - const hasDataFunction = !!data; - const hasSlotsDescription = !!slots; - - const isInvalidated = !empty(invalidatingDependencyKeys); - const isMissingContentDependencies = !empty(missingContentDependencyKeys); - const isMissingExtraDependencies = !empty(missingExtraDependencyKeys); - - let wrappedGenerate; - - const optionalDecorateTime = (prefix, fn) => - (DECORATE_TIME - ? decorateTime(`${prefix}/${generate.name}`, fn) - : fn); - - if (isInvalidated) { - wrappedGenerate = function() { - throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`); - }; + let generate = ([arg1, arg2], ...extraArgs) => { + if (spec.data && !arg1) { + throw new Error(`Expected data`); + } - annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'}); - wrappedGenerate.fulfilled = false; - } else if (isMissingContentDependencies || isMissingExtraDependencies) { - wrappedGenerate = function() { - throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`); - }; + if (spec.data && spec.relations && !arg2) { + throw new Error(`Expected relations`); + } - annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); - wrappedGenerate.fulfilled = false; - } else { - let callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => { - if (hasDataFunction && !arg1) { - throw new Error(`Expected data`); - } + if (spec.relations && !arg1) { + throw new Error(`Expected relations`); + } - if (hasDataFunction && hasRelationsFunction && !arg2) { - throw new Error(`Expected relations`); + try { + if (spec.data && spec.relations) { + return spec.generate(arg1, arg2, ...extraArgs, boundExtraDependencies); + } else if (spec.data || spec.relations) { + return spec.generate(arg1, ...extraArgs, boundExtraDependencies); + } else { + return spec.generate(...extraArgs, boundExtraDependencies); } + } catch (caughtError) { + const error = new Error( + `Error generating content for ${dependency}`, + {cause: caughtError}); - if (hasRelationsFunction && !arg1) { - throw new Error(`Expected relations`); - } + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = caughtError; + + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; + + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; + + throw error; + } + }; - try { - if (hasDataFunction && hasRelationsFunction) { - return generate(arg1, arg2, ...extraArgs, fulfilledDependencies); - } else if (hasDataFunction || hasRelationsFunction) { - return generate(arg1, ...extraArgs, fulfilledDependencies); - } else { - return generate(...extraArgs, fulfilledDependencies); - } - } catch (caughtError) { - const error = new Error( - `Error generating content for ${generate.name}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = caughtError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; + generate = (baseGenerate => (...args) => { + const result = baseGenerate(...args); + + if (result instanceof Template || result instanceof Tag) { + if (Object.hasOwn(result, Symbol.for('hsmusic.content.via'))) { + result[Symbol.for('hsmusic.contentFunction.via')].push(dependency); + } else { + result[Symbol.for('hsmusic.contentFunction.via')] = [dependency]; } - }; + } + + return result; + })(generate); - callUnderlyingGenerate = - optionalDecorateTime(`generate`, callUnderlyingGenerate); + generate = optionalDecorateTime(`generate`, dependency, generate); - if (hasSlotsDescription) { - const stationery = fulfilledDependencies.html.stationery({ - annotation: generate.name, + if (spec.slots) { + let stationery = null; + return (...args) => { + stationery ??= boundExtraDependencies.html.stationery({ + annotation: dependency, // These extra slots are for the data and relations (positional) args. // No hacks to store them temporarily or otherwise "invisibly" alter @@ -182,170 +132,23 @@ export function expectDependencies({ slots: { _cfArg1: {validate: v => v.isObject}, _cfArg2: {validate: v => v.isObject}, - ...slots, + ...spec.slots, }, content(slots) { const args = [slots._cfArg1, slots._cfArg2]; - return callUnderlyingGenerate(args, slots); + return generate(args, slots); }, }); - wrappedGenerate = function(...args) { - return stationery.template().slots({ - _cfArg1: args[0] ?? null, - _cfArg2: args[1] ?? null, - }); - }; - } else { - wrappedGenerate = function(...args) { - return callUnderlyingGenerate(args); - }; - } - - wrappedGenerate.fulfill = function() { - throw new Error(`All dependencies already fulfilled (${generate.name})`); - }; - - annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'}); - wrappedGenerate.fulfilled = true; - } - - wrappedGenerate[contentFunction.identifyingSymbol] = true; - - if (hasSprawlFunction) { - wrappedGenerate.sprawl = optionalDecorateTime(`sprawl`, sprawl); - } - - if (hasQueryFunction) { - wrappedGenerate.query = optionalDecorateTime(`query`, query); - } - - if (hasRelationsFunction) { - wrappedGenerate.relations = optionalDecorateTime(`relations`, relations); - } - - if (hasDataFunction) { - wrappedGenerate.data = optionalDecorateTime(`data`, data); - } - - wrappedGenerate.fulfill ??= function fulfill(dependencies) { - // To avoid unneeded destructuring, `fullfillDependencies` is a mutating - // function. But `fulfill` itself isn't meant to mutate! We create a copy - // of these variables, so their original values are kept for additional - // calls to this same `fulfill`. - const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys); - const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys); - const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys); - const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys); - const newlyFulfilledDependencies = {...fulfilledDependencies}; - - try { - fulfillDependencies(dependencies, { - missingContentDependencyKeys: newlyMissingContentDependencyKeys, - missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, - invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, - fulfilledDependencyKeys: newlyFulfilledDependencyKeys, - fulfilledDependencies: newlyFulfilledDependencies, + return stationery.template().slots({ + _cfArg1: args[0] ?? null, + _cfArg2: args[1] ?? null, }); - } catch (error) { - error.message += ` (${generate.name})`; - throw error; - } - - return expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys: newlyMissingContentDependencyKeys, - missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, - invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, - fulfilledDependencyKeys: newlyFulfilledDependencyKeys, - fulfilledDependencies: newlyFulfilledDependencies, - }); - - }; - - Object.assign(wrappedGenerate, { - contentDependencies: expectedContentDependencyKeys, - extraDependencies: expectedExtraDependencyKeys, - }); - - return wrappedGenerate; -} - -export function fulfillDependencies(dependencies, { - missingContentDependencyKeys, - missingExtraDependencyKeys, - invalidatingDependencyKeys, - fulfilledDependencyKeys, - fulfilledDependencies, -}) { - // This is a mutating function. Be aware: it WILL mutate the provided sets - // and objects EVEN IF there are errors. This function doesn't exit early, - // so all provided dependencies which don't have an associated error should - // be treated as fulfilled (this is reflected via fulfilledDependencyKeys). - - const errors = []; - - for (let [key, value] of Object.entries(dependencies)) { - if (fulfilledDependencyKeys.has(key)) { - errors.push(new Error(`Dependency ${key} is already fulfilled`)); - continue; - } - - const isContentKey = missingContentDependencyKeys.has(key); - const isExtraKey = missingExtraDependencyKeys.has(key); - - if (!isContentKey && !isExtraKey) { - errors.push(new Error(`Dependency ${key} is not expected`)); - continue; - } - - if (value === undefined) { - errors.push(new Error(`Dependency ${key} was provided undefined`)); - continue; - } - - const isContentFunction = - !!value?.[contentFunction.identifyingSymbol]; - - const isFulfilledContentFunction = - isContentFunction && value.fulfilled; - - if (isContentKey) { - if (!isContentFunction) { - errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`)); - continue; - } - - if (!isFulfilledContentFunction) { - invalidatingDependencyKeys.add(key); - } - - missingContentDependencyKeys.delete(key); - } else if (isExtraKey) { - if (isContentFunction) { - errors.push(new Error(`Extra dependency ${key} is a content function`)); - continue; - } - - missingExtraDependencyKeys.delete(key); - } - - fulfilledDependencyKeys.add(key); - fulfilledDependencies[key] = value; + }; } - if (!empty(errors)) { - throw new AggregateError(errors, `Errors fulfilling dependencies`); - } + return (...args) => generate(args); } export function getArgsForRelationsAndData(contentFunction, wikiData, ...args) { @@ -393,8 +196,6 @@ export function getRelationsTree(dependencies, contentFunctionName, wikiData, .. }; if (contentFunction.relations) { - const listedDependencies = new Set(contentFunction.contentDependencies); - // Note: "slots" here is a completely separate concept from HTML template // slots, which are handled completely within the content function. Here, // relation slots are just references to a position within the relations @@ -408,10 +209,6 @@ export function getRelationsTree(dependencies, contentFunctionName, wikiData, .. })(); const relationFunction = (name, ...args) => { - if (!listedDependencies.has(name)) { - throw new Error(`Called relation('${name}') but ${contentFunctionName} doesn't list that dependency`); - } - const relationSymbol = Symbol(relationSymbolMessage(name)); const traceError = new Error(); @@ -502,27 +299,11 @@ export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, return recursive(layout); } -export function getNeededContentDependencyNames(contentDependencies, name) { - const set = new Set(); - - function recursive(name) { - const contentFunction = contentDependencies[name]; - for (const dependencyName of contentFunction?.contentDependencies ?? []) { - recursive(dependencyName); - } - set.add(name); - } - - recursive(name); - - return set; -} - export const decorateErrorWithRelationStack = (fn, traceStack) => decorateError(fn, caughtError => { let cause = caughtError; - for (const {name, args, traceError} of traceStack.slice().reverse()) { + for (const {name, args, traceError} of traceStack.toReversed()) { const nameText = colors.green(`"${name}"`); const namePart = `Error in relation(${nameText})`; @@ -579,65 +360,10 @@ export function quickEvaluate({ const flatTreeInfo = flattenRelationsTree(treeInfo); const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo; - const neededContentDependencyNames = - getNeededContentDependencyNames(allContentDependencies, name); - - // Content functions aren't recursive, so by following the set above - // sequentually, we will always provide fulfilled content functions as the - // dependencies for later content functions. - const fulfilledContentDependencies = {}; - for (const name of neededContentDependencyNames) { - const unfulfilledContentFunction = allContentDependencies[name]; - if (!unfulfilledContentFunction) continue; - - const {contentDependencies, extraDependencies} = unfulfilledContentFunction; - - if (empty(contentDependencies) && empty(extraDependencies)) { - fulfilledContentDependencies[name] = unfulfilledContentFunction; - continue; - } - - const fulfillments = {}; - - for (const dependencyName of contentDependencies ?? []) { - if (dependencyName in fulfilledContentDependencies) { - fulfillments[dependencyName] = - fulfilledContentDependencies[dependencyName]; - } - } - - for (const dependencyName of extraDependencies ?? []) { - if (dependencyName in allExtraDependencies) { - fulfillments[dependencyName] = - allExtraDependencies[dependencyName]; - } - } - - fulfilledContentDependencies[name] = - unfulfilledContentFunction.fulfill(fulfillments); - } - - // There might still be unfulfilled content functions if dependencies weren't - // provided as part of allContentDependencies or allExtraDependencies. - // Catch and report these early, together in an aggregate error. - const unfulfilledErrors = []; - const unfulfilledNames = []; - for (const name of neededContentDependencyNames) { - const contentFunction = fulfilledContentDependencies[name]; - if (!contentFunction) continue; - if (!contentFunction.fulfilled) { - try { - contentFunction(); - } catch (error) { - error.message = `(${name}) ${error.message}`; - unfulfilledErrors.push(error); - unfulfilledNames.push(name); - } - } - } - - if (!empty(unfulfilledErrors)) { - throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`); + allContentDependencies = {...allContentDependencies}; + for (const [name, contentFunction] of Object.entries(allContentDependencies)) { + allContentDependencies[name] = + contentFunction.bindExtraDependencies(allExtraDependencies); } const slotResults = {}; @@ -646,10 +372,9 @@ export function quickEvaluate({ const callDecorated = (fn, ...args) => decorateErrorWithRelationStack(fn, traceStack)(...args); - const contentFunction = fulfilledContentDependencies[name]; - + const contentFunction = allContentDependencies[name]; if (!contentFunction) { - throw new Error(`Content function ${name} unfulfilled or not listed`); + throw new Error(`Content function ${name} not listed`); } const generateArgs = []; diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js index 930b6f13..d006374a 100644 --- a/src/content/dependencies/generateAbsoluteDatetimestamp.js +++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js @@ -1,15 +1,12 @@ export default { - contentDependencies: [ - 'generateDatetimestampTemplate', - 'generateTooltip', - ], + data: (date, contextDate) => ({ + date, - extraDependencies: ['html', 'language'], - - data: (date) => - ({date}), + contextDate: + contextDate ?? null, + }), - relations: (relation) => ({ + relations: (relation, _date, _contextDate) => ({ template: relation('generateDatetimestampTemplate'), @@ -19,35 +16,74 @@ export default { slots: { style: { - validate: v => v.is('full', 'year'), + validate: v => v.is(...[ + 'full', + 'year', + 'minimal-difference', + 'year-difference', + ]), default: 'full', }, - - // Only has an effect for 'year' style. - tooltip: { - type: 'boolean', - default: false, - }, }, - generate: (data, relations, slots, {language}) => - relations.template.slots({ - mainContent: - (slots.style === 'full' - ? language.formatDate(data.date) - : slots.style === 'year' - ? data.date.getFullYear().toString() - : null), - - tooltip: - slots.tooltip && - slots.style === 'year' && - relations.tooltip.slots({ - content: - language.formatDate(data.date), - }), - - datetime: - data.date.toISOString(), - }), + generate(data, relations, slots, {html, language}) { + if (!data.date) { + return html.blank(); + } + + relations.template.setSlots({ + tooltip: relations.tooltip, + datetime: data.date.toISOString(), + }); + + let label = null; + let tooltip = null; + + switch (slots.style) { + case 'full': { + label = language.formatDate(data.date); + break; + } + + case 'year': { + label = language.formatYear(data.date); + tooltip = language.formatDate(data.date); + break; + } + + case 'minimal-difference': { + if (data.date.toDateString() === data.contextDate?.toDateString()) { + return html.blank(); + } + + if (data.date.getFullYear() === data.contextDate?.getFullYear()) { + label = language.formatMonthDay(data.date); + tooltip = language.formatDate(data.date); + } else { + label = language.formatYear(data.date); + tooltip = language.formatDate(data.date); + } + + break; + } + + case 'year-difference': { + if (data.date.toDateString() === data.contextDate?.toDateString()) { + return html.blank(); + } + + if (data.date.getFullYear() === data.contextDate?.getFullYear()) { + label = language.formatDate(data.date); + } else { + label = language.formatYear(data.date); + tooltip = language.formatDate(data.date); + } + } + } + + relations.template.setSlot('mainContent', label); + relations.tooltip.setSlot('content', tooltip); + + return relations.template; + }, }; diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js index 7e05b5b5..699c5f86 100644 --- a/src/content/dependencies/generateAdditionalFilesList.js +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateAdditionalFilesListChunk'], - extraDependencies: ['html'], - relations: (relation, additionalFiles) => ({ chunks: additionalFiles diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js index 3cac851b..466a5d8d 100644 --- a/src/content/dependencies/generateAdditionalFilesListChunk.js +++ b/src/content/dependencies/generateAdditionalFilesListChunk.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkAdditionalFile', 'transformContent'], - extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'], - relations: (relation, file) => ({ description: relation('transformContent', file.description), diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js index b7392dfd..6bd1ab42 100644 --- a/src/content/dependencies/generateAdditionalNamesBox.js +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -1,18 +1,25 @@ export default { - contentDependencies: ['generateAdditionalNamesBoxItem'], - extraDependencies: ['html', 'language'], - relations: (relation, additionalNames) => ({ items: additionalNames .map(entry => relation('generateAdditionalNamesBoxItem', entry)), }), - generate: (relations, {html, language}) => + slots: { + alwaysVisible: { + type: 'boolean', + default: false, + }, + }, + + generate: (relations, slots, {html, language}) => html.tag('div', {id: 'additional-names-box'}, {class: 'drop'}, {[html.onlyIfContent]: true}, + slots.alwaysVisible && + {class: 'always-visible'}, + [ html.tag('p', {[html.onlyIfSiblings]: true}, diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js index e3e59a34..a39711c1 100644 --- a/src/content/dependencies/generateAdditionalNamesBoxItem.js +++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['transformContent'], - extraDependencies: ['html', 'language'], - relations: (relation, entry) => ({ nameContent: relation('transformContent', entry.name), diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js index 8c44c930..5491192a 100644 --- a/src/content/dependencies/generateAlbumArtInfoBox.js +++ b/src/content/dependencies/generateAlbumArtInfoBox.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateReleaseInfoContributionsLine'], - extraDependencies: ['html', 'language'], - relations: (relation, album) => ({ wallpaperArtistContributionsLine: (album.wallpaperArtwork diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js index e6762463..5346e56b 100644 --- a/src/content/dependencies/generateAlbumArtworkColumn.js +++ b/src/content/dependencies/generateAlbumArtworkColumn.js @@ -1,38 +1,51 @@ export default { - contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'], - extraDependencies: ['html'], - - relations: (relation, album) => ({ - firstCover: + query: (album) => ({ + nonAttachingArtworkIndex: (album.hasCoverArt - ? relation('generateCoverArtwork', album.coverArtworks[0]) + ? album.coverArtworks.findIndex((artwork, index) => + index > 1 && + !artwork.attachAbove) : null), + }), + + relations: (relation, query, album) => ({ + firstCovers: + (album.hasCoverArt && query.nonAttachingArtworkIndex >= 1 + ? album.coverArtworks + .slice(0, query.nonAttachingArtworkIndex) + .map(artwork => relation('generateCoverArtwork', artwork)) + + : album.hasCoverArt + ? album.coverArtworks + .map(artwork => relation('generateCoverArtwork', artwork)) - restCovers: - (album.hasCoverArt - ? album.coverArtworks.slice(1).map(artwork => - relation('generateCoverArtwork', artwork)) : []), albumArtInfoBox: relation('generateAlbumArtInfoBox', album), + + restCovers: + (album.hasCoverArt && query.nonAttachingArtworkIndex >= 1 + ? album.coverArtworks + .slice(query.nonAttachingArtworkIndex) + .map(artwork => relation('generateCoverArtwork', artwork)) + + : []), }), - generate: (relations, {html}) => - html.tags([ - relations.firstCover?.slots({ + generate(relations, {html}) { + for (const cover of [...relations.firstCovers, ...relations.restCovers]) { + cover.setSlots({ showOriginDetails: true, showArtTagDetails: true, showReferenceDetails: true, - }), + }); + } + return html.tags([ + relations.firstCovers, relations.albumArtInfoBox, - - relations.restCovers.map(cover => - cover.slots({ - showOriginDetails: true, - showArtTagDetails: true, - showReferenceDetails: true, - })), - ]), + relations.restCovers, + ]); + }, }; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js index 3cc141bc..dce258de 100644 --- a/src/content/dependencies/generateAlbumBanner.js +++ b/src/content/dependencies/generateAlbumBanner.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateBanner'], - extraDependencies: ['html', 'language'], - relations(relation, album) { if (!album.hasBannerArt) { return {}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 1e39b47d..4c203877 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -1,22 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumCommentarySidebar', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', - 'generateCommentaryEntry', - 'generateContentHeading', - 'generateCoverArtwork', - 'generatePageLayout', - 'linkAlbum', - 'linkExternal', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - query(album) { const query = {}; @@ -44,8 +28,8 @@ export default { relations.sidebar = relation('generateAlbumCommentarySidebar', album); - relations.albumStyleRules = - relation('generateAlbumStyleRules', album, null); + relations.albumStyleTags = + relation('generateAlbumStyleTags', album, null); relations.albumLink = relation('linkAlbum', album); @@ -151,7 +135,7 @@ export default { headingMode: 'sticky', color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, mainClasses: ['long-content'], mainContent: [ @@ -266,7 +250,10 @@ export default { }), })), - cover?.slots({mode: 'commentary'}), + cover?.slots({ + mode: 'commentary', + color: true, + }), trackDate && trackDate !== data.date && diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js index 9ecec66d..4863f059 100644 --- a/src/content/dependencies/generateAlbumCommentarySidebar.js +++ b/src/content/dependencies/generateAlbumCommentarySidebar.js @@ -1,15 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumSidebarTrackSection', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, album) => ({ sidebar: relation('generatePageSidebar'), diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js index 7f152871..f9cd027e 100644 --- a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js +++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js @@ -1,14 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateCoverGrid', - 'image', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - query: (album) => ({ artworks: (album.hasCoverArt diff --git a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js index 7dcdf6de..0322e227 100644 --- a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js +++ b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkArtistGallery'], - extraDependencies: ['html', 'language'], - relations(relation, coverArtists) { return { coverArtistLinks: diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js index ad99cb87..5932514e 100644 --- a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js +++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - generate: ({html, language}) => html.tag('p', {class: 'quick-info'}, language.$('albumGalleryPage.noTrackArtworksLine')), diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js index 2ba3b272..85b0fb74 100644 --- a/src/content/dependencies/generateAlbumGalleryPage.js +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -2,21 +2,6 @@ import {stitchArrays, unique} from '#sugar'; import {getKebabCase} from '#wiki-data'; export default { - contentDependencies: [ - 'generateAlbumGalleryAlbumGrid', - 'generateAlbumGalleryNoTrackArtworksLine', - 'generateAlbumGalleryStatsLine', - 'generateAlbumGalleryTrackGrid', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', - 'generateIntrapageDotSwitcher', - 'generatePageLayout', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - query(album) { const query = {}; @@ -46,8 +31,8 @@ export default { layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), albumLink: relation('linkAlbum', album), @@ -106,7 +91,7 @@ export default { headingMode: 'static', color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, mainClasses: ['top-index'], mainContent: [ diff --git a/src/content/dependencies/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js index 75bffb36..75341937 100644 --- a/src/content/dependencies/generateAlbumGalleryStatsLine.js +++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js @@ -1,38 +1,56 @@ import {getTotalDuration} from '#wiki-data'; export default { - extraDependencies: ['html', 'language'], - - data(album) { - return { - name: album.name, - date: album.date, - duration: getTotalDuration(album.tracks), - numTracks: album.tracks.length, - }; - }, - - generate(data, {html, language}) { - const parts = ['albumGalleryPage.statsLine']; - const options = {}; - - options.tracks = - html.tag('b', - language.countTracks(data.numTracks, {unit: true})); - - options.duration = - html.tag('b', - language.formatDuration(data.duration, {unit: true})); - - if (data.date) { - parts.push('withDate'); - options.date = - html.tag('b', - language.formatDate(data.date)); - } - - return ( - html.tag('p', {class: 'quick-info'}, - language.formatString(...parts, options))); - }, + data: (album) => ({ + date: + album.date, + + hideDuration: + album.hideDuration, + + duration: + (album.hideDuration + ? null + : getTotalDuration(album.tracks)), + + tracks: + (album.hideDuration + ? null + : album.tracks.length), + }), + + generate: (data, {html, language}) => + html.tag('p', {class: 'quick-info'}, + {[html.onlyIfContent]: true}, + + language.encapsulate('albumGalleryPage.statsLine', workingCapsule => { + const workingOptions = {}; + + if (data.hideDuration && !data.date) { + return html.blank(); + } + + if (!data.hideDuration) { + workingOptions.tracks = + html.tag('b', + language.countTracks(data.tracks, {unit: true})); + + workingOptions.duration = + html.tag('b', + language.formatDuration(data.duration, {unit: true})); + } + + if (data.date) { + workingCapsule += '.withDate'; + workingOptions.date = + html.tag('b', + language.formatDate(data.date)); + } + + if (data.hideDuration) { + workingCapsule += '.noDuration'; + } + + return language.$(workingCapsule, workingOptions); + })), }; diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js index fb5ed7ea..a50448c6 100644 --- a/src/content/dependencies/generateAlbumGalleryTrackGrid.js +++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js @@ -1,16 +1,6 @@ import {compareArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumGalleryCoverArtistsLine', - 'generateCoverGrid', - 'image', - 'linkAlbum', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - query(album, label) { const query = {}; @@ -77,6 +67,9 @@ export default { ? artwork.artistContribs .map(contrib => contrib.artist.name) : null)), + + allWarnings: + query.artworks.flatMap(artwork => artwork?.contentWarnings), }), slots: { @@ -117,6 +110,9 @@ export default { artists: language.formatUnitList(artists), })), + + revealAllWarnings: + data.allWarnings, }), ]), }; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index ed19bf75..a27074ff 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -1,33 +1,12 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateAdditionalFilesList', - 'generateAdditionalNamesBox', - 'generateAlbumArtworkColumn', - 'generateAlbumBanner', - 'generateAlbumNavAccent', - 'generateAlbumReleaseInfo', - 'generateAlbumSecondaryNav', - 'generateAlbumSidebar', - 'generateAlbumSocialEmbed', - 'generateAlbumStyleRules', - 'generateAlbumTrackList', - 'generateCommentaryEntry', - 'generateContentHeading', - 'generatePageLayout', - 'linkAlbumCommentary', - 'linkAlbumGallery', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, album) => ({ layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), socialEmbed: relation('generateAlbumSocialEmbed', album), @@ -64,23 +43,30 @@ export default { : null), commentaryLink: - ([album, ...album.tracks].some(({commentary}) => !empty(commentary)) + (album.tracks.some(track => !empty(track.commentary)) ? relation('linkAlbumCommentary', album) : null), + readCommentaryLine: + relation('generateReadCommentaryLine', album), + trackList: relation('generateAlbumTrackList', album), additionalFilesList: relation('generateAdditionalFilesList', album.additionalFiles), + commentaryContentHeading: + relation('generateCommentaryContentHeading', album), + artistCommentaryEntries: album.commentary .map(entry => relation('generateCommentaryEntry', entry)), - creditSourceEntries: - album.creditSources - .map(entry => relation('generateCommentaryEntry', entry)), + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + album.creditingSources, + album), }), data: (album) => ({ @@ -104,7 +90,7 @@ export default { color: data.color, headingMode: 'sticky', - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, additionalNames: relations.additionalNamesBox, @@ -156,12 +142,16 @@ export default { : html.blank()), - !html.isBlank(relations.creditSourceEntries) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !relations.commentaryLink && + !html.isBlank(relations.artistCommentaryEntries) && + relations.readCommentaryLine, + + !html.isBlank(relations.creditingSourcesSection) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -170,14 +160,14 @@ export default { html.tag('p', {[html.onlyIfContent]: true}, - {[html.joinChildren]: html.tag('br')}, - language.encapsulate('releaseInfo', capsule => [ - language.$(capsule, 'addedToWiki', { - [language.onlyIfOptions]: ['date'], - date: language.formatDate(data.dateAddedToWiki), - }), - ])), + language.$('releaseInfo.addedToWiki', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.dateAddedToWiki), + })), + + !html.isBlank(relations.artistCommentaryEntries) && + html.tag('hr', {class: 'main-separator'}), language.encapsulate('releaseInfo.additionalFiles', capsule => html.tags([ @@ -191,24 +181,14 @@ export default { ])), html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'artist-commentary'}, - title: language.$('misc.artistCommentary'), - }), - + relations.commentaryContentHeading, relations.artistCommentaryEntries, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'credit-sources'}, - title: language.$('misc.creditSources'), - }), - - relations.creditSourceEntries, - ]), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', + }), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js index 432c5f3d..237120f3 100644 --- a/src/content/dependencies/generateAlbumNavAccent.js +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -1,17 +1,6 @@ import {atOffset, empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkTrack', - 'linkAlbumCommentary', - 'linkAlbumGallery', - ], - - extraDependencies: ['html', 'language'], - query(album, track) { const query = {}; @@ -64,9 +53,8 @@ export default { hasMultipleTracks: album.tracks.length > 1, - commentaryPageIsStub: - [album, ...album.tracks] - .every(({commentary}) => empty(commentary)), + hasSubstantialCommentaryPage: + album.tracks.some(track => !empty(track.commentary)), galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt), @@ -97,14 +85,16 @@ export default { relations.nextLink.slot('link', relations.nextTrackLink); const galleryLink = - (!data.galleryIsStub || slots.currentExtra === 'gallery') && + (!data.galleryIsStub || + slots.currentExtra === 'gallery') && relations.albumGalleryLink.slots({ attributes: {class: slots.currentExtra === 'gallery' && 'current'}, content: language.$(albumNavCapsule, 'gallery'), }); const commentaryLink = - (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') && + (data.hasSubstantialCommentaryPage || + slots.currentExtra === 'commentary') && relations.albumCommentaryLink.slots({ attributes: {class: slots.currentExtra === 'commentary' && 'current'}, content: language.$(albumNavCapsule, 'commentary'), diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js index 7586393c..e4022f0d 100644 --- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js @@ -1,19 +1,10 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToAlbumLink', - 'generateReferencedArtworksPage', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, album) => ({ page: relation('generateReferencedArtworksPage', album.coverArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), albumLink: relation('linkAlbum', album), @@ -35,7 +26,7 @@ export default { data.name, }), - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, navLinks: [ {auto: 'home'}, diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js index d072d2f6..0dc1bf15 100644 --- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js @@ -1,19 +1,10 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToAlbumLink', - 'generateReferencingArtworksPage', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, album) => ({ page: relation('generateReferencingArtworksPage', album.coverArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), albumLink: relation('linkAlbum', album), @@ -35,7 +26,7 @@ export default { data.name, }), - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, navLinks: [ {auto: 'home'}, diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index 0abb412c..4cec4120 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -1,28 +1,14 @@ import {accumulateSum, empty} from '#sugar'; export default { - contentDependencies: [ - 'generateReleaseInfoContributionsLine', - 'linkExternal', - ], - - extraDependencies: ['html', 'language'], - relations(relation, album) { const relations = {}; relations.artistContributionsLine = relation('generateReleaseInfoContributionsLine', album.artistContribs); - relations.wallpaperArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); - - relations.bannerArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); - - relations.externalLinks = - album.urls.map(url => - relation('linkExternal', url)); + relations.listenLine = + relation('generateReleaseInfoListenLine', album); return relations; }, @@ -43,7 +29,7 @@ export default { .map(track => track.duration) .filter(value => value > 0); - if (empty(durationTerms)) { + if (empty(durationTerms) || album.hideDuration) { data.duration = null; data.durationApproximate = null; } else { @@ -87,21 +73,16 @@ export default { html.tag('p', {[html.onlyIfContent]: true}, - language.$(capsule, 'listenOn', { - [language.onlyIfOptions]: ['links'], - - links: - language.formatDisjunctionList( - relations.externalLinks - .map(link => - link.slot('context', [ - 'album', - (data.numTracks === 0 - ? 'albumNoTracks' - : data.numTracks === 1 - ? 'albumOneTrack' - : 'albumMultipleTracks'), - ]))), + relations.listenLine.slots({ + context: [ + 'album', + + (data.numTracks === 0 + ? 'albumNoTracks' + : data.numTracks === 1 + ? 'albumOneTrack' + : 'albumMultipleTracks'), + ], })), ])), }; diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js index bfa48f03..2140bfdb 100644 --- a/src/content/dependencies/generateAlbumSecondaryNav.js +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -1,15 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumSecondaryNavGroupPart', - 'generateAlbumSecondaryNavSeriesPart', - 'generateDotSwitcherTemplate', - 'generateSecondaryNav', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({groupData}) => ({ // TODO: Series aren't their own things, so we access them weirdly. seriesData: diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js index 22dfa51c..2f08804b 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js @@ -2,15 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html'], - query(group, album) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js index 16f205e3..ee180f16 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - query(series, album) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js index 7cf689cc..83a637b0 100644 --- a/src/content/dependencies/generateAlbumSidebar.js +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -2,17 +2,6 @@ import {sortAlbumsTracksChronologically} from '#sort'; import {stitchArrays, transposeArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumSidebarGroupBox', - 'generateAlbumSidebarSeriesBox', - 'generateAlbumSidebarTrackListBox', - 'generatePageSidebar', - 'generatePageSidebarConjoinedBox', - 'generateTrackReleaseBox', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({groupData}) => ({ // TODO: Series aren't their own things, so we access them weirdly. seriesData: @@ -46,7 +35,8 @@ export default { const allReleaseAlbums = sortAlbumsTracksChronologically( - Array.from(albumTrackMap.keys())); + Array.from(albumTrackMap.keys()), + {getDate: album => albumTrackMap.get(album).date}); const currentReleaseIndex = allReleaseAlbums.indexOf(track.album); @@ -108,39 +98,65 @@ export default { : null), }), - data: (_query, _sprawl, _album, track) => ({ + data: (_query, _sprawl, album, track) => ({ isAlbumPage: !track, isTrackPage: !!track, + + albumStyle: album.style, }), generate(data, relations, {html}) { + const presentGroupsLikeAlbum = + data.isAlbumPage || + data.albumStyle === 'single'; + for (const box of [ ...relations.groupBoxes, ...relations.seriesBoxes.flat(), ...relations.disconnectedSeriesBoxes, ]) { - box.setSlot('mode', - data.isAlbumPage ? 'album' : 'track'); + box.setSlot('mode', presentGroupsLikeAlbum ? 'album' : 'track'); } + const groupBoxes = + (presentGroupsLikeAlbum + ? [ + relations.disconnectedSeriesBoxes, + + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).map(({groupBox, seriesBoxes}) => [ + groupBox, + seriesBoxes.map(seriesBox => [ + html.tag('div', + {class: 'sidebar-box-joiner'}, + {class: 'collapsible'}), + seriesBox, + ]), + ]), + ] + : [ + relations.conjoinedBox.slots({ + attributes: {class: 'conjoined-group-sidebar-box'}, + boxes: + ([relations.disconnectedSeriesBoxes, + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).flatMap(({groupBox, seriesBoxes}) => [ + groupBox, + ...seriesBoxes, + ]), + ]).flat() + .map(box => box.content), /* TODO: Kludge. */ + }) + ]); + return relations.sidebar.slots({ boxes: [ - data.isAlbumPage && [ - relations.disconnectedSeriesBoxes, - - stitchArrays({ - groupBox: relations.groupBoxes, - seriesBoxes: relations.seriesBoxes, - }).map(({groupBox, seriesBoxes}) => [ - groupBox, - seriesBoxes.map(seriesBox => [ - html.tag('div', - {class: 'sidebar-box-joiner'}, - {class: 'collapsible'}), - seriesBox, - ]), - ]), - ], + data.isAlbumPage && + groupBoxes, data.isTrackPage && relations.earlierTrackReleaseBoxes, @@ -151,20 +167,7 @@ export default { relations.laterTrackReleaseBoxes, data.isTrackPage && - relations.conjoinedBox.slots({ - attributes: {class: 'conjoined-group-sidebar-box'}, - boxes: - ([relations.disconnectedSeriesBoxes, - stitchArrays({ - groupBox: relations.groupBoxes, - seriesBoxes: relations.seriesBoxes, - }).flatMap(({groupBox, seriesBoxes}) => [ - groupBox, - ...seriesBoxes, - ]), - ]).flat() - .map(box => box.content), /* TODO: Kludge. */ - }), + groupBoxes, ], }); }, diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js index f3be74f7..0a9c0db9 100644 --- a/src/content/dependencies/generateAlbumSidebarGroupBox.js +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -2,16 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkAlbum', - 'linkExternal', - 'linkGroup', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album, group) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js index 37616cb2..22f1fe72 100644 --- a/src/content/dependencies/generateAlbumSidebarSeriesBox.js +++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkAlbum', - 'linkGroup', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album, series) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js index 3a244e3a..4e9437c9 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackListBox.js +++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAlbumSidebarTrackSection', - 'generatePageSidebarBox', - 'linkAlbum', - ], - - extraDependencies: ['html'], - relations: (relation, album, track) => ({ box: relation('generatePageSidebarBox'), @@ -24,7 +16,9 @@ export default { attributes: {class: 'track-list-sidebar-box'}, content: [ - html.tag('h1', relations.albumLink), + html.tag('h1', {[html.onlyIfSiblings]: true}, + relations.albumLink), + relations.trackSections, ], }) diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js index dae5fa03..68281bfe 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackSection.js +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -1,9 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['getColors', 'html', 'language'], - relations(relation, album, track, trackSection) { const relations = {}; @@ -22,10 +19,12 @@ export default { !empty(trackSection.tracks); data.isTrackPage = !!track; + data.albumStyle = album.style; data.name = trackSection.name; data.color = trackSection.color; data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + data.hasSiblingSections = album.trackSections.length > 1; data.firstTrackNumber = (data.hasTrackNumbers @@ -115,6 +114,21 @@ export default { : trackLink), }))); + const list = + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)); + + if (data.albumStyle === 'single' && !data.hasSiblingSections) { + if (trackListItems.length <= 1) { + return html.blank(); + } else { + return list; + } + } + return html.tag('details', data.includesCurrentTrack && {class: 'current'}, @@ -157,11 +171,7 @@ export default { return language.$(workingCapsule, workingOptions); })))), - (data.hasTrackNumbers - ? html.tag('ol', - {start: data.firstTrackNumber}, - trackListItems) - : html.tag('ul', trackListItems)), + list, ]); }, }; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js index e28a3fd0..1200ec8b 100644 --- a/src/content/dependencies/generateAlbumSocialEmbed.js +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -1,13 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateSocialEmbed', - 'generateAlbumSocialEmbedDescription', - ], - - extraDependencies: ['absoluteTo', 'language'], - relations(relation, album) { return { socialEmbed: diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js index 69c39c3a..db6da5b7 100644 --- a/src/content/dependencies/generateAlbumSocialEmbedDescription.js +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -1,8 +1,6 @@ import {accumulateSum} from '#sugar'; export default { - extraDependencies: ['language'], - data: (album) => ({ duration: accumulateSum(album.tracks, track => track.duration), diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js deleted file mode 100644 index 6bfcc62e..00000000 --- a/src/content/dependencies/generateAlbumStyleRules.js +++ /dev/null @@ -1,107 +0,0 @@ -import {empty, stitchArrays} from '#sugar'; - -export default { - extraDependencies: ['to'], - - data(album, track) { - const data = {}; - - data.hasWallpaper = !empty(album.wallpaperArtistContribs); - data.hasBanner = !empty(album.bannerArtistContribs); - - if (data.hasWallpaper) { - if (!empty(album.wallpaperParts)) { - data.wallpaperMode = 'parts'; - - data.wallpaperPaths = - album.wallpaperParts.map(part => - (part.asset - ? ['media.albumWallpaperPart', album.directory, part.asset] - : null)); - - data.wallpaperStyles = - album.wallpaperParts.map(part => part.style); - } else { - data.wallpaperMode = 'one'; - data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; - data.wallpaperStyle = album.wallpaperStyle; - } - } - - if (data.hasBanner) { - data.hasBannerStyle = !!album.bannerStyle; - data.bannerStyle = album.bannerStyle; - } - - data.albumDirectory = album.directory; - - if (track) { - data.trackDirectory = track.directory; - } - - return data; - }, - - generate(data, {to}) { - const indent = parts => - (parts ?? []) - .filter(Boolean) - .join('\n') - .split('\n') - .map(line => ' '.repeat(4) + line) - .join('\n'); - - const rule = (selector, parts) => - (!empty(parts.filter(Boolean)) - ? [`${selector} {`, indent(parts), `}`] - : []); - - const oneWallpaperRule = - data.wallpaperMode === 'one' && - rule(`body::before`, [ - `background-image: url("${to(...data.wallpaperPath)}");`, - data.wallpaperStyle, - ]); - - const wallpaperPartRules = - data.wallpaperMode === 'parts' && - stitchArrays({ - path: data.wallpaperPaths, - style: data.wallpaperStyles, - }).map(({path, style}, index) => - rule(`.wallpaper-part:nth-child(${index + 1})`, [ - path && `background-image: url("${to(...path)}");`, - style, - ])); - - const nukeBasicWallpaperRule = - data.wallpaperMode === 'parts' && - rule(`body::before`, ['display: none']); - - const wallpaperRules = [ - oneWallpaperRule, - ...wallpaperPartRules || [], - nukeBasicWallpaperRule, - ]; - - const bannerRule = - data.hasBanner && - rule(`#banner img`, [ - data.bannerStyle, - ]); - - const dataRule = - rule(`:root`, [ - data.albumDirectory && - `--album-directory: ${data.albumDirectory};`, - data.trackDirectory && - `--track-directory: ${data.trackDirectory};`, - ]); - - return ( - [...wallpaperRules, bannerRule, dataRule] - .filter(Boolean) - .flat() - .join('\n')); - }, -}; diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js new file mode 100644 index 00000000..caf21dc4 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleTags.js @@ -0,0 +1,62 @@ +import {empty} from '#sugar'; + +export default { + relations: (relation, album, _track) => ({ + styleTag: + relation('generateStyleTag'), + + wallpaperStyleTag: + relation('generateAlbumWallpaperStyleTag', album), + }), + + data(album, track) { + const data = {}; + + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + data.albumDirectory = album.directory; + + if (track) { + data.trackDirectory = track.directory; + } + + return data; + }, + + generate: (data, relations, {html}) => + html.tags([ + relations.wallpaperStyleTag, + + relations.styleTag.clone().slots({ + attributes: {class: 'album-banner-style'}, + + rules: [ + data.hasBanner && { + select: '#banner img', + declare: [data.bannerStyle], + }, + ], + }), + + relations.styleTag.clone().slots({ + attributes: {class: 'album-directory-style'}, + + rules: [ + { + select: ':root', + declare: [ + data.albumDirectory && + `--album-directory: ${data.albumDirectory};`, + data.trackDirectory && + `--track-directory: ${data.trackDirectory};`, + ], + }, + ] + }), + ], {[html.joinChildren]: ''}), +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js index 0a949ded..93cb420b 100644 --- a/src/content/dependencies/generateAlbumTrackList.js +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -35,14 +35,6 @@ function getDisplayMode(album) { } export default { - contentDependencies: [ - 'generateAlbumTrackListItem', - 'generateContentHeading', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album) { return { displayMode: getDisplayMode(album), diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index 44297c15..68722a83 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTrackListItem'], - extraDependencies: ['html'], - query: (track, album) => ({ trackHasDuration: !!track.duration, @@ -20,7 +17,7 @@ export default { item: relation('generateTrackListItem', track, - track.album.artistContribs), + track.album.trackArtistContribs), }), data: (query, track, album) => ({ @@ -43,7 +40,7 @@ export default { generate: (data, relations, slots) => relations.item.slots({ - showArtists: true, + showArtists: 'auto', showDuration: (slots.collapseDurationScope === 'track' diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js new file mode 100644 index 00000000..b3f74716 --- /dev/null +++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js @@ -0,0 +1,35 @@ +export default { + relations: (relation, album) => ({ + wallpaperStyleTag: + (album.hasWallpaperArt + ? relation('generateWallpaperStyleTag') + : null), + }), + + data: (album) => ({ + singleWallpaperPath: + ['media.albumWallpaper', album.directory, album.wallpaperFileExtension], + + singleWallpaperStyle: + album.wallpaperStyle, + + wallpaperPartPaths: + album.wallpaperParts.map(part => + (part.asset + ? ['media.albumWallpaperPart', album.directory, part.asset] + : null)), + + wallpaperPartStyles: + album.wallpaperParts.map(part => part.style), + }), + + generate: (data, relations, {html}) => + (relations.wallpaperStyleTag + ? relations.wallpaperStyleTag.slots({ + singleWallpaperPath: data.singleWallpaperPath, + singleWallpaperStyle: data.singleWallpaperStyle, + wallpaperPartPaths: data.wallpaperPartPaths, + wallpaperPartStyles: data.wallpaperPartStyles, + }) + : html.blank()), +}; diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js index 80d19b5a..37a32a94 100644 --- a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js +++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js @@ -6,9 +6,6 @@ import { } from '#sugar'; export default { - contentDependencies: ['linkArtTagDynamically'], - extraDependencies: ['html', 'language'], - // Recursion ain't too pretty! query(ancestorArtTag, targetArtTag) { diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js index cfd6d03e..f20babba 100644 --- a/src/content/dependencies/generateArtTagGalleryPage.js +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -2,22 +2,6 @@ import {sortArtworksChronologically} from '#sort'; import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generateAdditionalNamesBox', - 'generateArtTagGalleryPageFeaturedLine', - 'generateArtTagGalleryPageShowingLine', - 'generateArtTagNavLinks', - 'generateCoverGrid', - 'generatePageLayout', - 'generateQuickDescription', - 'image', - 'linkAnythingMan', - 'linkArtTagGallery', - 'linkExternal', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({wikiInfo}) { return { enableListings: wikiInfo.enableListings, diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js index b4620fa4..8593cc21 100644 --- a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js +++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - slots: { showing: { validate: v => v.is('all', 'direct', 'indirect'), diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js index 6df4d0e5..2a34ae57 100644 --- a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js +++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - slots: { showing: { validate: v => v.is('all', 'direct', 'indirect'), diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js index 9df51b77..683eeab6 100644 --- a/src/content/dependencies/generateArtTagInfoPage.js +++ b/src/content/dependencies/generateArtTagInfoPage.js @@ -1,20 +1,6 @@ import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generateAdditionalNamesBox', - 'generateArtTagNavLinks', - 'generateArtTagSidebar', - 'generateContentHeading', - 'generatePageLayout', - 'linkArtTagGallery', - 'linkArtTagInfo', - 'linkExternal', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableListings: wikiInfo.enableListings, }), @@ -182,12 +168,12 @@ export default { artTagLink: relations.relatedArtTagLinks, annotation: data.relatedArtTagAnnotations, }).map(({artTagLink, annotation}) => - (html.isBlank(annotation) - ? artTagLink - : language.$(capsule, 'tagWithAnnotation', { + (annotation + ? language.$(capsule, 'tagWithAnnotation', { tag: artTagLink, annotation, - })))), + }) + : artTagLink))), }))), html.tag('blockquote', diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js index 9061a09f..1298ce99 100644 --- a/src/content/dependencies/generateArtTagNavLinks.js +++ b/src/content/dependencies/generateArtTagNavLinks.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'linkArtTagInfo', - 'linkArtTagGallery', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({enableListings: wikiInfo.enableListings}), diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js index 9e2f813c..60ea504f 100644 --- a/src/content/dependencies/generateArtTagSidebar.js +++ b/src/content/dependencies/generateArtTagSidebar.js @@ -1,15 +1,6 @@ import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebar', - 'generatePageSidebarBox', - 'generateArtTagAncestorDescendantMapList', - 'linkArtTagDynamically', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({artTagData}) => ({artTagData}), diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js index a4135489..19c66b8a 100644 --- a/src/content/dependencies/generateArtistArtworkColumn.js +++ b/src/content/dependencies/generateArtistArtworkColumn.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateCoverArtwork'], - relations: (relation, artist) => ({ coverArtwork: (artist.hasAvatar diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js index bab32f7d..389de740 100644 --- a/src/content/dependencies/generateArtistCredit.js +++ b/src/content/dependencies/generateArtistCredit.js @@ -1,14 +1,7 @@ -import {compareArrays, empty} from '#sugar'; +import {compareArrays, empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistCreditWikiEditsPart', - 'linkContribution', - ], - - extraDependencies: ['html', 'language'], - - query: (creditContributions, contextContributions) => { + query: (creditContributions, contextContributions, _formatText) => { const query = {}; const featuringFilter = contribution => @@ -52,7 +45,10 @@ export default { return query; }, - relations: (relation, query, _creditContributions, _contextContributions) => ({ + relations: (relation, query, + _creditContributions, + _contextContributions, + formatText) => ({ normalContributionLinks: query.normalContributions .map(contrib => relation('linkContribution', contrib)), @@ -64,15 +60,26 @@ export default { wikiEditsPart: relation('generateArtistCreditWikiEditsPart', query.wikiEditContributions), + + formatText: + relation('transformContent', formatText), }), - data: (query, _creditContributions, _contextContributions) => ({ + data: (query, _creditContributions, _contextContributions, _formatText) => ({ normalContributionArtistsDifferFromContext: query.normalContributionArtistsDifferFromContext, normalContributionAnnotationsDifferFromContext: query.normalContributionAnnotationsDifferFromContext, + normalContributionArtistDirectories: + query.normalContributions + .map(contrib => contrib.artist.directory), + + featuringContributionArtistDirectories: + query.featuringContributions + .map(contrib => contrib.artist.directory), + hasWikiEdits: !empty(query.wikiEditContributions), }), @@ -105,6 +112,10 @@ export default { generate(data, relations, slots, {html, language}) { if (!slots.normalStringKey) return html.blank(); + const effectivelyDiffers = + (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) || + (data.normalContributionArtistsDifferFromContext); + for (const link of [ ...relations.normalContributionLinks, ...relations.featuringContributionLinks, @@ -132,63 +143,112 @@ export default { }); } - if (empty(relations.normalContributionLinks)) { - return html.blank(); - } + let formattedArtistList = null; - const artistsList = - (data.hasWikiEdits && slots.showWikiEdits - ? language.$('misc.artistLink.withEditsForWiki', { - artists: - language.formatConjunctionList(relations.normalContributionLinks), + if (!html.isBlank(relations.formatText)) { + formattedArtistList = relations.formatText; - edits: - relations.wikiEditsPart.slots({ - showAnnotation: slots.showAnnotation, - }), - }) - : language.formatConjunctionList(relations.normalContributionLinks)); + const substituteContrib = ({link, directory}) => ({ + match: {replacerKey: 'artist', replacerValue: directory}, + substitute: link, - const featuringList = - language.formatConjunctionList(relations.featuringContributionLinks); + apply(link, node) { + if (node.data.label) { + link.setSlot('content', language.sanitize(node.data.label)); + } + }, + }); - const everyoneList = - language.formatConjunctionList([ - ...relations.normalContributionLinks, - ...relations.featuringContributionLinks, - ]); + relations.formatText.setSlots({ + mode: 'inline', - const effectivelyDiffers = - (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) || - (data.normalContributionArtistsDifferFromContext); + substitute: [ + stitchArrays({ + link: relations.normalContributionLinks, + directory: data.normalContributionArtistDirectories, + }).map(substituteContrib), - if (empty(relations.featuringContributionLinks)) { + stitchArrays({ + link: relations.featuringContributionLinks, + directory: data.featuringContributionArtistDirectories, + }).map(substituteContrib), + ].flat(), + }); + } + + let content; + + if (formattedArtistList) { if (effectivelyDiffers) { - return language.$(slots.normalStringKey, { - ...slots.additionalStringOptions, - artists: artistsList, + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: formattedArtistList, + }); + } + } else { + if (empty(relations.normalContributionLinks)) { + return html.blank(); + } + + const artistsList = + (data.hasWikiEdits && slots.showWikiEdits + ? language.$('misc.artistLink.withEditsForWiki', { + artists: + language.formatConjunctionList(relations.normalContributionLinks), + + edits: + relations.wikiEditsPart.slots({ + showAnnotation: slots.showAnnotation, + }), + }) + + : language.formatConjunctionList(relations.normalContributionLinks)); + + const featuringList = + language.formatConjunctionList(relations.featuringContributionLinks); + + const everyoneList = + language.formatConjunctionList([ + ...relations.normalContributionLinks, + ...relations.featuringContributionLinks, + ]); + + if (empty(relations.featuringContributionLinks)) { + if (effectivelyDiffers) { + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + }); + } else { + return html.blank(); + } + } else if (effectivelyDiffers && slots.normalFeaturingStringKey) { + content = + language.$(slots.normalFeaturingStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + featuring: featuringList, }); + } else if (slots.featuringStringKey) { + content = + language.$(slots.featuringStringKey, { + ...slots.additionalStringOptions, + artists: featuringList, + }); } else { - return html.blank(); + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: everyoneList, + }); } } - if (effectivelyDiffers && slots.normalFeaturingStringKey) { - return language.$(slots.normalFeaturingStringKey, { - ...slots.additionalStringOptions, - artists: artistsList, - featuring: featuringList, - }); - } else if (slots.featuringStringKey) { - return language.$(slots.featuringStringKey, { - ...slots.additionalStringOptions, - artists: featuringList, - }); - } else { - return language.$(slots.normalStringKey, { - ...slots.additionalStringOptions, - artists: everyoneList, - }); - } + // TODO: This is obviously evil. + return ( + html.metatag('chunkwrap', {split: /,| (?=and)/}, + html.resolve(content))); }, }; diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js index 70296e39..4178928d 100644 --- a/src/content/dependencies/generateArtistCreditWikiEditsPart.js +++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateTextWithTooltip', - 'generateTooltip', - 'linkContribution', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contributions) => ({ textWithTooltip: relation('generateTextWithTooltip'), @@ -48,6 +40,7 @@ export default { showAnnotation: slots.showAnnotation, trimAnnotation: true, preventTooltip: true, + preventWrapping: true, }))), }), }), diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js index 6a24275e..d8f1c4b1 100644 --- a/src/content/dependencies/generateArtistGalleryPage.js +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -1,16 +1,6 @@ import {sortArtworksChronologically} from '#sort'; export default { - contentDependencies: [ - 'generateArtistNavLinks', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAnythingMan', - ], - - extraDependencies: ['html', 'language'], - query: (artist) => ({ artworks: sortArtworksChronologically( @@ -58,6 +48,10 @@ export default { .map(artwork => artwork.artistContribs .filter(contrib => contrib.artist !== artist) .map(contrib => contrib.artist.name)), + + allWarnings: + query.artworks + .flatMap(artwork => artwork.contentWarnings), }), generate: (data, relations, {html, language}) => @@ -93,6 +87,8 @@ export default { artists: language.formatUnitList(names), })), + + revealAllWarnings: data.allWarnings, }), ], diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js index 3e0cd1d2..6940053f 100644 --- a/src/content/dependencies/generateArtistGroupContributionsInfo.js +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -1,83 +1,87 @@ -import {empty, filterProperties, stitchArrays, unique} from '#sugar'; +import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar'; export default { - contentDependencies: ['linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], + sprawl: ({groupCategoryData}) => ({ + groupOrder: + groupCategoryData.flatMap(category => category.groups), + }), - sprawl({groupCategoryData}) { - return { - groupOrder: groupCategoryData.flatMap(category => category.groups), - } - }, + query(sprawl, contributions) { + const allGroupsUnordered = + new Set(contributions.flatMap(contrib => contrib.groups)); - query(sprawl, tracksAndAlbums) { - const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album); - const filteredTracks = tracksAndAlbums.filter(thing => thing.album); + const allGroupsOrdered = + sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); - const allAlbums = unique([ - ...filteredAlbums, - ...filteredTracks.map(track => track.album), - ]); + const groupToThingsCountedForContributions = + new Map(allGroupsOrdered.map(group => [group, new Set])); - const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups)); - const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); + const groupToThingsCountedForDuration = + new Map(allGroupsOrdered.map(group => [group, new Set])); - const mapTemplate = allGroupsOrdered.map(group => [group, 0]); - const groupToCountMap = new Map(mapTemplate); - const groupToDurationMap = new Map(mapTemplate); - const groupToDurationCountMap = new Map(mapTemplate); - - for (const album of filteredAlbums) { - for (const group of album.groups) { - groupToCountMap.set(group, groupToCountMap.get(group) + 1); - } - } + for (const contrib of contributions) { + for (const group of contrib.groups) { + if (contrib.countInContributionTotals) { + groupToThingsCountedForContributions.get(group).add(contrib.thing); + } - for (const track of filteredTracks) { - for (const group of track.album.groups) { - groupToCountMap.set(group, groupToCountMap.get(group) + 1); - if (track.duration && track.mainReleaseTrack === null) { - groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration); - groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1); + if (contrib.countInDurationTotals) { + groupToThingsCountedForDuration.get(group).add(contrib.thing); } } } + const groupToTotalContributions = + withEntries( + groupToThingsCountedForContributions, + entries => entries.map( + ([group, things]) => + ([group, things.size]))); + + const groupToTotalDuration = + withEntries( + groupToThingsCountedForDuration, + entries => entries.map( + ([group, things]) => + ([group, accumulateSum(things, thing => thing.duration)]))) + const groupsSortedByCount = allGroupsOrdered - .slice() - .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a)); + .filter(group => groupToTotalContributions.get(group) > 0) + .sort((a, b) => + (groupToTotalContributions.get(b) + - groupToTotalContributions.get(a))); - // The filter here ensures all displayed groups have at least some duration - // when sorting by duration. const groupsSortedByDuration = allGroupsOrdered - .filter(group => groupToDurationMap.get(group) > 0) - .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a)); + .filter(group => groupToTotalDuration.get(group) > 0) + .sort((a, b) => + (groupToTotalDuration.get(b) + - groupToTotalDuration.get(a))); const groupCountsSortedByCount = groupsSortedByCount - .map(group => groupToCountMap.get(group)); + .map(group => groupToTotalContributions.get(group)); const groupDurationsSortedByCount = groupsSortedByCount - .map(group => groupToDurationMap.get(group)); + .map(group => groupToTotalDuration.get(group)); const groupDurationsApproximateSortedByCount = groupsSortedByCount - .map(group => groupToDurationCountMap.get(group) > 1); + .map(group => groupToThingsCountedForDuration.get(group).size > 1); const groupCountsSortedByDuration = groupsSortedByDuration - .map(group => groupToCountMap.get(group)); + .map(group => groupToTotalContributions.get(group)); const groupDurationsSortedByDuration = groupsSortedByDuration - .map(group => groupToDurationMap.get(group)); + .map(group => groupToTotalDuration.get(group)); const groupDurationsApproximateSortedByDuration = groupsSortedByDuration - .map(group => groupToDurationCountMap.get(group) > 1); + .map(group => groupToThingsCountedForDuration.get(group).size > 1); return { groupsSortedByCount, @@ -93,29 +97,35 @@ export default { }; }, - relations(relation, query) { - return { - groupLinksSortedByCount: - query.groupsSortedByCount - .map(group => relation('linkGroup', group)), + relations: (relation, query) => ({ + groupLinksSortedByCount: + query.groupsSortedByCount + .map(group => relation('linkGroup', group)), - groupLinksSortedByDuration: - query.groupsSortedByDuration - .map(group => relation('linkGroup', group)), - }; - }, + groupLinksSortedByDuration: + query.groupsSortedByDuration + .map(group => relation('linkGroup', group)), + }), - data(query) { - return filterProperties(query, [ - 'groupCountsSortedByCount', - 'groupDurationsSortedByCount', - 'groupDurationsApproximateSortedByCount', + data: (query) => ({ + groupCountsSortedByCount: + query.groupCountsSortedByCount, - 'groupCountsSortedByDuration', - 'groupDurationsSortedByDuration', - 'groupDurationsApproximateSortedByDuration', - ]); - }, + groupDurationsSortedByCount: + query.groupDurationsSortedByCount, + + groupDurationsApproximateSortedByCount: + query.groupDurationsApproximateSortedByCount, + + groupCountsSortedByDuration: + query.groupCountsSortedByDuration, + + groupDurationsSortedByDuration: + query.groupDurationsSortedByDuration, + + groupDurationsApproximateSortedByDuration: + query.groupDurationsApproximateSortedByDuration, + }), slots: { title: { diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index 3a3cf8b7..cf8ce994 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -1,48 +1,18 @@ import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistArtworkColumn', - 'generateArtistGroupContributionsInfo', - 'generateArtistInfoPageArtworksChunkedList', - 'generateArtistInfoPageCommentaryChunkedList', - 'generateArtistInfoPageFlashesChunkedList', - 'generateArtistInfoPageTracksChunkedList', - 'generateArtistNavLinks', - 'generateContentHeading', - 'generatePageLayout', - 'linkArtistGallery', - 'linkExternal', - 'linkGroup', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query: (artist) => ({ - // Even if an artist has served as both "artist" (compositional) and - // "contributor" (instruments, production, etc) on the same track, that - // track only counts as one unique contribution in the list. - allTracks: - unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - ]).flat() - .map(({thing}) => thing)), - - // Artworks are different, though. We intentionally duplicate album data - // objects when the artist has contributed some combination of cover art, - // wallpaper, and banner - these each count as a unique contribution. - allArtworkThings: - ([ - artist.albumCoverArtistContributions, - artist.albumWallpaperArtistContributions, - artist.albumBannerArtistContributions, - artist.trackCoverArtistContributions, - ]).flat() - .filter(({annotation}) => !annotation?.startsWith('edits for wiki')) - .map(({thing}) => thing.thing), + trackContributions: [ + ...artist.trackArtistContributions, + ...artist.trackContributorContributions, + ], + + artworkContributions: [ + ...artist.albumCoverArtistContributions, + ...artist.albumWallpaperArtistContributions, + ...artist.albumBannerArtistContributions, + ...artist.trackCoverArtistContributions, + ], // Banners and wallpapers don't show up in the artist gallery page, only // cover art. @@ -93,7 +63,7 @@ export default { relation('generateArtistInfoPageTracksChunkedList', artist), tracksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allTracks), + relation('generateArtistGroupContributionsInfo', query.trackContributions), artworksChunkedList: relation('generateArtistInfoPageArtworksChunkedList', artist, false), @@ -102,7 +72,7 @@ export default { relation('generateArtistInfoPageArtworksChunkedList', artist, true), artworksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allArtworkThings), + relation('generateArtistGroupContributionsInfo', query.artworkContributions), artistGalleryLink: (query.hasGallery @@ -128,7 +98,11 @@ export default { .map(({annotation}) => annotation), totalTrackCount: - query.allTracks.length, + unique( + query.trackContributions + .filter(contrib => contrib.countInContributionTotals) + .map(contrib => contrib.thing)) + .length, totalDuration: artist.totalDuration, diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js index 66e4204a..f4c9439a 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageArtworksChunkItem', - 'linkAlbum', - ], - - extraDependencies: ['html'], - relations: (relation, album, contribs) => ({ template: relation('generateArtistInfoPageChunk'), diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js index 2f2fe0c5..e3ba5342 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js @@ -1,19 +1,13 @@ -export default { - contentDependencies: [ - 'generateArtistInfoPageChunkItem', - 'generateArtistInfoPageOtherArtistLinks', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], +import {empty} from '#sugar'; +export default { query: (contrib) => ({ kind: - (contrib.isBannerArtistContribution + (contrib.thing.thingProperty === 'bannerArtwork' ? 'banner' - : contrib.isWallpaperArtistContribution + : contrib.thing.thingProperty === 'wallpaperArtwork' ? 'wallpaper' - : contrib.isForAlbum + : contrib.thing.thingProperty === 'coverArtworks' ? 'album-cover' : 'track-cover'), }), @@ -29,6 +23,9 @@ export default { otherArtistLinks: relation('generateArtistInfoPageOtherArtistLinks', [contrib]), + + originDetails: + relation('transformContent', contrib.thing.originDetails), }), data: (query, contrib) => ({ @@ -37,6 +34,9 @@ export default { annotation: contrib.annotation, + + label: + contrib.thing.label, }), slots: { @@ -51,9 +51,33 @@ export default { otherArtistLinks: relations.otherArtistLinks, annotation: - (slots.filterEditsForWiki - ? data.annotation?.replace(/^edits for wiki(: )?/, '') - : data.annotation), + language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => { + const workingOptions = {}; + + const artworkLabel = data.label; + + if (artworkLabel) { + workingCapsule += '.withLabel'; + workingOptions.label = + language.typicallyLowerCase(artworkLabel); + } + + const contribAnnotation = + (slots.filterEditsForWiki + ? data.annotation?.replace(/^edits for wiki(: )?/, '') + : data.annotation); + + if (contribAnnotation) { + workingCapsule += '.withAnnotation'; + workingOptions.annotation = contribAnnotation; + } + + if (empty(Object.keys(workingOptions))) { + return html.blank(); + } + + return language.$(workingCapsule, workingOptions); + }), content: language.encapsulate('artistPage.creditList.entry', capsule => @@ -68,5 +92,11 @@ export default { : data.kind === 'banner' ? language.$(capsule, 'bannerArt') : language.$(capsule, 'coverArt')))))), + + originDetails: + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }), }), }; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js index 75a4aa5a..40ffc5dd 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -3,11 +3,6 @@ import {sortAlbumsTracksChronologically, sortContributionsChronologically} import {chunkByConditions, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageArtworksChunk', - ], - query(artist, filterEditsForWiki) { const query = {}; diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js index fce68a7d..80429912 100644 --- a/src/content/dependencies/generateArtistInfoPageChunk.js +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -1,8 +1,6 @@ import {empty} from '#sugar'; export default { - extraDependencies: ['html', 'language'], - slots: { mode: { validate: v => v.is('flash', 'album'), diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js index 7987b642..8117ca9a 100644 --- a/src/content/dependencies/generateArtistInfoPageChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -1,9 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: ['generateTextWithTooltip'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ textWithTooltip: relation('generateTextWithTooltip'), @@ -33,6 +30,11 @@ export default { type: 'html', mutable: false, }, + + originDetails: { + type: 'html', + mutable: false, + }, }, generate: (relations, slots, {html, language}) => @@ -40,52 +42,59 @@ export default { html.tag('li', slots.rerelease && {class: 'rerelease'}, - language.encapsulate(entryCapsule, workingCapsule => { - const workingOptions = {entry: slots.content}; - - if (!html.isBlank(slots.rereleaseTooltip)) { - workingCapsule += '.rerelease'; - workingOptions.rerelease = - relations.textWithTooltip.slots({ - attributes: {class: 'rerelease'}, - text: language.$(entryCapsule, 'rerelease.term'), - tooltip: slots.rereleaseTooltip, - }); - - return language.$(workingCapsule, workingOptions); - } - - if (!html.isBlank(slots.firstReleaseTooltip)) { - workingCapsule += '.firstRelease'; - workingOptions.firstRelease = - relations.textWithTooltip.slots({ - attributes: {class: 'first-release'}, - text: language.$(entryCapsule, 'firstRelease.term'), - tooltip: slots.firstReleaseTooltip, - }); - - return language.$(workingCapsule, workingOptions); - } - - let anyAccent = false; - - if (!empty(slots.otherArtistLinks)) { - anyAccent = true; - workingCapsule += '.withArtists'; - workingOptions.artists = - language.formatConjunctionList(slots.otherArtistLinks); - } - - if (!html.isBlank(slots.annotation)) { - anyAccent = true; - workingCapsule += '.withAnnotation'; - workingOptions.annotation = slots.annotation; - } - - if (anyAccent) { - return language.$(workingCapsule, workingOptions); - } else { - return slots.content; - } - }))), + html.tags([ + language.encapsulate(entryCapsule, workingCapsule => { + const workingOptions = {entry: slots.content}; + + if (!html.isBlank(slots.rereleaseTooltip)) { + workingCapsule += '.rerelease'; + workingOptions.rerelease = + relations.textWithTooltip.slots({ + attributes: {class: 'rerelease'}, + text: language.$(entryCapsule, 'rerelease.term'), + tooltip: slots.rereleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + if (!html.isBlank(slots.firstReleaseTooltip)) { + workingCapsule += '.firstRelease'; + workingOptions.firstRelease = + relations.textWithTooltip.slots({ + attributes: {class: 'first-release'}, + text: language.$(entryCapsule, 'firstRelease.term'), + tooltip: slots.firstReleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + let anyAccent = false; + + if (!empty(slots.otherArtistLinks)) { + anyAccent = true; + workingCapsule += '.withArtists'; + workingOptions.artists = + language.formatConjunctionList(slots.otherArtistLinks); + } + + if (!html.isBlank(slots.annotation)) { + anyAccent = true; + workingCapsule += '.withAnnotation'; + workingOptions.annotation = slots.annotation; + } + + if (anyAccent) { + return language.$(workingCapsule, workingOptions); + } else { + return slots.content; + } + }), + + html.tag('span', {class: 'origin-details'}, + {[html.onlyIfContent]: true}, + + slots.originDetails), + ]))), }; diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js index e7915ab7..54577885 100644 --- a/src/content/dependencies/generateArtistInfoPageChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { groupInfo: { type: 'html', diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js index 88c5ed54..caec58d6 100644 --- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -7,18 +7,6 @@ import { } from '#sort'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageChunkItem', - 'linkAlbum', - 'linkFlash', - 'linkFlashAct', - 'linkTrack', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(artist, filterWikiEditorCommentary) { const processEntry = ({ thing, diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js index f86dead7..1d498b9f 100644 --- a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js +++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js @@ -1,19 +1,19 @@ -import {sortChronologically} from '#sort'; +import {sortAlbumsTracksChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateTooltip', - 'linkOtherReleaseOnArtistInfoPage', - ], +query: (track, artist) => ({ + rereleases: + sortAlbumsTracksChronologically( + track.otherReleases.filter(track => { + const contribs = [ + ...track.artistContribs, + ...track.contributorContribs, + ]; - extraDependencies: ['html', 'language'], - - query: (track) => ({ - rereleases: - sortChronologically(track.allReleases).slice(1), - }), + return contribs.some(contrib => contrib.artist === artist); + })), +}), relations: (relation, query, track, artist) => ({ tooltip: diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js index 8aa7223a..acdb9897 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js @@ -1,10 +1,4 @@ export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageFlashesChunkItem', - 'linkFlashAct', - ], - relations: (relation, flashAct, contribs) => ({ template: relation('generateArtistInfoPageChunk'), diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js index e4908bf9..36d7945d 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js @@ -1,8 +1,4 @@ export default { - contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'], - - extraDependencies: ['language'], - relations: (relation, contrib) => ({ // Flashes and games can list multiple contributors as collaborative // credits, but we don't display these on the artist page, since they diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js index b347faf5..762386a2 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -3,13 +3,6 @@ import {sortContributionsChronologically, sortFlashesChronologically} import {chunkByConditions, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageFlashesChunk', - ], - - extraDependencies: ['wikiData'], - sprawl: ({wikiInfo}) => ({ enableFlashesAndGames: wikiInfo.enableFlashesAndGames, diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js index dcee9c00..afb61c33 100644 --- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -1,8 +1,6 @@ import {unique} from '#sugar'; export default { - contentDependencies: ['linkArtist'], - query(contribs) { const associatedContributionsByOtherArtists = contribs diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js index 1d849919..bf5fe616 100644 --- a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js +++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js @@ -1,18 +1,22 @@ -import {sortChronologically} from '#sort'; +import {sortAlbumsTracksChronologically} from '#sort'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateTooltip', - 'linkOtherReleaseOnArtistInfoPage' - ], + query(track, artist) { + const query = {}; - extraDependencies: ['html', 'language'], + query.firstRelease = + sortAlbumsTracksChronologically(track.allReleases)[0]; - query: (track) => ({ - firstRelease: - sortChronologically(track.allReleases)[0], - }), + const contribs = [ + ...query.firstRelease.artistContribs, + ...query.firstRelease.contributorContribs, + ]; + + query.creditedOnFirstRelease = + contribs.some(contrib => contrib.artist === artist); + + return query; + }, relations: (relation, query, track, artist) => ({ tooltip: @@ -22,10 +26,15 @@ export default { relation('generateColorStyleAttribute', track.color), firstReleaseLink: - relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist), + (query.creditedOnFirstRelease + ? relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist) + : relation('linkTrackAsRelease', query.firstRelease)), }), - data: (query, track) => ({ + data: (query, track, artist) => ({ + artistName: + artist.name, + rereleaseDate: track.dateFirstReleased ?? track.album.date, @@ -33,6 +42,9 @@ export default { firstReleaseDate: query.firstRelease.dateFirstReleased ?? query.firstRelease.album.date, + + creditedOnFirstRelease: + query.creditedOnFirstRelease, }), generate: (data, relations, {html, language}) => @@ -56,6 +68,15 @@ export default { approximate: true, absolute: true, }), + + !data.creditedOnFirstRelease && [ + html.tag('hr', {class: 'cute'}), + + html.tag('span', {class: 'not-credited-on-first-release'}, + language.$(capsule, 'notCreditedOnFirstRelease', { + artist: data.artistName, + })), + ], ], })), }; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js index f6d70901..3e4cc4e9 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunk.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js @@ -2,12 +2,6 @@ import {unique} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageTracksChunkItem', - 'linkAlbum', - ], - relations: (relation, artist, album, trackContribLists) => ({ template: relation('generateArtistInfoPageChunk'), diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js index a42d6fee..e976c57f 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js @@ -1,18 +1,8 @@ -import {sortChronologically} from '#sort'; +import {sortAlbumsTracksChronologically} from '#sort'; import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkItem', - 'generateArtistInfoPageFirstReleaseTooltip', - 'generateArtistInfoPageOtherArtistLinks', - 'generateArtistInfoPageRereleaseTooltip', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - - query (_artist, contribs) { + query(artist, contribs) { const query = {}; // TODO: Very mysterious what to do if the set of contributions is, @@ -22,11 +12,11 @@ export default { const creditedAsArtist = contribs - .some(contrib => contrib.isArtistContribution); + .some(contrib => contrib.thingProperty === 'artistContribs'); const creditedAsContributor = contribs - .some(contrib => contrib.isContributorContribution); + .some(contrib => contrib.thingProperty === 'contributorContribs'); const annotatedContribs = contribs @@ -34,11 +24,11 @@ export default { const annotatedArtistContribs = annotatedContribs - .filter(contrib => contrib.isArtistContribution); + .filter(contrib => contrib.thingProperty === 'artistContribs'); const annotatedContributorContribs = annotatedContribs - .filter(contrib => contrib.isContributorContribution); + .filter(contrib => contrib.thingProperty === 'contributorContribs'); // Don't display annotations associated with crediting in the // Contributors field if the artist is also credited as an Artist @@ -73,16 +63,23 @@ export default { // different - and it's the latter that determines whether the // track is a rerelease! const allReleasesChronologically = - sortChronologically(query.track.allReleases); + sortAlbumsTracksChronologically(query.track.allReleases); query.isFirstRelease = allReleasesChronologically[0] === query.track; - query.isRerelease = + query.isLaterRelease = allReleasesChronologically[0] !== query.track; - query.hasOtherReleases = - !empty(query.track.otherReleases); + query.hasOtherCreditedReleases = + query.track.otherReleases.some(track => { + const contribs = [ + ...track.artistContribs, + ...track.contributorContribs, + ]; + + return contribs.some(contrib => contrib.artist === artist); + }); return query; }, @@ -98,12 +95,12 @@ export default { relation('generateArtistInfoPageOtherArtistLinks', contribs), rereleaseTooltip: - (query.isRerelease + (query.isLaterRelease ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist) : null), firstReleaseTooltip: - (query.isFirstRelease && query.hasOtherReleases + (query.isFirstRelease && query.hasOtherCreditedReleases ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist) : null), }), diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js index 84eb29ac..15588ed3 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -4,11 +4,6 @@ import {stitchArrays} from '#sugar'; import {chunkArtistTrackContributions} from '#wiki-data'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageTracksChunk', - ], - query(artist) { const query = {}; diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js index 1b4b6eca..69ae3e19 100644 --- a/src/content/dependencies/generateArtistNavLinks.js +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -1,14 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'linkArtist', - 'linkArtistGallery', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableListings: wikiInfo.enableListings, @@ -34,6 +26,9 @@ export default { (query.hasGallery ? relation('linkArtistGallery', artist) : null), + + artistRollingWindowLink: + relation('linkArtistRollingWindow', artist), }), data: (_query, sprawl) => ({ @@ -45,7 +40,7 @@ export default { showExtraLinks: {type: 'boolean', default: false}, currentExtra: { - validate: v => v.is('gallery'), + validate: v => v.is('gallery', 'rolling-window'), }, }, @@ -79,6 +74,7 @@ export default { }), slots.showExtraLinks && + slots.currentExtra !== 'rolling-window' && relations.artistGalleryLink?.slots({ attributes: [ slots.currentExtra === 'gallery' && @@ -87,6 +83,12 @@ export default { content: language.$('misc.nav.gallery'), }), + + slots.currentExtra === 'rolling-window' && + relations.artistRollingWindowLink.slots({ + attributes: {class: 'current'}, + content: language.$('misc.nav.rollingWindow'), + }), ], }), }, diff --git a/src/content/dependencies/generateArtistRollingWindowPage.js b/src/content/dependencies/generateArtistRollingWindowPage.js new file mode 100644 index 00000000..aafd1b55 --- /dev/null +++ b/src/content/dependencies/generateArtistRollingWindowPage.js @@ -0,0 +1,418 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import Thing from '#thing'; + +import { + chunkByConditions, + filterMultipleArrays, + empty, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + sprawl: ({groupCategoryData}) => ({ + groupCategoryData, + }), + + query(sprawl, artist) { + const query = {}; + + const musicContributions = + artist.musicContributions + .filter(contrib => contrib.date); + + const artworkContributions = + artist.artworkContributions + .filter(contrib => + contrib.date && + contrib.thingProperty !== 'wallpaperArtistContribs' && + contrib.thingProperty !== 'bannerArtistContribs'); + + const musicThings = + musicContributions + .map(contrib => contrib.thing); + + const artworkThings = + artworkContributions + .map(contrib => contrib.thing.thing); + + const musicContributionDates = + musicContributions + .map(contrib => contrib.date); + + const artworkContributionDates = + artworkContributions + .map(contrib => contrib.date); + + const musicContributionKinds = + musicContributions + .map(() => 'music'); + + const artworkContributionKinds = + artworkContributions + .map(() => 'artwork'); + + const allThings = [ + ...artworkThings, + ...musicThings, + ]; + + const allContributionDates = [ + ...artworkContributionDates, + ...musicContributionDates, + ]; + + const allContributionKinds = [ + ...artworkContributionKinds, + ...musicContributionKinds, + ]; + + const sortedThings = + sortAlbumsTracksChronologically(allThings.slice(), {latestFirst: true}); + + sortMultipleArrays( + allThings, + allContributionDates, + allContributionKinds, + (thing1, thing2) => + sortedThings.indexOf(thing1) - + sortedThings.indexOf(thing2)); + + const sourceIndices = + Array.from({length: allThings.length}, (_, i) => i); + + const sourceChunks = + chunkByConditions(sourceIndices, [ + (index1, index2) => + allThings[index1] !== + allThings[index2], + ]); + + const indicesTo = array => index => array[index]; + + query.things = + sourceChunks + .map(chunks => allThings[chunks[0]]); + + query.thingGroups = + query.things.map(thing => + (thing.constructor[Thing.referenceType] === 'album' + ? thing.groups + : thing.constructor[Thing.referenceType] === 'track' + ? thing.album.groups + : null)); + + query.thingContributionDates = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionDates))); + + query.thingContributionKinds = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionKinds))); + + // Matches the "kind" dropdown. + const kinds = ['artwork', 'music', 'flash']; + + const allKinds = + unique(query.thingContributionKinds.flat(2)); + + query.kinds = + kinds + .filter(kind => allKinds.includes(kind)); + + query.firstKind = + query.kinds.at(0); + + query.thingArtworks = + stitchArrays({ + thing: query.things, + kinds: query.thingContributionKinds, + }).map(({thing, kinds}) => + (kinds.includes('artwork') + ? (thing.coverArtworks ?? thing.trackArtworks ?? []) + .find(artwork => artwork.artistContribs + .some(contrib => contrib.artist === artist)) + : (thing.coverArtworks ?? thing.trackArtworks)?.[0] ?? + thing.album?.coverArtworks[0] ?? + null)); + + const allGroups = + unique(query.thingGroups.flat()); + + query.groupCategories = + sprawl.groupCategoryData.slice(); + + query.groupCategoryGroups = + sprawl.groupCategoryData + .map(category => category.groups + .filter(group => allGroups.includes(group))); + + filterMultipleArrays( + query.groupCategories, + query.groupCategoryGroups, + (_category, groups) => !empty(groups)); + + const groupsMatchingFirstKind = + unique( + stitchArrays({ + thing: query.things, + groups: query.thingGroups, + kinds: query.thingContributionKinds, + }).filter(({kinds}) => kinds.includes(query.firstKind)) + .flatMap(({groups}) => groups)); + + query.firstGroup = + sprawl.groupCategoryData + .flatMap(category => category.groups) + .find(group => groupsMatchingFirstKind.includes(group)); + + query.firstGroupCategory = + query.firstGroup.category; + + return query; + }, + + relations: (relation, query, sprawl, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + sourceGrid: + relation('generateCoverGrid'), + + sourceGridImages: + query.thingArtworks + .map(artwork => relation('image', artwork)), + + sourceGridLinks: + query.things + .map(thing => relation('linkAnythingMan', thing)), + }), + + data: (query, sprawl, artist) => ({ + name: + artist.name, + + categoryGroupDirectories: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.directory)), + + categoryGroupNames: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.name)), + + firstGroupCategoryIndex: + query.groupCategories + .indexOf(query.firstGroupCategory), + + firstGroupIndex: + stitchArrays({ + category: query.groupCategories, + groups: query.groupCategoryGroups, + }).find(({category}) => category === query.firstGroupCategory) + .groups + .indexOf(query.firstGroup), + + kinds: + query.kinds, + + sourceGridNames: + query.things + .map(thing => thing.name), + + sourceGridGroupDirectories: + query.thingGroups + .map(groups => groups + .map(group => group.directory)), + + sourceGridGroupNames: + query.thingGroups + .map(groups => groups + .map(group => group.name)), + + sourceGridContributionKinds: + query.thingContributionKinds, + + sourceGridContributionDates: + query.thingContributionDates, + }), + + generate: (data, relations, {html, language}) => + relations.layout.slots({ + title: + language.$('artistRollingWindowPage.title', { + artist: data.name, + }), + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {id: 'timeframe-configuration'}, + language.$('artistRollingWindowPage.windowConfigurationLine', { + timeBefore: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-before'}, + {type: 'number'}, + {value: 3, min: 0}), + }), + + timeAfter: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-after'}, + {type: 'number'}, + {value: 3, min: 1}), + }), + + peek: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-peek'}, + {type: 'number'}, + {value: 1, min: 0}), + }), + })), + + html.tag('p', {id: 'contribution-configuration'}, + language.$('artistRollingWindowPage.contributionConfigurationLine', { + kind: + html.tag('select', {id: 'contribution-kind'}, + data.kinds.map(kind => + html.tag('option', {value: kind}, + language.$('artistRollingWindowPage.contributionKind', kind)))), + + group: + html.tag('select', {id: 'contribution-group'}, [ + html.tag('option', {value: '-'}, + language.$('artistRollingWindowPage.contributionGroup.all')), + + stitchArrays({ + names: data.categoryGroupNames, + directories: data.categoryGroupDirectories, + }).map(({names, directories}, categoryIndex) => [ + html.tag('hr'), + + stitchArrays({name: names, directory: directories}) + .map(({name, directory}, groupIndex) => + html.tag('option', {value: directory}, + categoryIndex === data.firstGroupCategoryIndex && + groupIndex === data.firstGroupIndex && + {selected: true}, + + language.$('artistRollingWindowPage.contributionGroup.group', { + group: name, + }))), + ]), + ]), + })), + + html.tag('p', {id: 'timeframe-selection-info'}, [ + html.tag('span', {id: 'timeframe-selection-some'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionLine', { + contributions: + html.tag('b', {id: 'timeframe-selection-contribution-count'}), + + timeframes: + html.tag('b', {id: 'timeframe-selection-timeframe-count'}), + + firstDate: + html.tag('b', {id: 'timeframe-selection-first-date'}), + + lastDate: + html.tag('b', {id: 'timeframe-selection-last-date'}), + })), + + html.tag('span', {id: 'timeframe-selection-none'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.timeframeSelectionLine.none')), + ]), + + html.tag('p', {id: 'timeframe-selection-control'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionControl', { + timeframes: + html.tag('select', {id: 'timeframe-selection-menu'}), + + previous: + html.tag('a', {id: 'timeframe-selection-previous'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.previous')), + + next: + html.tag('a', {id: 'timeframe-selection-next'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.next')), + })), + + html.tag('div', {id: 'timeframe-source-area'}, [ + html.tag('p', {id: 'timeframe-empty'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.emptyTimeframeLine')), + + relations.sourceGrid.slots({ + attributes: {style: 'display: none'}, + + lazy: true, + + links: + relations.sourceGridLinks.map(link => + link.slot('attributes', {target: '_blank'})), + + names: + data.sourceGridNames, + + images: + relations.sourceGridImages, + + info: + stitchArrays({ + contributionKinds: data.sourceGridContributionKinds, + contributionDates: data.sourceGridContributionDates, + groupDirectories: data.sourceGridGroupDirectories, + groupNames: data.sourceGridGroupNames, + }).map(({ + contributionKinds, + contributionDates, + groupDirectories, + groupNames, + }) => [ + stitchArrays({ + directory: groupDirectories, + name: groupNames, + }).map(({directory, name}) => + html.tag('data', {class: 'contribution-group'}, + {value: directory}, + name)), + + stitchArrays({ + kind: contributionKinds, + date: contributionDates, + }).map(({kind, date}) => + html.tag('time', {class: `${kind}-contribution-date`}, + {datetime: date.toUTCString()}, + language.formatDate(date))), + ]), + }), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + currentExtra: 'rolling-window', + }) + .content, + }), +} diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js index 6648b463..08d33348 100644 --- a/src/content/dependencies/generateBackToAlbumLink.js +++ b/src/content/dependencies/generateBackToAlbumLink.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkAlbum'], - extraDependencies: ['language'], - relations: (relation, track) => ({ trackLink: relation('linkAlbum', track), diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js index 8677d811..90dfb6d5 100644 --- a/src/content/dependencies/generateBackToTrackLink.js +++ b/src/content/dependencies/generateBackToTrackLink.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['language'], - relations: (relation, track) => ({ trackLink: relation('linkTrack', track), diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js index 15eb08eb..509b15c2 100644 --- a/src/content/dependencies/generateBanner.js +++ b/src/content/dependencies/generateBanner.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'to'], - slots: { path: { validate: v => v.validateArrayItems(v.isString), diff --git a/src/content/dependencies/generateCollapsedContentEntrySection.js b/src/content/dependencies/generateCollapsedContentEntrySection.js new file mode 100644 index 00000000..aec5fe28 --- /dev/null +++ b/src/content/dependencies/generateCollapsedContentEntrySection.js @@ -0,0 +1,37 @@ +export default { + relations: (relation, entries, thing) => ({ + contentContentHeading: + relation('generateContentContentHeading', thing), + + entries: + entries + .map(entry => relation('generateCommentaryEntry', entry)), + }), + + slots: { + id: {type: 'string'}, + string: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + html.tag('details', + {[html.onlyIfContent]: true}, + + slots.id && [ + {class: 'memorable'}, + {'data-memorable-id': slots.id}, + ], + + [ + relations.contentContentHeading.slots({ + attributes: [ + slots.id && {id: slots.id}, + ], + + string: slots.string, + summary: true, + }), + + relations.entries, + ]), +}; diff --git a/src/content/dependencies/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js index 03d95ac5..277ec434 100644 --- a/src/content/dependencies/generateColorStyleAttribute.js +++ b/src/content/dependencies/generateColorStyleAttribute.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateColorStyleVariables'], - extraDependencies: ['html'], - relations: (relation) => ({ colorVariables: relation('generateColorStyleVariables'), diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js deleted file mode 100644 index c412b8f2..00000000 --- a/src/content/dependencies/generateColorStyleRules.js +++ /dev/null @@ -1,42 +0,0 @@ -export default { - contentDependencies: ['generateColorStyleVariables'], - extraDependencies: ['html'], - - relations: (relation) => ({ - variables: - relation('generateColorStyleVariables'), - }), - - data: (color) => ({ - color: - color ?? null, - }), - - slots: { - color: { - validate: v => v.isColor, - }, - }, - - generate(data, relations, slots) { - const color = data.color ?? slots.color; - - if (!color) { - return ''; - } - - return [ - `:root {`, - ...( - relations.variables - .slots({ - color, - context: 'page-root', - mode: 'property-list', - }) - .content - .map(line => line + ';')), - `}`, - ].join('\n'); - }, -}; diff --git a/src/content/dependencies/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js new file mode 100644 index 00000000..b378fd1d --- /dev/null +++ b/src/content/dependencies/generateColorStyleTag.js @@ -0,0 +1,48 @@ +export default { + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + + variables: + relation('generateColorStyleVariables'), + }), + + data: (color) => ({ + color: + color ?? null, + }), + + slots: { + color: { + validate: v => v.isColor, + }, + }, + + generate(data, relations, slots, {html}) { + const color = + data.color ?? slots.color; + + if (!color) { + return html.blank(); + } + + return relations.styleTag.slots({ + attributes: [ + {class: 'color-style'}, + {'data-color': color}, + ], + + rules: [ + { + select: ':root', + declare: + relations.variables.slots({ + color, + context: 'page-root', + mode: 'declarations', + }).content, + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js index 5270dbe4..0865ed3e 100644 --- a/src/content/dependencies/generateColorStyleVariables.js +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'getColors'], - slots: { color: { validate: v => v.isColor, @@ -18,7 +16,7 @@ export default { }, mode: { - validate: v => v.is('style', 'property-list'), + validate: v => v.is('style', 'declarations'), default: 'style', }, }, @@ -50,15 +48,15 @@ export default { `--shadow-color: ${shadow}`, ]; - let selectedProperties; + let selectedDeclarations; switch (slots.context) { case 'any-content': - selectedProperties = anyContent; + selectedDeclarations = anyContent; break; case 'image-box': - selectedProperties = [ + selectedDeclarations = [ `--primary-color: ${primary}`, `--dim-color: ${dim}`, `--deep-color: ${deep}`, @@ -67,14 +65,14 @@ export default { break; case 'page-root': - selectedProperties = [ + selectedDeclarations = [ ...anyContent, `--page-primary-color: ${primary}`, ]; break; case 'primary-only': - selectedProperties = [ + selectedDeclarations = [ `--primary-color: ${primary}`, ]; break; @@ -82,10 +80,10 @@ export default { switch (slots.mode) { case 'style': - return selectedProperties.join('; '); + return selectedDeclarations.join('; '); - case 'property-list': - return selectedProperties; + case 'declarations': + return selectedDeclarations.map(declaration => declaration + ';'); } }, }; diff --git a/src/content/dependencies/generateCommentaryContentHeading.js b/src/content/dependencies/generateCommentaryContentHeading.js new file mode 100644 index 00000000..691762aa --- /dev/null +++ b/src/content/dependencies/generateCommentaryContentHeading.js @@ -0,0 +1,43 @@ +import {empty} from '#sugar'; + +export default { + query: (thing) => ({ + entries: + (thing.isTrack + ? [...thing.commentary, ...thing.commentaryFromMainRelease] + : thing.commentary), + }), + + relations: (relation, _query, thing) => ({ + contentContentHeading: + relation('generateContentContentHeading', thing), + }), + + data: (query, _thing) => ({ + hasWikiEditorCommentary: + query.entries.some(entry => entry.isWikiEditorCommentary), + + onlyWikiEditorCommentary: + !empty(query.entries) && + query.entries.every(entry => entry.isWikiEditorCommentary), + + hasAnyCommentary: + !empty(query.entries), + }), + + generate: (data, relations, {language}) => + relations.contentContentHeading.slots({ + // It's #artist-commentary for legacy reasons... Sorry... + attributes: {id: 'artist-commentary'}, + + string: + language.encapsulate('misc.artistCommentary', capsule => + (data.onlyWikiEditorCommentary + ? language.encapsulate(capsule, 'onlyWikiCommentary') + : data.hasWikiEditorCommentary + ? language.encapsulate(capsule, 'withWikiCommentary') + : data.hasAnyCommentary + ? capsule + : null)), + }), +}; diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index 367de506..38eb6b43 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -1,15 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateCommentaryEntryDate', - 'generateColorStyleAttribute', - 'linkArtist', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, entry) => ({ artistLinks: (!empty(entry.artists) && !entry.artistText @@ -39,11 +30,16 @@ export default { relation('generateCommentaryEntryDate', entry), }), + data: (entry) => ({ + isWikiEditorCommentary: + entry.isWikiEditorCommentary, + }), + slots: { color: {validate: v => v.isColor}, }, - generate: (relations, slots, {html, language}) => + generate: (data, relations, slots, {html, language}) => language.encapsulate('misc.artistCommentary.entry', entryCapsule => html.tags([ html.tag('p', {class: 'commentary-entry-heading'}, @@ -107,6 +103,9 @@ export default { relations.colorStyle.clone() .slot('color', slots.color), + data.isWikiEditorCommentary && + {class: 'wiki-commentary'}, + relations.bodyContent.slot('mode', 'multiline')), ])), }; diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js index f1cf5cb3..e924f244 100644 --- a/src/content/dependencies/generateCommentaryEntryDate.js +++ b/src/content/dependencies/generateCommentaryEntryDate.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], - extraDependencies: ['html', 'language'], - relations: (relation, _entry) => ({ textWithTooltip: relation('generateTextWithTooltip'), diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js index d68ba42e..8cc30913 100644 --- a/src/content/dependencies/generateCommentaryIndexPage.js +++ b/src/content/dependencies/generateCommentaryIndexPage.js @@ -1,13 +1,11 @@ +import multilingualWordCount from 'word-count'; + import {sortChronologically} from '#sort'; import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generatePageLayout', 'linkAlbumCommentary'], - extraDependencies: ['html', 'language', 'wikiData'], - - sprawl({albumData}) { - return {albumData}; - }, + sprawl: ({albumData}) => + ({albumData}), query(sprawl) { const query = {}; @@ -21,44 +19,52 @@ export default { .filter(({commentary}) => commentary) .flatMap(({commentary}) => commentary)); - query.wordCounts = - entries.map(entries => - accumulateSum( - entries, - entry => entry.body.split(' ').length)); + query.bodies = + entries.map(entries => entries.map(entry => entry.body)); query.entryCounts = entries.map(entries => entries.length); - filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts, - (album, wordCount, entryCount) => entryCount >= 1); + filterMultipleArrays(query.albums, query.bodies, query.entryCounts, + (album, bodies, entryCount) => entryCount >= 1); return query; }, - relations(relation, query) { - return { - layout: - relation('generatePageLayout'), + relations: (relation, query) => ({ + layout: + relation('generatePageLayout'), - albumLinks: - query.albums - .map(album => relation('linkAlbumCommentary', album)), - }; - }, + albumLinks: + query.albums + .map(album => relation('linkAlbumCommentary', album)), - data(query) { - return { - wordCounts: query.wordCounts, - entryCounts: query.entryCounts, + albumBodies: + query.bodies + .map(bodies => bodies + .map(body => relation('transformContent', body))), + }), - totalWordCount: accumulateSum(query.wordCounts), - totalEntryCount: accumulateSum(query.entryCounts), - }; - }, + data: (query) => ({ + entryCounts: query.entryCounts, + totalEntryCount: accumulateSum(query.entryCounts), + }), - generate: (data, relations, {html, language}) => - language.encapsulate('commentaryIndex', pageCapsule => + generate(data, relations, {html, language}) { + const wordCounts = + relations.albumBodies.map(bodies => + accumulateSum(bodies, body => + multilingualWordCount( + html.resolve( + body.slot('mode', 'multiline'), + {normalize: 'plain'})))); + + const totalWordCount = + accumulateSum(wordCounts); + + const {entryCounts, totalEntryCount} = data; + + return language.encapsulate('commentaryIndex', pageCapsule => relations.layout.slots({ title: language.$(pageCapsule, 'title'), @@ -69,11 +75,11 @@ export default { html.tag('p', language.$(pageCapsule, 'infoLine', { words: html.tag('b', - language.formatWordCount(data.totalWordCount, {unit: true})), + language.formatWordCount(totalWordCount, {unit: true})), entries: html.tag('b', - language.countCommentaryEntries(data.totalEntryCount, {unit: true})), + language.countCommentaryEntries(totalEntryCount, {unit: true})), })), language.encapsulate(pageCapsule, 'albumList', listCapsule => [ @@ -83,8 +89,8 @@ export default { html.tag('ul', stitchArrays({ albumLink: relations.albumLinks, - wordCount: data.wordCounts, - entryCount: data.entryCounts, + wordCount: wordCounts, + entryCount: entryCounts, }).map(({albumLink, wordCount, entryCount}) => html.tag('li', language.$(listCapsule, 'item', { @@ -100,5 +106,6 @@ export default { {auto: 'home'}, {auto: 'current'}, ], - })), + })); + }, }; diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js new file mode 100644 index 00000000..9ed2d9f0 --- /dev/null +++ b/src/content/dependencies/generateContentContentHeading.js @@ -0,0 +1,73 @@ +export default { + relations: (relation, _thing) => ({ + contentHeading: + relation('generateContentHeading'), + }), + + data: (thing) => ({ + name: + (thing + ? thing.name + : null), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + string: { + type: 'string', + }, + + summary: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {html, language}) => + relations.contentHeading.slots({ + attributes: slots.attributes, + + title: + (() => { + if (!slots.string) return html.blank(); + + const options = {}; + + if (slots.summary) { + options.cue = + html.tag('span', {class: 'cue'}, + language.$(slots.string, 'cue')); + } + + if (data.name) { + options.thing = html.tag('i', data.name); + } + + if (slots.summary) { + return html.tags([ + html.tag('span', {class: 'when-open'}, + language.$(slots.string, options)), + + html.tag('span', {class: 'when-collapsed'}, + language.$(slots.string, 'collapsed', options)), + ]); + } else { + return language.$(slots.string, options); + } + })(), + + stickyTitle: + (slots.string + ? language.$(slots.string, 'sticky') + : html.blank()), + + tag: + (slots.summary + ? 'summary' + : 'p'), + }), +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js index f52bc043..a7cf201f 100644 --- a/src/content/dependencies/generateContentHeading.js +++ b/src/content/dependencies/generateContentHeading.js @@ -1,7 +1,5 @@ export default { extraDependencies: ['html'], - contentDependencies: ['generateColorStyleAttribute'], - relations: (relation) => ({ colorStyle: relation('generateColorStyleAttribute'), }), diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js index d1c3de0f..3716bcd6 100644 --- a/src/content/dependencies/generateContributionList.js +++ b/src/content/dependencies/generateContributionList.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkContribution'], - extraDependencies: ['html'], - relations: (relation, contributions) => ({ contributionLinks: contributions diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js index 3a31014d..fd16371b 100644 --- a/src/content/dependencies/generateContributionTooltip.js +++ b/src/content/dependencies/generateContributionTooltip.js @@ -1,21 +1,79 @@ -export default { - contentDependencies: [ - 'generateContributionTooltipChronologySection', - 'generateContributionTooltipExternalLinkSection', - 'generateTooltip', - ], +function compareReleaseContributions(a, b) { + if (a === b) { + return true; + } + + const {previous: aPrev, next: aNext} = getSiblings(a); + const {previous: bPrev, next: bNext} = getSiblings(b); + + const effective = contrib => + (contrib?.thing.isAlbum && contrib.thing.style === 'single' + ? contrib.thing.tracks[0] + : contrib?.thing); + + return ( + effective(aPrev) === effective(bPrev) && + effective(aNext) === effective(bNext) + ); +} - extraDependencies: ['html'], +function getSiblings(contribution) { + let previous = contribution; + while (previous && previous.thing === contribution.thing) { + previous = previous.previousBySameArtist; + } - relations: (relation, contribution) => ({ + let next = contribution; + while (next && next.thing === contribution.thing) { + next = next.nextBySameArtist; + } + + return {previous, next}; +} + +export default { + query: (contribution) => ({ + albumArtistContribution: + (contribution.thing.isTrack + ? contribution.thing.album.artistContribs + .find(artistContrib => artistContrib.artist === contribution.artist) + : null), + }), + + relations: (relation, query, contribution) => ({ tooltip: relation('generateTooltip'), externalLinkSection: relation('generateContributionTooltipExternalLinkSection', contribution), - chronologySection: + ownChronologySection: relation('generateContributionTooltipChronologySection', contribution), + + artistReleaseChronologySection: + (query.albumArtistContribution + ? relation('generateContributionTooltipChronologySection', + query.albumArtistContribution) + : null), + }), + + data: (query, contribution) => ({ + artistName: + contribution.artist.name, + + isAlbumArtistContribution: + contribution.thing.isAlbum && + contribution.thingProperty === 'artistContribs', + + isSingleTrackArtistContribution: + contribution.thing.isTrack && + contribution.thingProperty === 'artistContribs' && + contribution.thing.album.style === 'single', + + artistReleaseChronologySectionDiffers: + (query.albumArtistContribution + ? !compareReleaseContributions(contribution, query.albumArtistContribution) + : null), }), slots: { @@ -25,24 +83,64 @@ export default { chronologyKind: {type: 'string'}, }, - generate: (relations, slots, {html}) => - relations.tooltip.slots({ - attributes: - {class: 'contribution-tooltip'}, - - contentAttributes: { - [html.joinChildren]: - html.tag('span', {class: 'tooltip-divider'}), - }, - - content: [ - slots.showExternalLinks && - relations.externalLinkSection, - - slots.showChronology && - relations.chronologySection.slots({ - kind: slots.chronologyKind, - }), - ], - }), + generate: (data, relations, slots, {html, language}) => + language.encapsulate('misc.artistLink', capsule => + relations.tooltip.slots({ + attributes: + {class: 'contribution-tooltip'}, + + contentAttributes: { + [html.joinChildren]: + html.tag('span', {class: 'tooltip-divider'}), + }, + + content: [ + slots.showExternalLinks && + relations.externalLinkSection, + + slots.showChronology && + language.encapsulate(capsule, 'chronology', capsule => { + const chronologySections = []; + + if (data.isAlbumArtistContribution) { + relations.ownChronologySection.setSlots({ + kind: 'release', + heading: + language.$(capsule, 'heading.artistReleases', { + artist: data.artistName, + }), + }); + } else { + relations.ownChronologySection.setSlot('kind', slots.chronologyKind); + } + + if ( + data.isSingleTrackArtistContribution && + relations.artistReleaseChronologySection + ) { + relations.artistReleaseChronologySection.setSlot('kind', 'release'); + + relations.artistReleaseChronologySection.setSlot('heading', + language.$(capsule, 'heading.artistReleases', { + artist: data.artistName, + })); + + chronologySections.push(relations.artistReleaseChronologySection); + + if (data.artistReleaseChronologySectionDiffers) { + relations.ownChronologySection.setSlot('heading', + language.$(capsule, 'heading.artistTracks', { + artist: data.artistName, + })); + + chronologySections.push(relations.ownChronologySection); + } + } else { + chronologySections.push(relations.ownChronologySection); + } + + return chronologySections; + }), + ], + })), }; diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js index 378c0e1c..e4b9bfda 100644 --- a/src/content/dependencies/generateContributionTooltipChronologySection.js +++ b/src/content/dependencies/generateContributionTooltipChronologySection.js @@ -1,36 +1,33 @@ -import Thing from '#thing'; - function getName(thing) { if (!thing) { return null; } - const referenceType = thing.constructor[Thing.referenceType]; - - if (referenceType === 'artwork') { + if (thing.isArtwork) { return thing.thing.name; } return thing.name; } -export default { - contentDependencies: ['linkAnythingMan'], - extraDependencies: ['html', 'language'], +function getSiblings(contribution) { + let previous = contribution; + while (previous && previous.thing === contribution.thing) { + previous = previous.previousBySameArtist; + } - query(contribution) { - let previous = contribution; - while (previous && previous.thing === contribution.thing) { - previous = previous.previousBySameArtist; - } + let next = contribution; + while (next && next.thing === contribution.thing) { + next = next.nextBySameArtist; + } - let next = contribution; - while (next && next.thing === contribution.thing) { - next = next.nextBySameArtist; - } + return {previous, next}; +} - return {previous, next}; - }, +export default { + query: (contribution) => ({ + ...getSiblings(contribution), + }), relations: (relation, query, _contribution) => ({ previousLink: @@ -53,23 +50,19 @@ export default { }), slots: { - kind: { - validate: v => - v.is( - 'album', - 'bannerArt', - 'coverArt', - 'flash', - 'track', - 'trackArt', - 'trackContribution', - 'wallpaperArt'), - }, + heading: {type: 'html', mutable: false}, + kind: {type: 'string'}, }, generate: (data, relations, slots, {html, language}) => language.encapsulate('misc.artistLink.chronology', capsule => html.tags([ + html.tag('span', {class: 'chronology-heading'}, + {[html.onlyIfContent]: true}, + {[html.onlyIfSiblings]: true}, + + slots.heading), + html.tags([ relations.previousLink?.slots({ attributes: {class: 'chronology-link'}, diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js index 4f9a23ed..210db1e9 100644 --- a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js +++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js @@ -1,14 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateExternalHandle', - 'generateExternalIcon', - 'generateExternalPlatform', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contribution) => ({ icons: contribution.artist.urls diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index c1a23bbd..616b3c95 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -1,15 +1,8 @@ export default { - contentDependencies: [ - 'generateCoverArtworkArtTagDetails', - 'generateCoverArtworkArtistDetails', - 'generateCoverArtworkOriginDetails', - 'generateCoverArtworkReferenceDetails', - 'image', - ], - - extraDependencies: ['html'], - relations: (relation, artwork) => ({ + colorStyleAttribute: + relation('generateColorStyleAttribute'), + image: relation('image', artwork), @@ -40,13 +33,17 @@ export default { dimensions: artwork.dimensions, + + style: + artwork.style, }), slots: { alt: {type: 'string'}, color: { - validate: v => v.isColor, + validate: v => v.anyOf(v.isBoolean, v.isColor), + default: false, }, mode: { @@ -68,10 +65,15 @@ export default { generate(data, relations, slots, {html}) { const {image} = relations; - image.setSlots({ - color: slots.color ?? data.color, - alt: slots.alt, - }); + const imgAttributes = html.attributes(); + + if (data.style) { + imgAttributes.add('style', data.style.split('\n').join(' ')); + } + + image.setSlot('imgAttributes', imgAttributes); + + image.setSlot('alt', slots.alt); const square = (data.dimensions @@ -84,6 +86,22 @@ export default { image.setSlot('dimensions', data.dimensions); } + const attributes = html.attributes(); + + let color = null; + if (typeof slots.color === 'boolean') { + if (slots.color) { + color = data.color; + } + } else if (slots.color) { + color = slots.color; + } + + if (color) { + relations.colorStyleAttribute.setSlot('color', color); + attributes.add(relations.colorStyleAttribute); + } + return html.tags([ data.attachAbove && html.tag('div', {class: 'cover-artwork-joiner'}), @@ -96,12 +114,38 @@ export default { data.attachedArtworkIsMainArtwork && {class: 'attached-artwork-is-main-artwork'}, + attributes, + (slots.mode === 'primary' ? [ relations.image.slots({ thumb: 'medium', reveal: true, link: true, + + responsiveThumb: true, + responsiveSizes: + // No clamp(), min(), or max() here because Safari. + // The boundaries here are mostly experimental, apart from + // the ones which flat-out switch layouts. + + // Layout - Thin (phones) + // Most of viewport width + '(max-width: 600px) 90vw,\n' + + + // Layout - Medium + // Sidebar is hidden; content area is by definition + // most of the viewport + '(max-width: 640px) 220px,\n' + + '(max-width: 800px) 36vw,\n' + + '(max-width: 850px) 280px,\n' + + + // Layout - Wide + // Sidebar is visible; content area has its own maximum + // Assume the sidebar is at minimum width + '(max-width: 880px) 220px,\n' + + '(max-width: 1050pz) calc(0.40 * (90vw - 150px - 10px)),\n' + + '280px', }), slots.showOriginDetails && diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js index 4d908665..50571a4f 100644 --- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js @@ -5,9 +5,6 @@ function linkable(tag) { } export default { - contentDependencies: ['linkArtTagGallery'], - extraDependencies: ['html', 'language'], - query: (artwork) => ({ linkableArtTags: artwork.artTags.filter(linkable), diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js index 3ead80ab..2773c6fc 100644 --- a/src/content/dependencies/generateCoverArtworkArtistDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkArtistGallery'], - extraDependencies: ['html', 'language'], - relations: (relation, artwork) => ({ artistLinks: artwork.artistContribs diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js index 3908414f..e489eea6 100644 --- a/src/content/dependencies/generateCoverArtworkOriginDetails.js +++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js @@ -1,19 +1,5 @@ -import Thing from '#thing'; - export default { - contentDependencies: [ - 'generateArtistCredit', - 'generateAbsoluteDatetimestamp', - 'linkAlbum', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'pagePath'], - query: (artwork) => ({ - artworkThingType: - artwork.thing.constructor[Thing.referenceType], - attachedArtistContribs: (artwork.attachedArtwork ? artwork.attachedArtwork.artistContribs @@ -29,15 +15,18 @@ export default { source: relation('transformContent', artwork.source), + originDetails: + relation('transformContent', artwork.originDetails), + albumLink: - (query.artworkThingType === 'album' + (artwork.thing.isAlbum ? relation('linkAlbum', artwork.thing) : null), datetimestamp: - (artwork.date && artwork.date !== artwork.thing.date - ? relation('generateAbsoluteDatetimestamp', artwork.date) - : null), + relation('generateAbsoluteDatetimestamp', + artwork.date, + artwork.thing.date), }), @@ -45,23 +34,26 @@ export default { label: artwork.label, - artworkThingType: - query.artworkThingType, + forAlbum: + artwork.thing.isAlbum, + + forSingleStyleAlbum: + artwork.thing.isAlbum && + artwork.thing.style === 'single', + + showFilename: + artwork.showFilename, }), generate: (data, relations, {html, language, pagePath}) => language.encapsulate('misc.coverArtwork', capsule => html.tag('p', {class: 'image-details'}, {[html.onlyIfContent]: true}, - {[html.joinChildren]: html.tag('br')}, {class: 'origin-details'}, (() => { - relations.datetimestamp?.setSlots({ - style: 'year', - tooltip: true, - }); + relations.datetimestamp.setSlot('style', 'year-difference'); const artworkBy = language.encapsulate(capsule, 'artworkBy', workingCapsule => { @@ -72,7 +64,7 @@ export default { workingOptions.label = data.label; } - if (relations.datetimestamp) { + if (!html.isBlank(relations.datetimestamp)) { workingCapsule += '.withYear'; workingOptions.year = relations.datetimestamp; } @@ -94,7 +86,8 @@ export default { const trackArtFromAlbum = pagePath[0] === 'track' && - data.artworkThingType === 'album' && + data.forAlbum && + !data.forSingleStyleAlbum && language.$(capsule, 'trackArtFromAlbum', { album: relations.albumLink.slot('color', false), @@ -112,7 +105,7 @@ export default { workingOptions.label = data.label; } - if (html.isBlank(artworkBy) && relations.datetimestamp) { + if (html.isBlank(artworkBy) && !html.isBlank(relations.datetimestamp)) { workingCapsule += '.withYear'; workingOptions.year = relations.datetimestamp; } @@ -129,7 +122,7 @@ export default { label: data.label, }; - if (relations.datetimestamp) { + if (!html.isBlank(relations.datetimestamp)) { workingCapsule += '.withYear'; workingOptions.year = relations.datetimestamp; } @@ -146,12 +139,35 @@ export default { year: relations.datetimestamp, }); + const originDetailsLine = + html.tag('span', {class: 'origin-details-line'}, + {[html.onlyIfContent]: true}, + + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + })); + + const filenameLine = + html.tag('span', {class: 'filename-line'}, + {[html.onlyIfContent]: true}, + + html.tag('code', {class: 'filename'}, + {[html.onlyIfContent]: true}, + + language.sanitize(data.showFilename))); + return [ - artworkBy, - trackArtFromAlbum, - source, - label, - year, + html.tags([ + artworkBy, + trackArtFromAlbum, + source, + label, + year, + ], {[html.joinChildren]: html.tag('br')}), + + originDetailsLine, + filenameLine, ]; })())), }; diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js index 035ab586..d4e4e7e4 100644 --- a/src/content/dependencies/generateCoverArtworkReferenceDetails.js +++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'], - extraDependencies: ['html', 'language'], - relations: (relation, artwork) => ({ referencedArtworksLink: relation('linkReferencedArtworks', artwork), diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js index 0705d93e..1ffeff8e 100644 --- a/src/content/dependencies/generateCoverCarousel.js +++ b/src/content/dependencies/generateCoverCarousel.js @@ -2,8 +2,6 @@ import {empty, repeat, stitchArrays} from '#sugar'; import {getCarouselLayoutForNumberOfItems} from '#wiki-data'; export default { - extraDependencies: ['html'], - slots: { images: {validate: v => v.strictArrayOf(v.isHTML)}, links: {validate: v => v.strictArrayOf(v.isHTML)}, diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js index e4dfd905..091833a9 100644 --- a/src/content/dependencies/generateCoverGrid.js +++ b/src/content/dependencies/generateCoverGrid.js @@ -1,20 +1,22 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateGridActionLinks'], - extraDependencies: ['html', 'language'], + relations: (relation) => ({ + actionLinks: + relation('generateGridActionLinks'), - relations(relation) { - return { - actionLinks: relation('generateGridActionLinks'), - }; - }, + expando: + relation('generateGridExpando'), + }), slots: { + attributes: {type: 'attributes', mutable: false}, + images: {validate: v => v.strictArrayOf(v.isHTML)}, links: {validate: v => v.strictArrayOf(v.isHTML)}, names: {validate: v => v.strictArrayOf(v.isHTML)}, info: {validate: v => v.strictArrayOf(v.isHTML)}, + tab: {validate: v => v.strictArrayOf(v.isHTML)}, notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)}, // Differentiating from sparseArrayOf here - this list of classes should @@ -30,45 +32,115 @@ export default { v.isString))), }, + itemAttributes: { + validate: v => + v.strictArrayOf( + v.optional(v.isAttributes)), + }, + lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + + revealAllWarnings: { + validate: v => v.looseArrayOf(v.isString), + }, + + bottomCaption: { + type: 'html', + mutable: false, + }, + + cutIndex: {validate: v => v.isWholeNumber}, }, generate: (relations, slots, {html, language}) => html.tag('div', {class: 'grid-listing'}, + slots.attributes, {[html.onlyIfContent]: true}, [ + !empty((slots.revealAllWarnings ?? []).filter(Boolean)) && + language.encapsulate('misc.coverGrid.revealAll', capsule => + html.tag('div', {class: 'reveal-all-container'}, + ((slots.tab ?? []) + .slice(0, 4) + .some(tab => tab && !html.isBlank(tab))) && + + {class: 'has-nearby-tab'}, + + html.tag('p', {class: 'reveal-all'}, [ + html.tag('a', {href: '#'}, [ + html.tag('span', {class: 'reveal-label'}, + language.$(capsule, 'reveal')), + + html.tag('span', {class: 'conceal-label'}, + {style: 'display: none'}, + language.$(capsule, 'conceal')), + ]), + + html.tag('br'), + + html.tag('span', {class: 'warnings'}, + language.$(capsule, 'warnings', { + warnings: + language.formatUnitList( + unique(slots.revealAllWarnings.filter(Boolean)) + .sort() + .map(warning => html.tag('b', warning))), + })), + ]))), + stitchArrays({ classes: slots.classes, + attributes: slots.itemAttributes, image: slots.images, link: slots.links, name: slots.names, info: slots.info, + tab: slots.tab, notFromThisGroup: slots.notFromThisGroup ?? Array.from(slots.links).fill(null) }).map(({ classes, + attributes, image, link, name, info, + tab, notFromThisGroup, }, index) => link.slots({ attributes: [ + link.getSlotValue('attributes'), + {class: ['grid-item', 'box']}, + tab && + !html.isBlank(tab) && + {class: 'has-tab'}, + + attributes, + (classes ? {class: classes} : null), + + slots.cutIndex >= 1 && + index >= slots.cutIndex && + {class: 'hidden-by-expandable-cut'}, ], colorContext: 'image-box', content: [ + html.tag('span', + {[html.onlyIfContent]: true}, + + tab), + image.slots({ thumb: 'medium', square: true, @@ -106,5 +178,17 @@ export default { relations.actionLinks .slot('actionLinks', slots.actionLinks), + + (slots.cutIndex >= 1 && + slots.cutIndex < slots.links.length + ? relations.expando.slots({ + caption: slots.bottomCaption, + }) + + : !html.isBlank(relations.bottomCaption) + ? html.tag('p', {class: 'grid-caption'}, + slots.caption) + + : html.blank()), ]), }; diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js index a92d15fc..56b2e595 100644 --- a/src/content/dependencies/generateDatetimestampTemplate.js +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip'], - extraDependencies: ['html'], - relations: (relation) => ({ textWithTooltip: relation('generateTextWithTooltip'), diff --git a/src/content/dependencies/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js index 22205922..561a44bc 100644 --- a/src/content/dependencies/generateDotSwitcherTemplate.js +++ b/src/content/dependencies/generateDotSwitcherTemplate.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js deleted file mode 100644 index 122ca4b1..00000000 --- a/src/content/dependencies/generateExpandableGallerySection.js +++ /dev/null @@ -1,92 +0,0 @@ -export default { - contentDependencies: ['generateContentHeading'], - extraDependencies: ['html', 'language'], - - relations: (relation) => ({ - contentHeading: - relation('generateContentHeading'), - }), - - slots: { - title: { - type: 'html', - mutable: false, - }, - - contentAboveCut: { - type: 'html', - mutable: false, - }, - - contentBelowCut: { - type: 'html', - mutable: false, - }, - - caption: { - type: 'html', - mutable: false, - }, - - expandCue: { - type: 'html', - mutable: false, - }, - - collapseCue: { - type: 'html', - mutable: false, - }, - }, - - generate: (relations, slots, {html, language}) => - html.tag('section', {class: 'expandable-gallery-section'}, [ - relations.contentHeading.slots({ - tag: 'h2', - title: slots.title, - }), - - html.tag('div', {class: 'section-content-above-cut'}, - {[html.onlyIfContent]: true}, - - slots.contentAboveCut), - - html.tag('div', {class: 'section-content-below-cut'}, - {[html.onlyIfContent]: true}, - - !html.isBlank(slots.contentBelowCut) && - {style: 'display: none'}, - - slots.contentBelowCut), - - html.tag('div', {class: 'section-expando'}, - {[html.onlyIfSiblings]: true}, - - html.tag('div', {class: 'section-expando-content'}, - {[html.joinChildren]: html.tag('br')}, - - [ - html.tag('span', {class: 'section-caption'}, - slots.caption), - - !html.isBlank(slots.contentBelowCut) && - language.$('misc.coverGrid.expandCollapseCue', { - cue: - html.tag('a', {class: 'section-expando-toggle'}, - {href: '#'}, - - {[html.joinChildren]: ''}, - {[html.noEdgeWhitespace]: true}, - - [ - html.tag('span', {class: 'section-expand-cue'}, - slots.expandCue), - - html.tag('span', {class: 'section-collapse-cue'}, - {style: 'display: none'}, - slots.collapseCue), - ]), - }), - ])), - ]), -}; diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js index 8c0368a4..8653b177 100644 --- a/src/content/dependencies/generateExternalHandle.js +++ b/src/content/dependencies/generateExternalHandle.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js index 637af658..03af643e 100644 --- a/src/content/dependencies/generateExternalIcon.js +++ b/src/content/dependencies/generateExternalIcon.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language', 'to'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js index c4f63ecf..b2822d64 100644 --- a/src/content/dependencies/generateExternalPlatform.js +++ b/src/content/dependencies/generateExternalPlatform.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js index 84ab549d..896ee224 100644 --- a/src/content/dependencies/generateFlashActGalleryPage.js +++ b/src/content/dependencies/generateFlashActGalleryPage.js @@ -1,19 +1,6 @@ import striptags from 'striptags'; export default { - contentDependencies: [ - 'generateCoverGrid', - 'generateFlashActNavAccent', - 'generateFlashActSidebar', - 'generatePageLayout', - 'image', - 'linkFlash', - 'linkFlashAct', - 'linkFlashIndex', - ], - - extraDependencies: ['language'], - relations: (relation, act) => ({ layout: relation('generatePageLayout'), diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js index c4ec77b8..7ad46051 100644 --- a/src/content/dependencies/generateFlashActNavAccent.js +++ b/src/content/dependencies/generateFlashActNavAccent.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkFlashAct', - ], - - extraDependencies: ['wikiData'], - sprawl: ({flashActData}) => ({flashActData}), diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js index 1421dde9..0d952077 100644 --- a/src/content/dependencies/generateFlashActSidebar.js +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -1,10 +1,4 @@ export default { - contentDependencies: [ - 'generateFlashActSidebarCurrentActBox', - 'generateFlashActSidebarSideMapBox', - 'generatePageSidebar', - ], - relations: (relation, act, flash) => ({ sidebar: relation('generatePageSidebar'), diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js index 6d152c7c..e08582fe 100644 --- a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js +++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkFlash', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, act, _flash) => ({ box: relation('generatePageSidebarBox'), diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js index 7b26ef31..4b97f21d 100644 --- a/src/content/dependencies/generateFlashActSidebarSideMapBox.js +++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js @@ -1,15 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generatePageSidebarBox', - 'linkFlashAct', - 'linkFlashIndex', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({flashSideData}) => ({flashSideData}), relations: (relation, sprawl, _act, _flash) => ({ diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js index 5987df9e..207c3bf3 100644 --- a/src/content/dependencies/generateFlashArtworkColumn.js +++ b/src/content/dependencies/generateFlashArtworkColumn.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateCoverArtwork'], - relations: (relation, flash) => ({ coverArtwork: relation('generateCoverArtwork', flash.coverArtwork), diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js index 2788406c..1fb286c6 100644 --- a/src/content/dependencies/generateFlashIndexPage.js +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -1,17 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkFlash', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({flashActData}) => ({flashActData}), query(sprawl) { diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 095e43c4..935ffdc6 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -1,22 +1,19 @@ import {empty} from '#sugar'; -export default { - contentDependencies: [ - 'generateAdditionalNamesBox', - 'generateCommentaryEntry', - 'generateContentHeading', - 'generateContributionList', - 'generateFlashActSidebar', - 'generateFlashArtworkColumn', - 'generateFlashNavAccent', - 'generatePageLayout', - 'generateTrackList', - 'linkExternal', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language'], +function checkInterrupted(which, relations, {html}) { + if ( + !html.isBlank(relations.contributorContributionList) || + !html.isBlank(relations.featuredTracksList) + ) return true; + + if (which === 'crediting-sources') { + if (!html.isBlank(relations.artistCommentaryEntries)) return true; + } + + return false; +} +export default { query(flash) { const query = {}; @@ -53,6 +50,12 @@ export default { contentHeading: relation('generateContentHeading'), + commentaryContentHeading: + relation('generateCommentaryContentHeading', flash), + + readCommentaryLine: + relation('generateReadCommentaryLine', flash), + flashActLink: relation('linkFlashAct', flash.act), @@ -60,7 +63,7 @@ export default { relation('generateFlashNavAccent', flash), featuredTracksList: - relation('generateTrackList', flash.featuredTracks), + relation('generateTrackList', flash.featuredTracks, []), contributorContributionList: relation('generateContributionList', flash.contributorContribs), @@ -69,9 +72,10 @@ export default { flash.commentary .map(entry => relation('generateCommentaryEntry', entry)), - creditSourceEntries: - flash.commentary - .map(entry => relation('generateCommentaryEntry', entry)), + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + flash.creditingSources, + flash), }), data: (_query, flash) => ({ @@ -123,21 +127,16 @@ export default { {[html.joinChildren]: html.tag('br')}, language.encapsulate('releaseInfo', capsule => [ - !html.isBlank(relations.artistCommentaryEntries) && - language.encapsulate(capsule, 'readCommentary', capsule => - language.$(capsule, { - link: - html.tag('a', - {href: '#artist-commentary'}, - language.$(capsule, 'link')), - })), + checkInterrupted('commentary', relations, {html}) && + relations.readCommentaryLine, - !html.isBlank(relations.creditSourceEntries) && - language.encapsulate(capsule, 'readCreditSources', capsule => + checkInterrupted('crediting-sources', relations, {html}) && + !html.isBlank(relations.creditingSourcesSection) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -168,24 +167,14 @@ export default { ]), html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'artist-commentary'}, - title: language.$('misc.artistCommentary'), - }), - + relations.commentaryContentHeading, relations.artistCommentaryEntries, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'credit-sources'}, - title: language.$('misc.creditSources'), - }), - - relations.creditSourceEntries, - ]), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', + }), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js index 0f5d2d6b..db9d3c1e 100644 --- a/src/content/dependencies/generateFlashNavAccent.js +++ b/src/content/dependencies/generateFlashNavAccent.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkFlash', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({flashActData}) => ({flashActData}), diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js index dfd83aef..efa1972a 100644 --- a/src/content/dependencies/generateFooterLocalizationLinks.js +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -2,15 +2,6 @@ import {sortByName} from '#sort'; import {stitchArrays} from '#sugar'; export default { - extraDependencies: [ - 'defaultLanguage', - 'html', - 'language', - 'languages', - 'pagePath', - 'to', - ], - generate({ defaultLanguage, html, diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js index 585a02b9..5b3f9c1e 100644 --- a/src/content/dependencies/generateGridActionLinks.js +++ b/src/content/dependencies/generateGridActionLinks.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, }, diff --git a/src/content/dependencies/generateGridExpando.js b/src/content/dependencies/generateGridExpando.js new file mode 100644 index 00000000..5a0cbce5 --- /dev/null +++ b/src/content/dependencies/generateGridExpando.js @@ -0,0 +1,37 @@ +export default { + slots: { + caption: {type: 'html', mutable: false}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('misc.coverGrid', capsule => + html.tag('div', {class: 'grid-expando'}, + {[html.onlyIfSiblings]: true}, + + html.tag('p', {class: 'grid-expando-content'}, + {[html.joinChildren]: html.tag('br')}, + + [ + html.tag('span', {class: 'grid-caption'}, + slots.caption), + + !html.isBlank(slots.contentBelowCut) && + language.$(capsule, 'expandCollapseCue', { + cue: + html.tag('a', {class: 'grid-expando-toggle'}, + {href: '#'}, + + {[html.joinChildren]: ''}, + {[html.noEdgeWhitespace]: true}, + + [ + html.tag('span', {class: 'grid-expand-cue'}, + language.$(capsule, 'expand')), + + html.tag('span', {class: 'grid-collapse-cue'}, + {style: 'display: none'}, + language.$(capsule, 'collapse')), + ]), + }), + ]))), +}; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js index dfdad0e8..e378f8a2 100644 --- a/src/content/dependencies/generateGroupGalleryPage.js +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -2,22 +2,6 @@ import {sortChronologically} from '#sort'; import {filterItemsForCarousel, getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateCoverCarousel', - 'generateGroupGalleryPageAlbumsByDateView', - 'generateGroupGalleryPageAlbumsBySeriesView', - 'generateGroupNavLinks', - 'generateGroupSecondaryNav', - 'generateIntrapageDotSwitcher', - 'generatePageLayout', - 'generateQuickDescription', - 'image', - 'linkAlbum', - 'linkListing', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({enableGroupUI: wikiInfo.enableGroupUI}), @@ -183,7 +167,10 @@ export default { }))), */ - relations.albumsByDateView, + relations.albumsByDateView.slots({ + showTitle: + !html.isBlank(relations.albumsBySeriesView), + }), relations.albumsBySeriesView.slots({ attributes: [ diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js index 7d9aa2d2..37c1951d 100644 --- a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js +++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js @@ -2,34 +2,51 @@ import {stitchArrays} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'], - extraDependencies: ['language'], + query: (albums, _group) => ({ + artworks: + albums.map(album => + (album.hasCoverArt + ? album.coverArtworks[0] + : null)), + }), - relations: (relation, albums, _group) => ({ + relations: (relation, query, albums, group) => ({ coverGrid: relation('generateCoverGrid'), links: - albums.map(album => - relation('linkAlbum', album)), + albums + .map(album => relation('linkAlbum', album)), images: - albums.map(album => - (album.hasCoverArt - ? relation('image', album.coverArtworks[0]) - : relation('image'))) + query.artworks + .map(artwork => relation('image', artwork)), + + tabs: + albums + .map(album => + relation('generateGroupGalleryPageAlbumGridTab', album, group)), }), - data: (albums, group) => ({ + data: (query, albums, group) => ({ names: albums.map(album => album.name), - durations: - albums.map(album => getTotalDuration(album.tracks)), + styles: + albums.map(album => album.style), tracks: albums.map(album => album.tracks.length), + allWarnings: + query.artworks.flatMap(artwork => artwork?.contentWarnings), + + durations: + albums.map(album => + (album.hideDuration + ? null + : getTotalDuration(album.tracks))), + notFromThisGroup: albums.map(album => !album.groups.includes(group)), }), @@ -53,14 +70,28 @@ export default { }), })), + itemAttributes: + data.styles.map(style => ({'data-style': style})), + + tab: relations.tabs, + info: stitchArrays({ + style: data.styles, tracks: data.tracks, duration: data.durations, - }).map(({tracks, duration}) => - language.$(capsule, 'details.albumLength', { - tracks: language.countTracks(tracks, {unit: true}), - time: language.formatDuration(duration), - })), + }).map(({style, tracks, duration}) => + (style === 'single' && duration + ? language.$(capsule, 'details.albumLength.single', { + time: language.formatDuration(duration), + }) + : duration + ? language.$(capsule, 'details.albumLength', { + tracks: language.countTracks(tracks, {unit: true}), + time: language.formatDuration(duration), + }) + : null)), + + revealAllWarnings: data.allWarnings, })), }; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js new file mode 100644 index 00000000..c3b860e4 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js @@ -0,0 +1,79 @@ +import {empty} from '#sugar'; + +export default { + query(album, group) { + if (album.groups.length > 1) { + const contextGroup = group; + + const candidateGroupCategory = + album.groups + .filter(group => !group.excludeFromGalleryTabs) + .find(group => group.category !== contextGroup.category) + ?.category ?? + null; + + const candidateGroups = + album.groups + .filter(group => !group.excludeFromGalleryTabs) + .filter(group => group.category === candidateGroupCategory); + + if (!empty(candidateGroups)) { + return { + mode: 'groups', + notedGroups: candidateGroups, + }; + } + } + + if (!empty(album.artistContribs)) { + if ( + album.artistContribs.length === 1 && + !empty(group.closelyLinkedArtists) && + (album.artistContribs[0].artist.name === + group.closelyLinkedArtists[0].artist.name) + ) { + return {mode: null}; + } + + return { + mode: 'artists', + notedArtistContribs: album.artistContribs, + }; + } + + return {mode: null};; + }, + + relations: (relation, query, _album, _group) => ({ + artistCredit: + (query.mode === 'artists' + ? relation('generateArtistCredit', query.notedArtistContribs, []) + : null), + }), + + data: (query, _album, _group) => ({ + mode: query.mode, + + groupNames: + (query.mode === 'groups' + ? query.notedGroups.map(group => group.name) + : null), + }), + + generate: (data, relations, {language}) => + language.encapsulate('misc.coverGrid.tab', capsule => + (data.mode === 'groups' + ? language.$(capsule, 'groups', { + groups: + language.formatUnitList(data.groupNames), + }) + : data.mode === 'artists' + ? relations.artistCredit.slots({ + normalStringKey: + capsule + '.artists', + + normalFeaturingStringKey: + capsule + '.artists.featuring', + }) + : null)), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js index b7d01eb5..75ef1048 100644 --- a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js @@ -1,15 +1,17 @@ import {sortChronologically} from '#sort'; export default { - contentDependencies: ['generateGroupGalleryPageAlbumGrid'], - extraDependencies: ['html', 'language'], - query: (group) => ({ albums: - sortChronologically(group.albums, {latestFirst: true}), + sortChronologically(group.albums.slice(), {latestFirst: true}), }), relations: (relation, query, group) => ({ + styleSelector: + (group.divideAlbumsByStyle + ? relation('generateGroupGalleryPageStyleSelector', group) + : null), + albumGrid: relation('generateGroupGalleryPageAlbumGrid', query.albums, @@ -17,6 +19,10 @@ export default { }), slots: { + showTitle: { + type: 'boolean', + }, + attributes: { type: 'attributes', mutable: false, @@ -31,8 +37,11 @@ export default { {[html.onlyIfContent]: true}, html.tag('section', [ - html.tag('h2', - language.$(capsule, 'title')), + slots.showTitle && + html.tag('h2', + language.$(capsule, 'title')), + + relations.styleSelector, relations.albumGrid, ]))), diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js index 0337275f..68cf249f 100644 --- a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateGroupGalleryPageSeriesSection'], - extraDependencies: ['html'], - relations: (relation, group) => ({ seriesSections: group.serieses diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js index 2ccead5d..1aa835d6 100644 --- a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js +++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js @@ -1,22 +1,11 @@ import {sortChronologically} from '#sort'; export default { - contentDependencies: [ - 'generateExpandableGallerySection', - 'generateGroupGalleryPageAlbumGrid', - ], - - extraDependencies: ['html', 'language'], - query(series) { const query = {}; - // Includes undated albums. - const albumsLatestFirst = - sortChronologically(series.albums, {latestFirst: true}); - - query.albumsAboveCut = albumsLatestFirst.slice(0, 4); - query.albumsBelowCut = albumsLatestFirst.slice(4); + query.albums = + sortChronologically(series.albums.slice(), {latestFirst: true}); query.allAlbumsDated = series.albums.every(album => album.date); @@ -25,13 +14,13 @@ export default { series.albums.some(album => !album.groups.includes(series.group)); query.latestAlbum = - albumsLatestFirst + query.albums .filter(album => album.date) .at(0) ?? null; query.earliestAlbum = - albumsLatestFirst + query.albums .filter(album => album.date) .at(-1) ?? null; @@ -40,17 +29,12 @@ export default { }, relations: (relation, query, series) => ({ - gallerySection: - relation('generateExpandableGallerySection'), - - gridAboveCut: - relation('generateGroupGalleryPageAlbumGrid', - query.albumsAboveCut, - series.group), + contentHeading: + relation('generateContentHeading'), - gridBelowCut: + grid: relation('generateGroupGalleryPageAlbumGrid', - query.albumsBelowCut, + query.albums, series.group), }), @@ -88,69 +72,67 @@ export default { generate: (data, relations, {html, language}) => language.encapsulate('groupGalleryPage.albumSection', capsule => - relations.gallerySection.slots({ - title: data.name, - - contentAboveCut: relations.gridAboveCut, - contentBelowCut: relations.gridBelowCut, - - caption: - language.encapsulate(capsule, 'caption', captionCapsule => - html.tags([ - data.anyAlbumNotFromThisGroup && - language.$(captionCapsule, 'seriesAlbumsNotFromGroup', { - marker: - language.$('misc.coverGrid.details.notFromThisGroup.marker'), - - series: - html.tag('i', data.name), - - group: data.groupName, - }), - - language.encapsulate(captionCapsule, workingCapsule => { - const workingOptions = {}; - - workingOptions.tracks = - html.tag('b', - language.countTracks(data.tracks, {unit: true})); - - workingOptions.albums = - html.tag('b', - language.countAlbums(data.albums, {unit: true})); - - if (data.allAlbumsDated) { - const earliestDate = data.earliestAlbumDate; - const latestDate = data.latestAlbumDate; - - const earliestYear = earliestDate.getFullYear(); - const latestYear = latestDate.getFullYear(); - - if (earliestYear === latestYear) { - if (data.albums === 1) { - workingCapsule += '.withDate'; - workingOptions.date = - language.formatDate(earliestDate); + html.tags([ + relations.contentHeading.slots({ + tag: 'h2', + title: language.sanitize(data.name), + }), + + relations.grid.slots({ + cutIndex: 4, + + bottomCaption: + language.encapsulate(capsule, 'caption', captionCapsule => + html.tags([ + data.anyAlbumNotFromThisGroup && + language.$(captionCapsule, 'seriesAlbumsNotFromGroup', { + marker: + language.$('misc.coverGrid.details.notFromThisGroup.marker'), + + series: + html.tag('i', data.name), + + group: data.groupName, + }), + + language.encapsulate(captionCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.tracks = + html.tag('b', + language.countTracks(data.tracks, {unit: true})); + + workingOptions.albums = + html.tag('b', + language.countAlbums(data.albums, {unit: true})); + + if (data.allAlbumsDated) { + const earliestDate = data.earliestAlbumDate; + const latestDate = data.latestAlbumDate; + + const earliestYear = earliestDate.getFullYear(); + const latestYear = latestDate.getFullYear(); + + if (earliestYear === latestYear) { + if (data.albums === 1) { + workingCapsule += '.withDate'; + workingOptions.date = + language.formatDate(earliestDate); + } else { + workingCapsule += '.withYear'; + workingOptions.year = + language.formatYear(earliestDate); + } } else { - workingCapsule += '.withYear'; - workingOptions.year = - language.formatYear(earliestDate); + workingCapsule += '.withYearRange'; + workingOptions.yearRange = + language.formatYearRange(earliestDate, latestDate); } - } else { - workingCapsule += '.withYearRange'; - workingOptions.yearRange = - language.formatYearRange(earliestDate, latestDate); } - } - - return language.$(workingCapsule, workingOptions); - }), - ], {[html.joinChildren]: html.tag('br')})), - expandCue: - language.$(capsule, 'expand'), - - collapseCue: - language.$(capsule, 'collapse'), - })), + return language.$(workingCapsule, workingOptions); + }), + ], {[html.joinChildren]: html.tag('br')})), + }), + ])), }; diff --git a/src/content/dependencies/generateGroupGalleryPageStyleSelector.js b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js new file mode 100644 index 00000000..9342e50f --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js @@ -0,0 +1,60 @@ +import {unique} from '#sugar'; + +export default { + query: (group) => ({ + styles: + unique(group.albums.map(album => album.style)), + }), + + data: (query, group) => ({ + albums: + group.albums.length, + + styles: + query.styles, + }), + + generate: (data, {html, language}) => + language.encapsulate('groupGalleryPage', pageCapsule => + (data.styles.length <= 1 + ? html.blank() + : html.tag('p', {class: 'gallery-style-selector'}, + {class: ['drop', 'shiny']}, + + language.encapsulate(pageCapsule, 'albumStyleSwitcher', capsule => [ + html.tag('span', + language.$(capsule)), + + html.tag('br'), + + html.tag('span', {class: 'styles'}, + data.styles.map(style => + html.tag('label', {'data-style': style}, [ + html.tag('input', {type: 'checkbox'}, + {checked: true}), + + html.tag('span', + language.$(capsule, style)), + ]))), + + html.tag('br'), + + html.tag('span', {class: ['count', 'all']}, + language.$(capsule, 'count.all', { + total: data.albums, + })), + + html.tag('span', {class: ['count', 'filtered']}, + {style: 'display: none'}, + + language.$(capsule, 'count.filtered', { + count: html.tag('span'), + total: data.albums, + })), + + html.tag('span', {class: ['count', 'none']}, + {style: 'display: none'}, + + language.$(capsule, 'count.none')), + ])))), +}; diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 7b9c2afa..0f3093b2 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -1,20 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateGroupInfoPageAlbumsSection', - 'generateGroupNavLinks', - 'generateGroupSecondaryNav', - 'generateGroupSidebar', - 'generatePageLayout', - 'linkArtist', - 'linkExternal', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableGroupUI: wikiInfo.enableGroupUI, diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js index df42598d..de55f33a 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js @@ -1,10 +1,6 @@ import {sortChronologically} from '#sort'; export default { - contentDependencies: ['generateGroupInfoPageAlbumsListItem'], - - extraDependencies: ['html'], - query: (group) => ({ // Typically, a latestFirst: false (default) chronological sort would be // appropriate here, but navigation between adjacent albums in a group is a diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js index bcd5d288..6bbee03a 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js @@ -1,13 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateGroupInfoPageAlbumsListItem', - ], - - extraDependencies: ['html', 'language'], - query: (group) => ({ closelyLinkedArtists: group.closelyLinkedArtists @@ -19,6 +12,10 @@ export default { group.serieses .map(() => relation('generateContentHeading')), + seriesDescriptions: + group.serieses + .map(series => relation('transformContent', series.description)), + seriesItems: group.serieses .map(series => series.albums @@ -57,11 +54,13 @@ export default { name: data.seriesNames, itemsShowArtists: data.seriesItemsShowArtists, heading: relations.seriesHeadings, + description: relations.seriesDescriptions, items: relations.seriesItems, }).map(({ name, itemsShowArtists, heading, + description, items, }) => html.tags([ @@ -73,7 +72,11 @@ export default { }), }), - html.tag('dd', + html.tag('dd', [ + html.tag('blockquote', + {[html.onlyIfContent]: true}, + description), + html.tag('ul', stitchArrays({ item: items, @@ -82,6 +85,7 @@ export default { item.slots({ accentMode: (showArtists ? 'artists' : null), - })))), + }))), + ]), ])))), }; diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js index 4680cb46..1211dfb8 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js @@ -1,16 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateArtistCredit', - 'generateColorStyleAttribute', - 'linkAlbum', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - query: (album, group) => { const otherCategory = album.groups @@ -76,7 +66,7 @@ export default { workingOptions.yearAccent = language.$(yearCapsule, 'accent', { year: - relations.datetimestamp.slots({style: 'year', tooltip: true}), + relations.datetimestamp.slot('style', 'year'), }); } @@ -127,9 +117,7 @@ export default { workingCapsule += '.withArtists'; workingOptions.by = html.tag('span', {class: 'by'}, - // TODO: This is obviously evil. - html.metatag('chunkwrap', {split: /,| (?=and)/}, - html.resolve(artistCredit))); + artistCredit); } return language.$(workingCapsule, workingOptions); diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js index 0b678e9d..4470eb2f 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js @@ -1,14 +1,4 @@ export default { - contentDependencies: [ - 'generateContentHeading', - 'generateGroupInfoPageAlbumsListByDate', - 'generateGroupInfoPageAlbumsListBySeries', - 'generateIntrapageDotSwitcher', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, group) => ({ contentHeading: relation('generateContentHeading'), diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js index 0e4ebe8a..18281bf0 100644 --- a/src/content/dependencies/generateGroupNavAccent.js +++ b/src/content/dependencies/generateGroupNavAccent.js @@ -1,14 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, group) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js index bdc3ee4c..4f13e474 100644 --- a/src/content/dependencies/generateGroupNavLinks.js +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateGroupNavAccent', 'linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({groupCategoryData, wikiInfo}) => ({ groupCategoryData, enableGroupUI: wikiInfo.enableGroupUI, diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js index c48f3142..6b4347dd 100644 --- a/src/content/dependencies/generateGroupSecondaryNav.js +++ b/src/content/dependencies/generateGroupSecondaryNav.js @@ -1,9 +1,4 @@ export default { - contentDependencies: [ - 'generateSecondaryNav', - 'generateGroupSecondaryNavCategoryPart', - ], - relations: (relation, group) => ({ secondaryNav: relation('generateSecondaryNav'), diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js index b2adb9f8..df627c99 100644 --- a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js +++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkGroupDynamically', - 'linkListing', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({listingSpec, wikiInfo}) => ({ groupsByCategoryListing: (wikiInfo.enableListings diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js index 0888cbbe..1359eaca 100644 --- a/src/content/dependencies/generateGroupSidebar.js +++ b/src/content/dependencies/generateGroupSidebar.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateGroupSidebarCategoryDetails', - 'generatePageSidebar', - 'generatePageSidebarBox', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({groupCategoryData}) => ({groupCategoryData}), relations: (relation, sprawl, group) => ({ diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js index 208ccd07..a7e1f240 100644 --- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -1,14 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations(relation, category) { return { colorStyle: diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js index cfb78a1b..006cfcce 100644 --- a/src/content/dependencies/generateImageOverlay.js +++ b/src/content/dependencies/generateImageOverlay.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - generate: ({html, language}) => html.tag('div', {id: 'image-overlay-container'}, html.tag('div', {id: 'image-overlay-content-container'}, [ diff --git a/src/content/dependencies/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js index 5a33444e..ddb7cb37 100644 --- a/src/content/dependencies/generateInterpageDotSwitcher.js +++ b/src/content/dependencies/generateInterpageDotSwitcher.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateDotSwitcherTemplate'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ template: relation('generateDotSwitcherTemplate'), diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js index 1d58367d..943d862c 100644 --- a/src/content/dependencies/generateIntrapageDotSwitcher.js +++ b/src/content/dependencies/generateIntrapageDotSwitcher.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateDotSwitcherTemplate'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ template: relation('generateDotSwitcherTemplate'), @@ -39,11 +36,32 @@ export default { stitchArrays({ title: slots.titles, targetID: slots.targetIDs, - }).map(({title, targetID}) => - html.tag('a', {href: '#'}, - {'data-target-id': targetID}, - {[html.onlyIfContent]: true}, + }).map(({title, targetID}) => { + const {content} = html.smush(title); + + const customCue = + content.find(item => + item?.tagName === 'span' && + item.attributes.has('class', 'dot-switcher-interaction-cue')); + + const cue = + (customCue && !html.isBlank(customCue) + ? customCue.content + : language.sanitize(title)); + + const a = + html.tag('a', {href: '#'}, + {'data-target-id': targetID}, + {[html.onlyIfContent]: true}, + + cue); - language.sanitize(title))), + if (customCue) { + content.splice(content.indexOf(customCue), 1, a); + return html.tags(content, {[html.joinChildren]: ''}); + } else { + return a; + } + }), }), }; diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js index 0a929429..e381a745 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateListAllAdditionalFilesChunk'], - extraDependencies: ['html', 'language'], - relations: (relation, _album, additionalFiles) => ({ chunk: relation('generateListAllAdditionalFilesChunk', additionalFiles), diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js index a0af1375..0f14f12c 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateContentHeading', - 'generateListAllAdditionalFilesAlbumChunk', - 'generateListAllAdditionalFilesTrackChunk', - 'linkAlbum', - ], - - extraDependencies: ['html'], - relations: (relation, album, property) => ({ heading: relation('generateContentHeading'), diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js index df652efd..d68e3bc1 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js +++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkAdditionalFile'], - extraDependencies: ['html', 'language'], - relations: (relation, additionalFiles) => ({ links: additionalFiles diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js index b2e5addf..9ac79bb5 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js +++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'], - extraDependencies: ['html'], - relations: (relation, track, additionalFiles) => ({ trackLink: relation('linkTrack', track), diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js index b3560aca..29e7b1c9 100644 --- a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js +++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkAlbum'], - data: (album) => ({directory: album.directory}), diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js index 78622e6e..db494f37 100644 --- a/src/content/dependencies/generateListingIndexList.js +++ b/src/content/dependencies/generateListingIndexList.js @@ -1,9 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkListing'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({listingTargetSpec, wikiInfo}) { return {listingTargetSpec, wikiInfo}; }, diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 5f9a99a9..987008eb 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -1,67 +1,36 @@ -import {bindOpts, empty, stitchArrays} from '#sugar'; +import {bindOpts, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateListingSidebar', - 'generatePageLayout', - 'linkListing', - 'linkListingIndex', - 'linkTemplate', - ], + relations: (relation, listing) => ({ + layout: + relation('generatePageLayout'), - extraDependencies: ['html', 'language', 'wikiData'], + sidebar: + relation('generateListingSidebar', listing), - relations(relation, listing) { - const relations = {}; + listingsIndexLink: + relation('linkListingIndex'), - relations.layout = - relation('generatePageLayout'); + chunkHeading: + relation('generateContentHeading'), - relations.sidebar = - relation('generateListingSidebar', listing); + showSkipToSectionLinkTemplate: + relation('linkTemplate'), - relations.listingsIndexLink = - relation('linkListingIndex'); + sameTargetListingsLine: + (listing.target.listings.length > 1 + ? relation('generateListingPageSameTargetListingsLine', listing) + : null), - relations.chunkHeading = - relation('generateContentHeading'); + seeAlsoLinks: + listing.seeAlso + .map(listing => relation('linkListing', listing)), + }), - relations.showSkipToSectionLinkTemplate = - relation('linkTemplate'); - - if (listing.target.listings.length > 1) { - relations.sameTargetListingLinks = - listing.target.listings - .map(listing => relation('linkListing', listing)); - } else { - relations.sameTargetListingLinks = []; - } - - relations.seeAlsoLinks = - (!empty(listing.seeAlso) - ? listing.seeAlso - .map(listing => relation('linkListing', listing)) - : []); - - return relations; - }, - - data(listing) { - return { - stringsKey: listing.stringsKey, - - targetStringsKey: listing.target.stringsKey, - - sameTargetListingStringsKeys: - listing.target.listings - .map(listing => listing.stringsKey), - - sameTargetListingsCurrentIndex: - listing.target.listings - .indexOf(listing), - }; - }, + data: (listing) => ({ + stringsKey: + listing.stringsKey, + }), slots: { type: { @@ -169,29 +138,7 @@ export default { headingMode: 'sticky', mainContent: [ - html.tag('p', - {[html.onlyIfContent]: true}, - language.$('listingPage.listingsFor', { - [language.onlyIfOptions]: ['listings'], - - target: - language.$('listingPage.target', data.targetStringsKey), - - listings: - language.formatUnitList( - stitchArrays({ - link: relations.sameTargetListingLinks, - stringsKey: data.sameTargetListingStringsKeys, - }).map(({link, stringsKey}, index) => - html.tag('span', - index === data.sameTargetListingsCurrentIndex && - {class: 'current'}, - - link.slots({ - attributes: {class: 'nowrap'}, - content: language.$('listingPage', stringsKey, 'title.short'), - })))), - })), + relations.sameTargetListingsLine, html.tag('p', {[html.onlyIfContent]: true}, diff --git a/src/content/dependencies/generateListingPageSameTargetListingsLine.js b/src/content/dependencies/generateListingPageSameTargetListingsLine.js new file mode 100644 index 00000000..2146b1eb --- /dev/null +++ b/src/content/dependencies/generateListingPageSameTargetListingsLine.js @@ -0,0 +1,46 @@ +import {stitchArrays} from '#sugar'; + +export default { + relations: (relation, listing) => ({ + listingLinks: + listing.target.listings + .map(listing => relation('linkListing', listing)), + }), + + data: (listing) => ({ + targetStringsKey: + listing.target.stringsKey, + + listingStringsKeys: + listing.target.listings.map(listing => listing.stringsKey), + + currentIndex: + listing.target.listings.indexOf(listing), + }), + + generate: (data, relations, {html, language}) => + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('listingPage.listingsFor', { + [language.onlyIfOptions]: ['listings'], + + target: + language.$('listingPage.target', data.targetStringsKey), + + listings: + language.formatUnitList( + stitchArrays({ + link: relations.listingLinks, + stringsKey: data.listingStringsKeys, + }).map(({link, stringsKey}, index) => + html.tag('span', + index === data.currentIndex && + {class: 'current'}, + + link.slots({ + attributes: {class: 'nowrap'}, + content: language.$('listingPage', stringsKey, 'title.short'), + })))), + })), +}; diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js index aeac05cf..2d9429cf 100644 --- a/src/content/dependencies/generateListingSidebar.js +++ b/src/content/dependencies/generateListingSidebar.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateListingIndexList', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'linkListingIndex', - ], - - extraDependencies: ['html'], - relations: (relation, currentListing) => ({ sidebar: relation('generatePageSidebar'), diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js index b57ebe15..80963d12 100644 --- a/src/content/dependencies/generateListingsIndexPage.js +++ b/src/content/dependencies/generateListingsIndexPage.js @@ -1,20 +1,14 @@ import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateListingIndexList', - 'generateListingSidebar', - 'generatePageLayout', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({albumData, trackData, wikiInfo}) { return { wikiName: wikiInfo.name, numTracks: trackData.length, numAlbums: albumData.length, - totalDuration: getTotalDuration(trackData), + totalDuration: + getTotalDuration( + trackData.filter(track => track.countInArtistTotals)), }; }, diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js index 02fd3634..15f84b27 100644 --- a/src/content/dependencies/generateLyricsEntry.js +++ b/src/content/dependencies/generateLyricsEntry.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkArtist', 'linkExternal', 'transformContent'], - extraDependencies: ['html', 'language'], - relations: (relation, entry) => ({ content: relation('transformContent', entry.body), @@ -17,6 +14,9 @@ export default { sourceLinks: entry.sourceURLs .map(url => relation('linkExternal', url)), + + originDetails: + relation('transformContent', entry.originDetails), }), data: (entry) => ({ @@ -25,6 +25,30 @@ export default { hasSquareBracketAnnotations: entry.hasSquareBracketAnnotations, + + numStanzas: + 1 + + + (Array.from( + entry.body + .matchAll(/\n\n|<br><br>/g)) + + .length) + + + (entry.body.includes('<br') + ? entry.body.split('\n').length + : 0), + + numLines: + 1 + + + (Array.from( + entry.body + .replaceAll(/(<br>){1,}/g, '\n') + .replaceAll(/\n{2,}/g, '\n') + .matchAll(/\n/g)) + + .length), }), slots: { @@ -36,9 +60,16 @@ export default { generate: (data, relations, slots, {html, language}) => language.encapsulate('misc.lyrics', capsule => - html.tag('div', {class: 'lyrics-entry'}, + html.tag('blockquote', {class: 'lyrics-entry'}, slots.attributes, + {'data-stanzas': data.numStanzas}, + {'data-lines': data.numLines}, + + (data.numStanzas > 1 || + data.numLines > 8) && + {class: 'long-lyrics'}, + [ html.tag('p', {class: 'lyrics-details'}, {[html.onlyIfContent]: true}, @@ -75,6 +106,14 @@ export default { language.$(capsule, 'squareBracketAnnotations'), ]), + html.tag('p', {class: 'origin-details'}, + {[html.onlyIfContent]: true}, + + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + })), + relations.content.slot('mode', 'lyrics'), ])), }; diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js index f6b719a9..bbc3a776 100644 --- a/src/content/dependencies/generateLyricsSection.js +++ b/src/content/dependencies/generateLyricsSection.js @@ -1,15 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateIntrapageDotSwitcher', - 'generateLyricsEntry', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, entries) => ({ heading: relation('generateContentHeading'), @@ -21,10 +12,10 @@ export default { entries .map(entry => relation('generateLyricsEntry', entry)), - annotations: + annotationParts: entries - .map(entry => entry.annotation) - .map(annotation => relation('transformContent', annotation)), + .map(entry => entry.annotationParts + .map(part => relation('transformContent', part))), }), data: (entries) => ({ @@ -54,11 +45,24 @@ export default { initialOptionIndex: 0, titles: - relations.annotations.map(annotation => - annotation.slots({ - mode: 'inline', - textOnly: true, - })), + relations.annotationParts + .map(([first, ...rest]) => + language.formatUnitList([ + html.tag('span', + {class: 'dot-switcher-interaction-cue'}, + {[html.onlyIfContent]: true}, + + first?.slots({ + mode: 'inline', + textOnly: true, + })), + + ...rest.map(part => + part.slots({ + mode: 'inline', + textOnly: true, + })), + ])), targetIDs: data.ids, diff --git a/src/content/dependencies/generateName.js b/src/content/dependencies/generateName.js new file mode 100644 index 00000000..e0d0c6d3 --- /dev/null +++ b/src/content/dependencies/generateName.js @@ -0,0 +1,33 @@ +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, thing) => ({ + customName: + (thing.nameText + ? relation('transformContent', thing.nameText) + : null), + }), + + data: (thing) => ({ + normalName: + thing.name, + + shortName: + thing.nameShort, + }), + + slots: { + preferShortName: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {language}) => + (relations.customName + ? relations.customName.slot('mode', 'inline') + : slots.preferShortName && data.shortName + ? language.sanitize(data.shortName) + : language.sanitize(data.normalName)), +}; diff --git a/src/content/dependencies/generateNearbyTrackList.js b/src/content/dependencies/generateNearbyTrackList.js new file mode 100644 index 00000000..56ab2df5 --- /dev/null +++ b/src/content/dependencies/generateNearbyTrackList.js @@ -0,0 +1,44 @@ +export default { + query: (tracks, contextTrack, _contextContributions) => ({ + presentedTracks: + (contextTrack + ? tracks.map(track => + track.otherReleases.find(({album}) => album === contextTrack.album) ?? + track) + : tracks), + }), + + relations: (relation, query, _tracks, _contextTrack, contextContributions) => ({ + items: + query.presentedTracks + .map(track => relation('generateTrackListItem', track, contextContributions)), + }), + + slots: { + showArtists: { + validate: v => v.is(true, false, 'auto'), + default: 'auto', + }, + + showDuration: { + type: 'boolean', + default: false, + }, + + colorMode: { + validate: v => v.is('none', 'track', 'line'), + default: 'track', + }, + }, + + generate: (relations, slots, {html}) => + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.items.map(item => + item.slots({ + showArtists: slots.showArtists, + showDuration: slots.showDuration, + colorMode: slots.colorMode, + }))), +}; diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js index 5d168e41..05248eb3 100644 --- a/src/content/dependencies/generateNewsEntryNavAccent.js +++ b/src/content/dependencies/generateNewsEntryNavAccent.js @@ -1,11 +1,4 @@ export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkNewsEntry', - ], - relations: (relation, previousEntry, nextEntry) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js index 4abd87d1..bbfb886d 100644 --- a/src/content/dependencies/generateNewsEntryPage.js +++ b/src/content/dependencies/generateNewsEntryPage.js @@ -2,16 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateNewsEntryNavAccent', - 'generateNewsEntryReadAnotherLinks', - 'generatePageLayout', - 'linkNewsIndex', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({newsData}) { return {newsData}; }, diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js index d978b0e4..1f6ee6d4 100644 --- a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js +++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateRelativeDatetimestamp', - 'linkNewsEntry', - ], - - extraDependencies: ['html', 'language'], - relations(relation, currentEntry, previousEntry, nextEntry) { const relations = {}; @@ -57,10 +49,7 @@ export default { if (relations.previousEntryDatetimestamp) { parts.push('withDate'); options.date = - relations.previousEntryDatetimestamp.slots({ - style: 'full', - tooltip: true, - }); + relations.previousEntryDatetimestamp.slot('style', 'full'); } entryLines.push(language.$(...parts, options)); @@ -75,10 +64,7 @@ export default { if (relations.nextEntryDatetimestamp) { parts.push('withDate'); options.date = - relations.nextEntryDatetimestamp.slots({ - style: 'full', - tooltip: true, - }); + relations.nextEntryDatetimestamp.slot('style', 'full'); } entryLines.push(language.$(...parts, options)); diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js index 02964ce8..d88bfdba 100644 --- a/src/content/dependencies/generateNewsIndexPage.js +++ b/src/content/dependencies/generateNewsIndexPage.js @@ -2,14 +2,6 @@ import {sortChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generatePageLayout', - 'linkNewsEntry', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({newsData}) { return {newsData}; }, diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js index 2e48cd2b..2c497e12 100644 --- a/src/content/dependencies/generateNextLink.js +++ b/src/content/dependencies/generateNextLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generatePreviousNextLink'], - relations: (relation) => ({ link: relation('generatePreviousNextLink'), diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 89fefb23..23d5932d 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -1,27 +1,9 @@ +import striptags from 'striptags'; + import {openAggregate} from '#aggregate'; import {atOffset, empty, repeat} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleRules', - 'generateFooterLocalizationLinks', - 'generateImageOverlay', - 'generatePageSidebar', - 'generateSearchSidebarBox', - 'generateStickyHeadingContainer', - 'transformContent', - ], - - extraDependencies: [ - 'getColors', - 'html', - 'language', - 'pagePath', - 'pagePathStringFromRoot', - 'to', - 'wikiData', - ], - sprawl: ({wikiInfo}) => ({ enableSearch: wikiInfo.enableSearch, footerContent: wikiInfo.footerContent, @@ -58,8 +40,14 @@ export default { relation('transformContent', sprawl.footerContent); } - relations.colorStyleRules = - relation('generateColorStyleRules'); + relations.colorStyleTag = + relation('generateColorStyleTag'); + + relations.staticURLStyleTag = + relation('generateStaticURLStyleTag'); + + relations.wikiWallpaperStyleTag = + relation('generateWikiWallpaperStyleTag'); relations.imageOverlay = relation('generateImageOverlay'); @@ -107,9 +95,9 @@ export default { color: {validate: v => v.isColor}, - styleRules: { - validate: v => v.sparseArrayOf(v.isHTML), - default: [], + styleTags: { + type: 'html', + mutable: false, }, mainClasses: { @@ -288,12 +276,17 @@ export default { const titleContentsHTML = (html.isBlank(slots.title) ? null - : html.isBlank(slots.additionalNames) - ? language.sanitize(slots.title) - : html.tag('a', { + + : (!html.isBlank(slots.additionalNames) && + !html.resolve(slots.additionalNames, {slots: ['alwaysVisible']}) + .getSlotValue('alwaysVisible')) + + ? html.tag('a', { href: '#additional-names-box', title: language.$('misc.additionalNames.tooltip').toString(), - }, language.sanitize(slots.title))); + }, language.sanitize(slots.title)) + + : language.sanitize(slots.title)); const titleHTML = (html.isBlank(slots.title) @@ -581,29 +574,33 @@ export default { {id: 'additional-files', string: 'additionalFiles'}, {id: 'commentary', string: 'commentary'}, {id: 'artist-commentary', string: 'artistCommentary'}, - {id: 'credit-sources', string: 'creditSources'}, + {id: 'crediting-sources', string: 'creditingSources'}, + {id: 'referencing-sources', string: 'referencingSources'}, ])), ]); - const styleRulesCSS = - html.resolve(slots.styleRules, {normalize: 'string'}); + const slottedStyleTags = + html.smush(slots.styleTags); - const fallbackBackgroundStyleRule = - (styleRulesCSS.match(/body::before[^}]*background-image:/) - ? '' - : `body::before {\n` + - ` background-image: url("${to('media.path', 'bg.jpg')}");\n` + - `}`); + const slottedWallpaperStyleTag = + slottedStyleTags.content + .find(tag => tag.attributes.has('class', 'wallpaper-style')); - const goshFrigginDarnitStyleRule = - `.image-media-link::after {\n` + - ` mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` + - `}`; + const fallbackWallpaperStyleTag = + (slottedWallpaperStyleTag + ? html.blank() + : relations.wikiWallpaperStyleTag); + + const usingWallpaperStyleTag = + (slottedWallpaperStyleTag + ? slottedWallpaperStyleTag + : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'})); const numWallpaperParts = - html.resolve(slots.styleRules, {normalize: 'string'}) - .match(/\.wallpaper-part:nth-child/g) - ?.length ?? 0; + (usingWallpaperStyleTag && + usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts') + ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts')) + : 0); const wallpaperPartsHTML = html.tag('div', {class: 'wallpaper-parts'}, @@ -659,11 +656,25 @@ export default { language.encapsulate('misc.pageTitle', workingCapsule => { const workingOptions = {}; - workingOptions.title = slots.title; + // Slightly jank: The output of striptags is, of course, a string, + // and as far as language.formatString() is concerned, that means + // it needs to be sanitized - including turning ampersands into + // &'s. But the title is already HTML that has implicitly been + // sanitized, however it got here, and includes HTML entities that + // are properly escaped. Those need to get included as they are, + // so we wrap the title in a tag and pass it off as good to go. + workingOptions.title = + html.tags([ + striptags(slots.title.toString()), + ]); if (!html.isBlank(slots.subtitle)) { + // Same shenanigans here, as far as wrapping striptags goes. workingCapsule += '.withSubtitle'; - workingOptions.subtitle = slots.subtitle; + workingOptions.subtitle = + html.tags([ + striptags(slots.subtitle.toString()), + ]); } const showWikiName = @@ -745,14 +756,14 @@ export default { href: to('staticCSS.path', 'site.css'), }), - html.tag('style', [ - relations.colorStyleRules - .slot('color', slots.color ?? data.wikiColor), + relations.colorStyleTag + .slot('color', slots.color ?? data.wikiColor), - fallbackBackgroundStyleRule, - goshFrigginDarnitStyleRule, - slots.styleRules, - ]), + relations.staticURLStyleTag, + + fallbackWallpaperStyleTag, + + slottedStyleTags, html.tag('script', { src: to('staticLib.path', 'chroma-js/chroma.min.js'), diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js index d3b55580..dfe85632 100644 --- a/src/content/dependencies/generatePageSidebar.js +++ b/src/content/dependencies/generatePageSidebar.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { // Attributes to apply to the whole sidebar. This be added to the // containing sidebar-column, arr - specify attributes on each section if diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js index 26b30494..3133aa64 100644 --- a/src/content/dependencies/generatePageSidebarBox.js +++ b/src/content/dependencies/generatePageSidebarBox.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { content: { type: 'html', diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js index 7974c707..4ed0ff22 100644 --- a/src/content/dependencies/generatePageSidebarConjoinedBox.js +++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js @@ -4,9 +4,6 @@ // templates' resolved content), take care when slotting into this. export default { - contentDependencies: ['generatePageSidebarBox'], - extraDependencies: ['html'], - relations: (relation) => ({ box: relation('generatePageSidebarBox'), diff --git a/src/content/dependencies/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js index 775367f9..29146a21 100644 --- a/src/content/dependencies/generatePreviousLink.js +++ b/src/content/dependencies/generatePreviousLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generatePreviousNextLink'], - relations: (relation) => ({ link: relation('generatePreviousNextLink'), diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js index afae1228..1e98358f 100644 --- a/src/content/dependencies/generatePreviousNextLink.js +++ b/src/content/dependencies/generatePreviousNextLink.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - slots: { link: { type: 'html', diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js index e144503e..f67f9514 100644 --- a/src/content/dependencies/generateQuickDescription.js +++ b/src/content/dependencies/generateQuickDescription.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['transformContent'], - extraDependencies: ['html', 'language'], - query: (thing) => ({ hasDescription: !!thing.description, diff --git a/src/content/dependencies/generateReadCommentaryLine.js b/src/content/dependencies/generateReadCommentaryLine.js new file mode 100644 index 00000000..05700536 --- /dev/null +++ b/src/content/dependencies/generateReadCommentaryLine.js @@ -0,0 +1,43 @@ +import {empty} from '#sugar'; + +export default { + query: (thing) => ({ + entries: + (thing.isTrack + ? [...thing.commentary, ...thing.commentaryFromMainRelease] + : thing.commentary), + }), + + data: (query, _thing) => ({ + hasWikiEditorCommentary: + query.entries.some(entry => entry.isWikiEditorCommentary), + + onlyWikiEditorCommentary: + !empty(query.entries) && + query.entries.every(entry => entry.isWikiEditorCommentary), + + hasAnyCommentary: + !empty(query.entries), + }), + + generate: (data, {html, language}) => + language.encapsulate('releaseInfo.readCommentary', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['link'], + + link: + html.tag('a', + {[html.onlyIfContent]: true}, + + {href: '#artist-commentary'}, + + language.encapsulate(capsule, 'link', capsule => + (data.onlyWikiEditorCommentary + ? language.$(capsule, 'onlyWikiCommentary') + : data.hasWikiEditorCommentary + ? language.$(capsule, 'withWikiCommentary') + : data.hasAnyCommentary + ? language.$(capsule) + : html.blank()))), + })), +}; diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js index 154b4762..2f47b7a5 100644 --- a/src/content/dependencies/generateReferencedArtworksPage.js +++ b/src/content/dependencies/generateReferencedArtworksPage.js @@ -1,14 +1,4 @@ export default { - contentDependencies: [ - 'generateCoverArtwork', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAnythingMan', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, artwork) => ({ layout: relation('generatePageLayout'), @@ -47,7 +37,7 @@ export default { }), slots: { - styleRules: {type: 'html', mutable: false}, + styleTags: {type: 'html', mutable: false}, title: {type: 'html', mutable: false}, @@ -62,7 +52,7 @@ export default { subtitle: language.$(pageCapsule, 'subtitle'), color: data.color, - styleRules: slots.styleRules, + styleTags: slots.styleTags, artworkColumnContent: relations.cover.slots({ diff --git a/src/content/dependencies/generateReferencedTracksList.js b/src/content/dependencies/generateReferencedTracksList.js new file mode 100644 index 00000000..1d566ce9 --- /dev/null +++ b/src/content/dependencies/generateReferencedTracksList.js @@ -0,0 +1,29 @@ +export default { + relations: (relation, track) => ({ + previousProductionTrackList: + relation('generateNearbyTrackList', + track.previousProductionTracks, + track, + track.artistContribs), + + referencedTrackList: + relation('generateNearbyTrackList', + track.referencedTracks, + track, + []), + }), + + generate: (relations, {html, language}) => + html.tag('ul', {[html.onlyIfContent]: true}, [ + html.inside(relations.previousProductionTrackList) + .map(li => html.inside(li)) + .map(label => + html.tag('li', + language.$('trackList.item.previousProduction', + {track: label}))), + + html.inside(relations.referencedTrackList), + ]), +}; + + diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js index 55977b37..abb92732 100644 --- a/src/content/dependencies/generateReferencingArtworksPage.js +++ b/src/content/dependencies/generateReferencingArtworksPage.js @@ -1,14 +1,4 @@ export default { - contentDependencies: [ - 'generateCoverArtwork', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAnythingMan', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, artwork) => ({ layout: relation('generatePageLayout'), @@ -47,7 +37,7 @@ export default { }), slots: { - styleRules: {type: 'html', mutable: false}, + styleTags: {type: 'html', mutable: false}, title: {type: 'html', mutable: false}, @@ -62,7 +52,7 @@ export default { subtitle: language.$(pageCapsule, 'subtitle'), color: data.color, - styleRules: slots.styleRules, + styleTags: slots.styleTags, artworkColumnContent: relations.cover.slots({ diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js index a997de0e..1415564e 100644 --- a/src/content/dependencies/generateRelativeDatetimestamp.js +++ b/src/content/dependencies/generateRelativeDatetimestamp.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateDatetimestampTemplate', - 'generateTooltip', - ], - - extraDependencies: ['html', 'language'], - data: (currentDate, referenceDate) => (currentDate.getTime() === referenceDate.getTime() ? {equal: true, date: currentDate} @@ -28,19 +20,11 @@ export default { validate: v => v.is('full', 'year'), default: 'full', }, - - tooltip: { - type: 'boolean', - default: false, - }, }, generate(data, relations, slots, {language}) { if (data.equal) { - return relations.fallback.slots({ - style: slots.style, - tooltip: slots.tooltip, - }); + return relations.fallback.slot('style', slots.style); } return relations.template.slots({ @@ -52,15 +36,14 @@ export default { : null), tooltip: - slots.tooltip && - relations.tooltip.slots({ - content: - language.formatRelativeDate(data.currentDate, data.referenceDate, { - considerRoundingDays: true, - approximate: true, - absolute: slots.style === 'year', - }), - }), + relations.tooltip.slots({ + content: + language.formatRelativeDate(data.currentDate, data.referenceDate, { + considerRoundingDays: true, + approximate: true, + absolute: slots.style === 'year', + }), + }), datetime: data.currentDate.toISOString(), diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js index 016e0a2c..4353ccf4 100644 --- a/src/content/dependencies/generateReleaseInfoContributionsLine.js +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -1,16 +1,15 @@ export default { - contentDependencies: ['generateArtistCredit'], - extraDependencies: ['html'], - - relations: (relation, contributions) => ({ + relations: (relation, contributions, formatText) => ({ credit: - relation('generateArtistCredit', contributions, []), + relation('generateArtistCredit', contributions, [], formatText), }), slots: { stringKey: {type: 'string'}, featuringStringKey: {type: 'string'}, + additionalStringOptions: {validate: v => v.isObject}, + chronologyKind: {type: 'string'}, }, @@ -27,5 +26,6 @@ export default { normalStringKey: slots.stringKey, normalFeaturingStringKey: slots.featuringStringKey, + additionalStringOptions: slots.additionalStringOptions, }), }; diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js new file mode 100644 index 00000000..97f248d6 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoListenLine.js @@ -0,0 +1,156 @@ +import {isExternalLinkContext} from '#external-links'; +import {empty, stitchArrays, unique} from '#sugar'; + +function getReleaseContext(urlString, { + _artistURLs, + albumArtistURLs, +}) { + const composerBandcampDomains = + albumArtistURLs + .filter(url => url.hostname.endsWith('.bandcamp.com')) + .map(url => url.hostname); + + const url = new URL(urlString); + + if (url.hostname === 'homestuck.bandcamp.com') { + return 'officialRelease'; + } + + if (composerBandcampDomains.includes(url.hostname)) { + return 'composerRelease'; + } + + return null; +} + +export default { + query(thing) { + const query = {}; + + query.album = + (thing.album + ? thing.album + : thing); + + query.urls = + (!empty(thing.urls) + ? thing.urls + : thing.album && + thing.album.style === 'single' && + thing.album.tracks[0] === thing + ? thing.album.urls + : []); + + query.artists = + thing.artistContribs + .map(contrib => contrib.artist); + + query.artistGroups = + query.artists + .flatMap(artist => artist.closelyLinkedGroups) + .map(({group}) => group); + + query.albumArtists = + query.album.artistContribs + .map(contrib => contrib.artist); + + query.albumArtistGroups = + query.albumArtists + .flatMap(artist => artist.closelyLinkedGroups) + .map(({group}) => group); + + return query; + }, + + relations: (relation, query, _thing) => ({ + links: + query.urls.map(url => relation('linkExternal', url)), + }), + + data(query, thing) { + const data = {}; + + data.name = thing.name; + + const artistURLs = + unique([ + ...query.artists.flatMap(artist => artist.urls), + ...query.artistGroups.flatMap(group => group.urls), + ]).map(url => new URL(url)); + + const albumArtistURLs = + unique([ + ...query.albumArtists.flatMap(artist => artist.urls), + ...query.albumArtistGroups.flatMap(group => group.urls), + ]).map(url => new URL(url)); + + const boundGetReleaseContext = urlString => + getReleaseContext(urlString, { + artistURLs, + albumArtistURLs, + }); + + let releaseContexts = + query.urls.map(boundGetReleaseContext); + + const albumReleaseContexts = + query.album.urls.map(boundGetReleaseContext); + + const presentReleaseContexts = + unique(releaseContexts.filter(Boolean)); + + const presentAlbumReleaseContexts = + unique(albumReleaseContexts.filter(Boolean)); + + if ( + presentReleaseContexts.length <= 1 && + presentAlbumReleaseContexts.length <= 1 + ) { + releaseContexts = + query.urls.map(() => null); + } + + data.releaseContexts = releaseContexts; + + return data; + }, + + slots: { + visibleWithoutLinks: { + type: 'boolean', + default: false, + }, + + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('releaseInfo.listenOn', capsule => + (empty(relations.links) && slots.visibleWithoutLinks + ? language.$(capsule, 'noLinks', { + name: + html.tag('i', data.name), + }) + + : language.$('releaseInfo.listenOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + stitchArrays({ + link: relations.links, + releaseContext: data.releaseContexts, + }).map(({link, releaseContext}) => + link.slot('context', [ + ... + (Array.isArray(slots.context) + ? slots.context + : [slots.context]), + + releaseContext, + ]))), + }))), +}; diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js index 308a1105..701a01ac 100644 --- a/src/content/dependencies/generateSearchSidebarBox.js +++ b/src/content/dependencies/generateSearchSidebarBox.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generatePageSidebarBox'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ sidebarBox: relation('generatePageSidebarBox'), @@ -58,6 +55,23 @@ export default { language.$(capsule, 'artTag')), ]), + language.encapsulate(capsule, 'resultDisambiguator', capsule => [ + html.tag('template', {class: 'wiki-search-group-result-disambiguator-string'}, + language.$(capsule, 'group', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + + html.tag('template', {class: 'wiki-search-flash-result-disambiguator-string'}, + language.$(capsule, 'flash', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + + html.tag('template', {class: 'wiki-search-track-result-disambiguator-string'}, + language.$(capsule, 'track', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + ]), + language.encapsulate(capsule, 'resultFilter', capsule => [ html.tag('template', {class: 'wiki-search-album-result-filter-string'}, language.$(capsule, 'album')), diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js index 9ce7ce9b..63b3839b 100644 --- a/src/content/dependencies/generateSecondaryNav.js +++ b/src/content/dependencies/generateSecondaryNav.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { content: { type: 'html', diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js index f204f1fb..fe7c17ac 100644 --- a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js +++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js @@ -1,15 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - relations: (relation) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js index 513ea518..5fa9376c 100644 --- a/src/content/dependencies/generateSocialEmbed.js +++ b/src/content/dependencies/generateSocialEmbed.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'], - sprawl({wikiInfo}) { return { canonicalBase: wikiInfo.canonicalBase, diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js index 226152c7..485b802e 100644 --- a/src/content/dependencies/generateStaticPage.js +++ b/src/content/dependencies/generateStaticPage.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generatePageLayout', 'transformContent'], - extraDependencies: ['html'], - relations(relation, staticPage) { return { layout: relation('generatePageLayout'), @@ -23,17 +20,19 @@ export default { title: data.name, headingMode: 'sticky', - styleRules: - (data.stylesheet - ? [data.stylesheet] - : []), + styleTags: [ + html.tag('style', {class: 'static-page-style'}, + {[html.onlyIfContent]: true}, + data.stylesheet), + ], mainClasses: ['long-content'], mainContent: [ relations.content, - data.script && - html.tag('script', data.script), + html.tag('script', + {[html.onlyIfContent]: true}, + data.script), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js new file mode 100644 index 00000000..443a4d08 --- /dev/null +++ b/src/content/dependencies/generateStaticURLStyleTag.js @@ -0,0 +1,20 @@ +export default { + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + }), + + generate: (relations, {to}) => + relations.styleTag.slots({ + attributes: {class: 'static-url-style'}, + + rules: [ + { + select: '.image-media-link::after', + declare: [ + `mask-image: url("${to('staticMisc.path', 'image.svg')}");` + ], + }, + ], + }), +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js index ec3062a3..f7388d60 100644 --- a/src/content/dependencies/generateStickyHeadingContainer.js +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { rootAttributes: { type: 'attributes', diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js new file mode 100644 index 00000000..cdeadcfe --- /dev/null +++ b/src/content/dependencies/generateStyleTag.js @@ -0,0 +1,46 @@ +import {empty} from '#sugar'; + +const indent = text => + text + .split('\n') + .map(line => ' '.repeat(4) + line) + .join('\n'); + +export default { + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + rules: { + validate: v => + v.looseArrayOf( + v.validateProperties({ + select: v.isString, + declare: v.looseArrayOf(v.isString), + })), + }, + }, + + generate: (slots, {html}) => + html.tag('style', slots.attributes, + {[html.onlyIfContent]: true}, + + slots.rules + .filter(Boolean) + + .map(rule => ({ + select: rule.select, + declare: rule.declare.filter(Boolean), + })) + + .filter(rule => !empty(rule.declare)) + + .map(rule => + `${rule.select} {\n` + + indent(rule.declare.join('\n')) + '\n' + + `}`) + + .join('\n\n')), +}; diff --git a/src/content/dependencies/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js index 49ce1f61..360cfebc 100644 --- a/src/content/dependencies/generateTextWithTooltip.js +++ b/src/content/dependencies/generateTextWithTooltip.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js index b09ee230..6f23af6d 100644 --- a/src/content/dependencies/generateTooltip.js +++ b/src/content/dependencies/generateTooltip.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js index e3041d3a..39a3e145 100644 --- a/src/content/dependencies/generateTrackArtistCommentarySection.js +++ b/src/content/dependencies/generateTrackArtistCommentarySection.js @@ -1,15 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateCommentaryEntry', - 'generateContentHeading', - 'linkAlbum', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - query: (track) => ({ otherSecondaryReleasesWithCommentary: track.otherReleases @@ -18,8 +9,8 @@ export default { }), relations: (relation, query, track) => ({ - contentHeading: - relation('generateContentHeading'), + commentaryContentHeading: + relation('generateCommentaryContentHeading', track), mainReleaseTrackLink: (track.isSecondaryRelease @@ -28,7 +19,7 @@ export default { mainReleaseArtistCommentaryEntries: (track.isSecondaryRelease - ? track.mainReleaseTrack.commentary + ? track.commentaryFromMainRelease .map(entry => relation('generateCommentaryEntry', entry)) : null), @@ -78,54 +69,40 @@ export default { generate: (data, relations, {html, language}) => language.encapsulate('misc.artistCommentary', capsule => html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'artist-commentary'}, - title: language.$('misc.artistCommentary'), - }), + relations.commentaryContentHeading, + relations.artistCommentaryEntries, data.isSecondaryRelease && - html.tags([ - html.tag('p', {class: ['drop', 'commentary-drop']}, - {[html.onlyIfSiblings]: true}, - - language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => { - const workingOptions = {}; - - workingOptions.album = - relations.mainReleaseTrackLink.slots({ - content: - data.mainReleaseAlbumName, - - color: - data.mainReleaseAlbumColor, - }); - - if (data.name !== data.mainReleaseName) { - workingCapsule += '.namedDifferently'; - workingOptions.name = - html.tag('i', data.mainReleaseName); - } - - return language.$(workingCapsule, workingOptions); - })), - - relations.mainReleaseArtistCommentaryEntries, - ]), - - html.tags([ - data.isSecondaryRelease && - !html.isBlank(relations.mainReleaseArtistCommentaryEntries) && - html.tag('p', {class: ['drop', 'commentary-drop']}, - {[html.onlyIfSiblings]: true}, - - language.$(capsule, 'info.releaseSpecific', { - album: - relations.thisReleaseAlbumLink, - })), - - relations.artistCommentaryEntries, - ]), + html.tag('div', {class: 'inherited-commentary-section'}, + {[html.onlyIfContent]: true}, + + [ + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfSiblings]: true}, + + language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => { + const workingOptions = {}; + + workingOptions.album = + relations.mainReleaseTrackLink.slots({ + content: + data.mainReleaseAlbumName, + + color: + data.mainReleaseAlbumColor, + }); + + if (data.name !== data.mainReleaseName) { + workingCapsule += '.namedDifferently'; + workingOptions.name = + html.tag('i', data.mainReleaseName); + } + + return language.$(workingCapsule, workingOptions); + })), + + relations.mainReleaseArtistCommentaryEntries, + ]), html.tag('p', {class: ['drop', 'commentary-drop']}, {[html.onlyIfContent]: true}, diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js index f06d735b..234586e0 100644 --- a/src/content/dependencies/generateTrackArtworkColumn.js +++ b/src/content/dependencies/generateTrackArtworkColumn.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateCoverArtwork'], - extraDependencies: ['html'], - relations: (relation, track) => ({ albumCover: (!track.hasUniqueCoverArt && track.album.hasCoverArt diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index ab6ea1cb..d3c2d766 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -1,45 +1,46 @@ -export default { - contentDependencies: [ - 'generateAdditionalFilesList', - 'generateAdditionalNamesBox', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumSidebar', - 'generateAlbumStyleRules', - 'generateCommentaryEntry', - 'generateContentHeading', - 'generateContributionList', - 'generateLyricsSection', - 'generatePageLayout', - 'generateTrackArtistCommentarySection', - 'generateTrackArtworkColumn', - 'generateTrackInfoPageFeaturedByFlashesList', - 'generateTrackInfoPageOtherReleasesList', - 'generateTrackList', - 'generateTrackListDividedByGroups', - 'generateTrackNavLinks', - 'generateTrackReleaseInfo', - 'generateTrackSocialEmbed', - 'linkAlbum', - 'linkTrack', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], +function checkInterrupted(which, relations, {html}) { + if ( + !html.isBlank(relations.additionalFilesList) || + !html.isBlank(relations.contributorContributionList) || + !html.isBlank(relations.flashesThatFeatureList) || + !html.isBlank(relations.lyricsSection) || + !html.isBlank(relations.midiProjectFilesList) || + !html.isBlank(relations.referencedByTracksList) || + !html.isBlank(relations.referencedTracksList) || + !html.isBlank(relations.sampledByTracksList) || + !html.isBlank(relations.sampledTracksList) || + !html.isBlank(relations.sheetMusicFilesList) + ) return true; + + if (which === 'crediting-sources' || which === 'referencing-sources') { + if (!html.isBlank(relations.artistCommentarySection)) return true; + } + + return false; +} +export default { query: (track) => ({ mainReleaseTrack: (track.isMainRelease ? track : track.mainReleaseTrack), + + singleTrackSingle: + track.album.style === 'single' && + track.album.tracks.length === 1, + + firstTrackInSingle: + track.album.style === 'single' && + track === track.album.tracks[0], }), relations: (relation, query, track) => ({ layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), socialEmbed: relation('generateTrackSocialEmbed', track), @@ -47,6 +48,9 @@ export default { navLinks: relation('generateTrackNavLinks', track), + albumNavLink: + relation('linkAlbum', track.album), + albumNavAccent: relation('generateAlbumNavAccent', track.album, track), @@ -60,33 +64,46 @@ export default { relation('generateAdditionalNamesBox', track.additionalNames), artworkColumn: - relation('generateTrackArtworkColumn', track), + (query.firstTrackInSingle + ? relation('generateAlbumArtworkColumn', track.album) + : relation('generateTrackArtworkColumn', track)), contentHeading: relation('generateContentHeading'), + name: + relation('generateName', track), + releaseInfo: relation('generateTrackReleaseInfo', track), - otherReleasesList: - relation('generateTrackInfoPageOtherReleasesList', track), + readCommentaryLine: + relation('generateReadCommentaryLine', track), + + otherReleasesLine: + relation('generateTrackInfoPageOtherReleasesLine', track), + + previousProductionLine: + relation('generateTrackInfoPagePreviousProductionLine', track), contributorContributionList: relation('generateContributionList', track.contributorContribs), referencedTracksList: - relation('generateTrackList', track.referencedTracks), + relation('generateReferencedTracksList', track), sampledTracksList: - relation('generateTrackList', track.sampledTracks), + relation('generateNearbyTrackList', track.sampledTracks, track, []), referencedByTracksList: relation('generateTrackListDividedByGroups', - query.mainReleaseTrack.referencedByTracks), + query.mainReleaseTrack.referencedByTracks, + track), sampledByTracksList: relation('generateTrackListDividedByGroups', - query.mainReleaseTrack.sampledByTracks), + query.mainReleaseTrack.sampledByTracks, + track), flashesThatFeatureList: relation('generateTrackInfoPageFeaturedByFlashesList', track), @@ -106,17 +123,35 @@ export default { artistCommentarySection: relation('generateTrackArtistCommentarySection', track), - creditSourceEntries: - track.creditSources - .map(entry => relation('generateCommentaryEntry', entry)), + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + track.creditingSources, + track), + + referencingSourcesSection: + relation('generateCollapsedContentEntrySection', + track.referencingSources, + track), }), - data: (_query, track) => ({ + data: (query, track) => ({ name: track.name, color: track.color, + + dateAlbumAddedToWiki: + track.album.dateAddedToWiki, + + needsLyrics: + track.needsLyrics, + + singleTrackSingle: + query.singleTrackSingle, + + firstTrackInSingle: + query.firstTrackInSingle, }), generate: (data, relations, {html, language}) => @@ -124,7 +159,7 @@ export default { relations.layout.slots({ title: language.$(pageCapsule, 'title', { - track: data.name, + track: relations.name, }), headingMode: 'sticky', @@ -132,7 +167,7 @@ export default { additionalNames: relations.additionalNamesBox, color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, artworkColumnContent: relations.artworkColumn, @@ -172,26 +207,35 @@ export default { language.$(capsule, 'link')), })), - !html.isBlank(relations.artistCommentarySection) && - language.encapsulate(capsule, 'readCommentary', capsule => + checkInterrupted('commentary', relations, {html}) && + relations.readCommentaryLine, + + !html.isBlank(relations.creditingSourcesSection) && + checkInterrupted('crediting-sources', relations, {html}) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#artist-commentary'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), - !html.isBlank(relations.creditSourceEntries) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !html.isBlank(relations.referencingSourcesSection) && + checkInterrupted('referencing-sources', relations, {html}) && + language.encapsulate(capsule, 'readReferencingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#referencing-sources'}, language.$(capsule, 'link')), })), ])), - relations.otherReleasesList, + html.tag('p', {[html.onlyIfContent]: true}, + relations.otherReleasesLine), + + html.tag('p', {[html.onlyIfContent]: true}, + relations.previousProductionLine), html.tags([ relations.contentHeading.clone() @@ -303,6 +347,25 @@ export default { relations.flashesThatFeatureList, ]), + data.firstTrackInSingle && + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.addedToWiki', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.dateAlbumAddedToWiki), + })), + + data.firstTrackInSingle && + (!html.isBlank(relations.lyricsSection) || + !html.isBlank(relations.artistCommentarySection)) && + html.tag('hr', {class: 'main-separator'}), + + data.needsLyrics && + html.isBlank(relations.lyricsSection) && + html.tag('p', + language.$(pageCapsule, 'needsLyrics')), + relations.lyricsSection, html.tags([ @@ -337,29 +400,40 @@ export default { relations.artistCommentarySection, - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'credit-sources'}, - title: language.$('misc.creditSources'), - }), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', + }), - relations.creditSourceEntries, - ]), + relations.referencingSourcesSection.slots({ + id: 'referencing-sources', + string: 'misc.referencingSources', + }), ], navLinkStyle: 'hierarchical', - navLinks: html.resolve(relations.navLinks), + navLinks: + (data.singleTrackSingle + ? [ + {auto: 'home'}, + { + html: relations.albumNavLink, + accent: language.$(pageCapsule, 'nav.singleAccent'), + }, + ] + : html.resolve(relations.navLinks)), navBottomRowContent: - relations.albumNavAccent.slots({ - showTrackNavigation: true, - showExtraLinks: false, - }), + (data.singleTrackSingle + ? null + : relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + })), secondaryNav: relations.secondaryNav - .slot('mode', 'track'), + .slot('mode', data.firstTrackInSingle ? 'album' : 'track'), leftSidebar: relations.sidebar, diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js index 61654512..cd7bb014 100644 --- a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js +++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js @@ -2,9 +2,6 @@ import {sortFlashesChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkFlash', 'linkTrack'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableFlashesAndGames: wikiInfo.enableFlashesAndGames, diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js new file mode 100644 index 00000000..1793b73f --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js @@ -0,0 +1,80 @@ +import {onlyItem, stitchArrays} from '#sugar'; + +export default { + query(track) { + const query = {}; + + query.singleSingle = + onlyItem( + track.otherReleases.filter(track => track.album.style === 'single')); + + query.regularReleases = + (query.singleSingle + ? track.otherReleases.filter(track => track !== query.singleSingle) + : track.otherReleases); + + return query; + }, + + relations: (relation, query, _track) => ({ + singleLink: + (query.singleSingle + ? relation('linkTrack', query.singleSingle) + : null), + + trackLinks: + query.regularReleases + .map(track => relation('linkTrack', track)), + }), + + data: (query, _track) => ({ + albumNames: + query.regularReleases + .map(track => track.album.name), + + albumColors: + query.regularReleases + .map(track => track.album.color), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo.alsoReleased', capsule => + language.encapsulate(capsule, workingCapsule => { + const workingOptions = {}; + + let any = false; + + const albumList = + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + albumColor: data.albumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))); + + if (!html.isBlank(albumList)) { + any = true; + workingCapsule += '.onAlbums'; + workingOptions.albums = albumList; + } + + if (relations.singleLink) { + any = true; + workingCapsule += '.asSingle'; + workingOptions.single = + relations.singleLink.slots({ + content: language.$(capsule, 'single'), + }); + } + + if (any) { + return language.$(workingCapsule, workingOptions); + } else { + return html.blank(); + } + })), +}; diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js deleted file mode 100644 index ebd76577..00000000 --- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js +++ /dev/null @@ -1,42 +0,0 @@ -import {stitchArrays} from '#sugar'; - -export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['html', 'language'], - - relations: (relation, track) => ({ - trackLinks: - track.otherReleases - .map(track => relation('linkTrack', track)), - }), - - data: (track) => ({ - albumNames: - track.otherReleases - .map(track => track.album.name), - - albumColors: - track.otherReleases - .map(track => track.album.color), - }), - - generate: (data, relations, {html, language}) => - html.tag('p', - {[html.onlyIfContent]: true}, - - language.$('releaseInfo.alsoReleasedOn', { - [language.onlyIfOptions]: ['albums'], - - albums: - language.formatConjunctionList( - stitchArrays({ - trackLink: relations.trackLinks, - albumName: data.albumNames, - albumColor: data.albumColors, - }).map(({trackLink, albumName, albumColor}) => - trackLink.slots({ - content: language.sanitize(albumName), - color: albumColor, - }))), - })), -}; diff --git a/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js new file mode 100644 index 00000000..f7f455c1 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js @@ -0,0 +1,38 @@ +import {stitchArrays} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +export default { + relations: (relation, track) => ({ + trackLinks: + track.followingProductionTracks + .map(track => relation('linkTrack', track)), + + albumLinks: + track.followingProductionTracks + .map(following => + (following.album !== track.album && + getKebabCase(following.name) === getKebabCase(track.name) + + ? relation('linkAlbum', following.album) + : null)), + }), + + generate: (relations, {language}) => + language.encapsulate('releaseInfo.previousProduction', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['tracks'], + + tracks: + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumLink: relations.albumLinks, + }).map(({trackLink, albumLink}) => + (albumLink + ? language.$(capsule, 'trackOnAlbum', { + track: trackLink, + album: albumLink, + }) + : trackLink))), + })), +}; diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index 53a32536..c259c914 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,14 +1,21 @@ export default { - contentDependencies: ['generateTrackListItem'], - extraDependencies: ['html'], - - relations: (relation, tracks) => ({ + relations: (relation, tracks, contextContributions) => ({ items: - tracks - .map(track => relation('generateTrackListItem', track, [])), + tracks.map(track => + relation('generateTrackListItem', track, contextContributions)), }), slots: { + showArtists: { + validate: v => v.is(true, false, 'auto'), + default: 'auto', + }, + + showDuration: { + type: 'boolean', + default: false, + }, + colorMode: { validate: v => v.is('none', 'track', 'line'), default: 'track', @@ -21,8 +28,8 @@ export default { relations.items.map(item => item.slots({ - showArtists: true, - showDuration: false, + showArtists: slots.showArtists, + showDuration: slots.showDuration, colorMode: slots.colorMode, }))), }; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js index 230868d6..419d7c0f 100644 --- a/src/content/dependencies/generateTrackListDividedByGroups.js +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -1,20 +1,12 @@ import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateTrackList', - 'linkGroup', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, }), - query(sprawl, tracks) { + query(sprawl, tracks, _contextTrack) { const dividingGroups = sprawl.divideTrackListsByGroups; const groupings = new Map(); @@ -50,10 +42,10 @@ export default { return {groups, groupedTracks, ungroupedTracks}; }, - relations: (relation, query, sprawl, tracks) => ({ + relations: (relation, query, sprawl, tracks, contextTrack) => ({ flatList: (empty(sprawl.divideTrackListsByGroups) - ? relation('generateTrackList', tracks) + ? relation('generateNearbyTrackList', tracks, contextTrack, []) : null), contentHeading: @@ -65,12 +57,12 @@ export default { groupedTrackLists: query.groupedTracks - .map(tracks => relation('generateTrackList', tracks)), + .map(tracks => relation('generateNearbyTrackList', tracks, contextTrack, [])), ungroupedTrackList: (empty(query.ungroupedTracks) ? null - : relation('generateTrackList', query.ungroupedTracks)), + : relation('generateNearbyTrackList', query.ungroupedTracks, contextTrack, [])), }), data: (query, _sprawl, _tracks) => ({ diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js index 3c850a18..c8c57534 100644 --- a/src/content/dependencies/generateTrackListItem.js +++ b/src/content/dependencies/generateTrackListItem.js @@ -1,21 +1,19 @@ export default { - contentDependencies: [ - 'generateArtistCredit', - 'generateColorStyleAttribute', - 'generateTrackListMissingDuration', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, track, contextContributions) => ({ trackLink: relation('linkTrack', track), - credit: + contextualCredit: + relation('generateArtistCredit', + track.artistContribs, + contextContributions, + track.artistTextInLists), + + acontextualCredit: relation('generateArtistCredit', track.artistContribs, - contextContributions), + [], + track.artistTextInLists), colorStyle: relation('generateColorStyleAttribute', track.color), @@ -35,12 +33,11 @@ export default { }), slots: { - // showArtists enables showing artists *at all.* It doesn't take precedence - // over behavior which automatically collapses (certain) artists because of - // provided context contributions. + // true always shows artists, false never does; 'auto' shows only if + // the track's artists differ from the given context contributions. showArtists: { - type: 'boolean', - default: true, + validate: v => v.is(true, false, 'auto'), + default: 'auto', }, // If true and the track doesn't have a duration, a missing-duration cue @@ -80,26 +77,33 @@ export default { : relations.missingDuration); } - const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); - - relations.credit.setSlots({ - normalStringKey: - artistCapsule + '.by', - - featuringStringKey: - artistCapsule + '.featuring', - - normalFeaturingStringKey: - artistCapsule + '.by.featuring', - }); - - if (!html.isBlank(relations.credit)) { - workingCapsule += '.withArtists'; - workingOptions.by = - html.tag('span', {class: 'by'}, - // TODO: This is obviously evil. - html.metatag('chunkwrap', {split: /,| (?=and)/}, - html.resolve(relations.credit))); + const chosenCredit = + (slots.showArtists === true + ? relations.acontextualCredit + : slots.showArtists === 'auto' + ? relations.contextualCredit + : null); + + if (chosenCredit) { + const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); + + chosenCredit.setSlots({ + normalStringKey: + artistCapsule + '.by', + + featuringStringKey: + artistCapsule + '.featuring', + + normalFeaturingStringKey: + artistCapsule + '.by.featuring', + }); + + if (!html.isBlank(chosenCredit)) { + workingCapsule += '.withArtists'; + workingOptions.by = + html.tag('span', {class: 'by'}, + chosenCredit); + } } return language.$(workingCapsule, workingOptions); diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js index b5917982..da3113a2 100644 --- a/src/content/dependencies/generateTrackListMissingDuration.js +++ b/src/content/dependencies/generateTrackListMissingDuration.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ textWithTooltip: relation('generateTextWithTooltip'), diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js index 6a8b7c64..d18e6cad 100644 --- a/src/content/dependencies/generateTrackNavLinks.js +++ b/src/content/dependencies/generateTrackNavLinks.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkAlbum', 'linkTrack'], - extraDependencies: ['html', 'language'], - relations: (relation, track) => ({ albumLink: relation('linkAlbum', track.album), @@ -11,6 +8,9 @@ export default { }), data: (track) => ({ + albumStyle: + track.album.style, + hasTrackNumbers: track.album.hasTrackNumbers, @@ -28,7 +28,13 @@ export default { language.encapsulate('trackPage.nav', navCapsule => [ {auto: 'home'}, - {html: relations.albumLink.slot('color', false)}, + { + html: relations.albumLink.slot('color', false), + accent: + (data.albumStyle === 'single' + ? language.$(navCapsule, 'singleAccent') + : null), + }, { html: diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js index 93438c5b..a2612067 100644 --- a/src/content/dependencies/generateTrackReferencedArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js @@ -1,19 +1,10 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToTrackLink', - 'generateReferencedArtworksPage', - 'generateTrackNavLinks', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, track) => ({ page: relation('generateReferencedArtworksPage', track.trackArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), navLinks: relation('generateTrackNavLinks', track), @@ -35,7 +26,7 @@ export default { data.name, }), - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, navLinks: html.resolve( diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js index e9818bad..be13dd79 100644 --- a/src/content/dependencies/generateTrackReferencingArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js @@ -1,19 +1,10 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToTrackLink', - 'generateReferencingArtworksPage', - 'generateTrackNavLinks', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, track) => ({ page: relation('generateReferencingArtworksPage', track.trackArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), navLinks: relation('generateTrackNavLinks', track), @@ -35,7 +26,7 @@ export default { data.name, }), - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, navLinks: html.resolve( diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js index ef02e2b9..c880fe63 100644 --- a/src/content/dependencies/generateTrackReleaseBox.js +++ b/src/content/dependencies/generateTrackReleaseBox.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generatePageSidebarBox', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, track) => ({ box: relation('generatePageSidebarBox'), diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js index 54e462c7..0207e574 100644 --- a/src/content/dependencies/generateTrackReleaseInfo.js +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -1,24 +1,19 @@ -import {empty} from '#sugar'; +import {compareArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateReleaseInfoContributionsLine', - 'linkExternal', - ], - - extraDependencies: ['html', 'language'], - relations(relation, track) { const relations = {}; - relations.artistContributionLinks = - relation('generateReleaseInfoContributionsLine', track.artistContribs); + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', + track.artistContribs, + track.artistText); - if (!empty(track.urls)) { - relations.externalLinks = - track.urls.map(url => - relation('linkExternal', url)); - } + relations.listenLine = + relation('generateReleaseInfoListenLine', track); + + relations.albumLink = + relation('linkAlbum', track.album); return relations; }, @@ -30,6 +25,16 @@ export default { data.date = track.date; data.duration = track.duration; + const {album} = track; + + data.showAlbum = + album.showAlbumInTracksWithoutArtists && + track.artistContribs.every(({annotation}) => !annotation) && + compareArrays( + track.artistContribs.map(({artist}) => artist), + album.artistContribs.map(({artist}) => artist), + {checkOrder: true}); + if ( track.hasUniqueCoverArt && +track.coverArtDate !== +track.date @@ -48,10 +53,21 @@ export default { {[html.joinChildren]: html.tag('br')}, [ - relations.artistContributionLinks.slots({ - stringKey: capsule + '.by', - featuringStringKey: capsule + '.by.featuring', - chronologyKind: 'track', + language.encapsulate(capsule, 'by', capsule => { + const withAlbum = + (data.showAlbum ? '.withAlbum' : ''); + + const albumOptions = + (data.showAlbum ? {album: relations.albumLink} : {}); + + return relations.artistContributionsLine.slots({ + stringKey: capsule + withAlbum, + featuringStringKey: capsule + '.featuring' + withAlbum, + + additionalStringOptions: albumOptions, + + chronologyKind: 'track', + }); }), language.$(capsule, 'released', { @@ -66,17 +82,9 @@ export default { ]), html.tag('p', - language.encapsulate(capsule, 'listenOn', capsule => - (relations.externalLinks - ? language.$(capsule, { - links: - language.formatDisjunctionList( - relations.externalLinks - .map(link => link.slot('context', 'track'))), - }) - : language.$(capsule, 'noLinks', { - name: - html.tag('i', data.name), - })))), + relations.listenLine.slots({ + visibleWithoutLinks: true, + context: ['track'], + })), ])), }; diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js index 310816f3..94453f7d 100644 --- a/src/content/dependencies/generateTrackSocialEmbed.js +++ b/src/content/dependencies/generateTrackSocialEmbed.js @@ -1,11 +1,4 @@ export default { - contentDependencies: [ - 'generateSocialEmbed', - 'generateTrackSocialEmbedDescription', - ], - - extraDependencies: ['absoluteTo', 'language'], - relations(relation, track) { return { socialEmbed: diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js index 4706aa26..97a4017f 100644 --- a/src/content/dependencies/generateTrackSocialEmbedDescription.js +++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js @@ -1,8 +1,6 @@ import {empty} from '#sugar'; export default { - extraDependencies: ['html', 'language'], - data: (track) => ({ artistNames: track.artistContribs diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js index c11aadc7..df8231ef 100644 --- a/src/content/dependencies/generateUnsafeMunchy.js +++ b/src/content/dependencies/generateUnsafeMunchy.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { contentSource: {type: 'string'}, }, diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js new file mode 100644 index 00000000..b89f01c2 --- /dev/null +++ b/src/content/dependencies/generateWallpaperStyleTag.js @@ -0,0 +1,77 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + }), + + slots: { + singleWallpaperPath: { + validate: v => v.strictArrayOf(v.isString), + }, + + singleWallpaperStyle: { + validate: v => v.isString, + }, + + wallpaperPartPaths: { + validate: v => + v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))), + }, + + wallpaperPartStyles: { + validate: v => + v.strictArrayOf(v.optional(v.isString)), + }, + }, + + generate(relations, slots, {html, to}) { + const attributes = html.attributes(); + const rules = []; + + attributes.add('class', 'wallpaper-style'); + + if (empty(slots.wallpaperPartPaths)) { + attributes.set('data-wallpaper-mode', 'one'); + + rules.push({ + select: 'body::before', + declare: [ + `background-image: url("${to(...slots.singleWallpaperPath)}");`, + slots.singleWallpaperStyle, + ], + }); + } else { + attributes.set('data-wallpaper-mode', 'parts'); + attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length); + + stitchArrays({ + path: slots.wallpaperPartPaths, + style: slots.wallpaperPartStyles, + }).forEach(({path, style}, index) => { + rules.push({ + select: `.wallpaper-part:nth-child(${index + 1})`, + declare: [ + path && `background-image: url("${to(...path)}");`, + style, + ], + }); + }); + + rules.push({ + select: 'body::before', + declare: [ + 'display: none;', + ], + }); + } + + relations.styleTag.setSlots({ + attributes, + rules, + }); + + return relations.styleTag; + }, +}; diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js index 9f501099..5e3ff381 100644 --- a/src/content/dependencies/generateWikiHomepageActionsRow.js +++ b/src/content/dependencies/generateWikiHomepageActionsRow.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateGridActionLinks', 'transformContent'], - relations: (relation, row) => ({ template: relation('generateGridActionLinks'), diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js index b45bfc19..8f4b3400 100644 --- a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js +++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'], - relations: (relation, row) => ({ coverCarousel: relation('generateCoverCarousel'), diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js index a00136ba..eb3417d7 100644 --- a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js +++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js @@ -2,9 +2,6 @@ import {empty, stitchArrays} from '#sugar'; import {getNewAdditions, getNewReleases} from '#wiki-data'; export default { - contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}, row) { const sprawl = {}; @@ -21,8 +18,7 @@ export default { sprawl.albums = (row.sourceGroup ? row.sourceGroup.albums - .slice() - .reverse() + .toReversed() .filter(album => album.isListedOnHomepage) .slice(0, row.countAlbumsFromGroup) : []); diff --git a/src/content/dependencies/generateWikiHomepageNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js index 83a27695..3a06a7c3 100644 --- a/src/content/dependencies/generateWikiHomepageNewsBox.js +++ b/src/content/dependencies/generateWikiHomepageNewsBox.js @@ -1,14 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkNewsEntry', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({newsData}) => ({ entries: newsData.slice(0, 3), diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js index 8c09a007..9029131b 100644 --- a/src/content/dependencies/generateWikiHomepagePage.js +++ b/src/content/dependencies/generateWikiHomepagePage.js @@ -1,15 +1,4 @@ export default { - contentDependencies: [ - 'generatePageLayout', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'generateWikiHomepageNewsBox', - 'generateWikiHomepageSection', - 'transformContent', - ], - - extraDependencies: ['wikiData'], - sprawl: ({wikiInfo}) => ({ wikiName: wikiInfo.name, diff --git a/src/content/dependencies/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js index 49a474da..5fc0c76f 100644 --- a/src/content/dependencies/generateWikiHomepageSection.js +++ b/src/content/dependencies/generateWikiHomepageSection.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateWikiHomepageActionsRow', - 'generateWikiHomepageAlbumCarouselRow', - 'generateWikiHomepageAlbumGridRow', - ], - - extraDependencies: ['html'], - relations: (relation, homepageSection) => ({ colorStyle: relation('generateColorStyleAttribute', homepageSection.color), diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js new file mode 100644 index 00000000..be52bcc1 --- /dev/null +++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js @@ -0,0 +1,35 @@ +export default { + sprawl: ({wikiInfo}) => ({wikiInfo}), + + relations: (relation) => ({ + wallpaperStyleTag: + relation('generateWallpaperStyleTag'), + }), + + data: ({wikiInfo}) => ({ + singleWallpaperPath: [ + 'media.path', + 'bg.' + wikiInfo.wikiWallpaperFileExtension, + ], + + singleWallpaperStyle: + wikiInfo.wikiWallpaperStyle, + + wallpaperPartPaths: + wikiInfo.wikiWallpaperParts.map(part => + (part.asset + ? ['media.path', part.asset] + : null)), + + wallpaperPartStyles: + wikiInfo.wikiWallpaperParts.map(part => part.style), + }), + + generate: (data, relations) => + relations.wallpaperStyleTag.slots({ + singleWallpaperPath: data.singleWallpaperPath, + singleWallpaperStyle: data.singleWallpaperStyle, + wallpaperPartPaths: data.wallpaperPartPaths, + wallpaperPartStyles: data.wallpaperPartStyles, + }), +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index bf47b14f..aacf2fed 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -2,20 +2,6 @@ import {logWarn} from '#cli'; import {empty} from '#sugar'; export default { - extraDependencies: [ - 'checkIfImagePathHasCachedThumbnails', - 'getDimensionsOfImagePath', - 'getSizeOfMediaFile', - 'getThumbnailEqualOrSmaller', - 'getThumbnailsAvailableForDimensions', - 'html', - 'language', - 'missingImagePaths', - 'to', - ], - - contentDependencies: ['generateColorStyleAttribute'], - relations: (relation, _artwork) => ({ colorStyle: relation('generateColorStyleAttribute'), @@ -42,6 +28,8 @@ export default { slots: { thumb: {type: 'string'}, + responsiveThumb: {type: 'boolean', default: false}, + responsiveSizes: {type: 'string'}, reveal: {type: 'boolean', default: true}, lazy: {type: 'boolean', default: false}, @@ -60,6 +48,12 @@ export default { mutable: false, }, + // Added to the <img>. + imgAttributes: { + type: 'attributes', + mutable: false, + }, + // Added to the <img> itself. alt: {type: 'string'}, @@ -114,12 +108,11 @@ export default { // src string directly when a parts-formed path *is* available seems wrong. // It should be possible to do urls.from(slots.path[0]).to(...slots.path), // for example, but will require reworking the control flow here a little. - let mediaSrc = null; + let mediaSrc = decodeURIComponent(originalSrc); if (originalSrc.startsWith(to('media.root'))) { - mediaSrc = - originalSrc - .slice(to('media.root').length) - .replace(/^\//, ''); + mediaSrc = mediaSrc + .slice(to('media.root').length) + .replace(/^\//, ''); } const isMissingImageFile = @@ -141,6 +134,8 @@ export default { const imgAttributes = html.attributes([ {class: 'image'}, + slots.imgAttributes, + slots.alt && {alt: slots.alt}, dimensions && @@ -205,31 +200,29 @@ export default { // so it won't be set if thumbnails aren't available. let revealSrc = null; + let originalDimensions; + let availableThumbs; + let selectedThumbtack; + + const getThumbSrc = (thumbtack) => + to('thumb.path', mediaSrc.replace(/\.(png|jpg)$/, `.${thumbtack}.jpg`)); + // If thumbnails are available *and* being used, calculate thumbSrc, // and provide some attributes relevant to the large image overlay. if (hasThumbnails && slots.thumb) { - const selectedSize = + selectedThumbtack = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc); - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); - displaySrc = - to('thumb.path', mediaSrcJpeg); + getThumbSrc(selectedThumbtack); if (willReveal) { - const miniSize = - getThumbnailEqualOrSmaller('mini', mediaSrc); - - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`); - revealSrc = - to('thumb.path', mediaSrcJpeg); + getThumbSrc(getThumbnailEqualOrSmaller('mini', mediaSrc)); } - const originalDimensions = getDimensionsOfImagePath(mediaSrc); - const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); + originalDimensions = getDimensionsOfImagePath(mediaSrc); + availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); const fileSize = (willLink && mediaSrc @@ -245,11 +238,54 @@ export default { !empty(availableThumbs) && {'data-thumbs': availableThumbs - .map(([name, size]) => `${name}:${size}`) + .map(([tack, size]) => `${tack}:${size}`) .join(' ')}, ]); } + let displayStaticImg = + html.tag('img', + imgAttributes, + {src: displaySrc}); + + if (hasThumbnails && slots.responsiveThumb) responsive: { + if (slots.lazy) { + logWarn`${'responsiveThumb'} and ${'lazy'} are used together, but not compatible`; + break responsive; + } + + if (!slots.thumb) { + logWarn`${'responsiveThumb'} must be used alongside a default ${'thumb'}`; + break responsive; + } + + const srcset = [ + // Never load the original source, which might be a very large + // uncompressed file. Bah! + /* [originalSrc, `${Math.min(...originalDimensions)}w`], */ + + ...availableThumbs.map(([tack, size]) => + [getThumbSrc(tack), `${Math.floor(0.95 * size)}w`]), + + // fallback + [displaySrc], + ].map(line => line.join(' ')).join(',\n'); + + displayStaticImg = + html.tag('img', + imgAttributes, + + {sizes: + (slots.responsiveSizes.match(/(?=(?:,|^))\s*\S/) + // slot provided fallback size + ? slots.responsiveSizes + // default fallback size + : slots.responsiveSizes + ',\n' + + new Map(availableThumbs).get(selectedThumbtack) + 'px')}, + + {srcset}); + } + if (!displaySrc) { return ( prepare( @@ -258,10 +294,7 @@ export default { } const images = { - displayStatic: - html.tag('img', - imgAttributes, - {src: displaySrc}), + displayStatic: displayStaticImg, displayLazy: slots.lazy && diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index a5009804..cfa6346c 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli'; import contentFunction, {ContentFunctionSpecError} from '#content-function'; import {annotateFunction} from '#sugar'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const codeSrcPath = path.resolve(__dirname, '..'); +const codeRootPath = path.resolve(codeSrcPath, '..'); + function cachebust(filePath) { if (filePath in cachebust.cache) { cachebust.cache[filePath] += 1; @@ -42,7 +47,9 @@ export function watchContentDependencies({ close, }); - const eslint = new ESLint(); + const eslint = new ESLint({ + cwd: codeRootPath, + }); const metaPath = fileURLToPath(import.meta.url); const metaDirname = path.dirname(metaPath); @@ -87,6 +94,8 @@ export function watchContentDependencies({ const filePaths = files.map(file => path.join(watchPath, file)); for (const filePath of filePaths) { if (filePath === metaPath) continue; + if (filePath.endsWith('.DS_Store')) continue; + const functionName = getFunctionName(filePath); if (!isMocked(functionName)) { contentDependencies[functionName] = null; @@ -98,8 +107,9 @@ export function watchContentDependencies({ watcher.on('all', (event, filePath) => { if (!['add', 'change'].includes(event)) return; if (filePath === metaPath) return; - handlePathUpdated(filePath); + if (filePath.endsWith('.DS_Store')) return; + handlePathUpdated(filePath); }); watcher.on('unlink', (filePath) => { @@ -108,6 +118,8 @@ export function watchContentDependencies({ return; } + if (filePath.endsWith('.DS_Store')) return; + handlePathRemoved(filePath); }); diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js index a8a940b1..1b5e650f 100644 --- a/src/content/dependencies/linkAdditionalFile.js +++ b/src/content/dependencies/linkAdditionalFile.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - query: (file, filename) => ({ index: file.filenames.indexOf(filename), diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js index 36b0d13a..085d5f62 100644 --- a/src/content/dependencies/linkAlbum.js +++ b/src/content/dependencies/linkAlbum.js @@ -1,8 +1,18 @@ export default { - contentDependencies: ['linkThing'], + relations: (relation, album) => ({ + link: + (album.style === 'single' + ? relation('linkTrack', album.tracks[0]) + : relation('linkThing', 'localized.album', album)), + }), - relations: (relation, album) => - ({link: relation('linkThing', 'localized.album', album)}), + data: (album) => ({ + style: album.style, + name: album.name, + }), - generate: (relations) => relations.link, + generate: (data, relations, {language}) => + (data.style === 'single' + ? relations.link.slot('content', language.sanitize(data.name)) + : relations.link), }; diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js index ab519fd6..f1917345 100644 --- a/src/content/dependencies/linkAlbumCommentary.js +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumCommentary', album)}), diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js index 45f8c2a9..ba572c8d 100644 --- a/src/content/dependencies/linkAlbumDynamically.js +++ b/src/content/dependencies/linkAlbumDynamically.js @@ -1,14 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'linkAlbumCommentary', - 'linkAlbumGallery', - 'linkAlbum', - ], - - extraDependencies: ['html', 'pagePath'], - relations: (relation, album) => ({ galleryLink: relation('linkAlbumGallery', album), diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js index e3f30a29..efba66d1 100644 --- a/src/content/dependencies/linkAlbumGallery.js +++ b/src/content/dependencies/linkAlbumGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumGallery', album)}), diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js index ba51b5e3..411bd2ab 100644 --- a/src/content/dependencies/linkAlbumReferencedArtworks.js +++ b/src/content/dependencies/linkAlbumReferencedArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}), diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js index 4d5e799d..3aee9a4b 100644 --- a/src/content/dependencies/linkAlbumReferencingArtworks.js +++ b/src/content/dependencies/linkAlbumReferencingArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}), diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js index e408c1b2..cb22baee 100644 --- a/src/content/dependencies/linkAnythingMan.js +++ b/src/content/dependencies/linkAnythingMan.js @@ -1,24 +1,13 @@ export default { - contentDependencies: [ - 'linkAlbum', - 'linkArtwork', - 'linkFlash', - 'linkTrack', - ], - - query: (thing) => ({ - referenceType: thing.constructor[Symbol.for('Thing.referenceType')], - }), - - relations: (relation, query, thing) => ({ + relations: (relation, thing) => ({ link: - (query.referenceType === 'album' + (thing.isAlbum ? relation('linkAlbum', thing) - : query.referenceType === 'artwork' + : thing.isArtwork ? relation('linkArtwork', thing) - : query.referenceType === 'flash' + : thing.isFlash ? relation('linkFlash', thing) - : query.referenceType === 'track' + : thing.isTrack ? relation('linkTrack', thing) : null), }), diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js index 964258e1..4514b7c1 100644 --- a/src/content/dependencies/linkArtTagDynamically.js +++ b/src/content/dependencies/linkArtTagDynamically.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'], - extraDependencies: ['pagePath'], - relations: (relation, artTag) => ({ galleryLink: relation('linkArtTagGallery', artTag), infoLink: relation('linkArtTagInfo', artTag), diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js index a92b69c1..92ab1ed3 100644 --- a/src/content/dependencies/linkArtTagGallery.js +++ b/src/content/dependencies/linkArtTagGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artTag) => ({link: relation('linkThing', 'localized.artTagGallery', artTag)}), diff --git a/src/content/dependencies/linkArtTagInfo.js b/src/content/dependencies/linkArtTagInfo.js index 409cb3c0..5eb2ac56 100644 --- a/src/content/dependencies/linkArtTagInfo.js +++ b/src/content/dependencies/linkArtTagInfo.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artTag) => ({link: relation('linkThing', 'localized.artTagInfo', artTag)}), diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js index 718ee6fa..917ae6b6 100644 --- a/src/content/dependencies/linkArtist.js +++ b/src/content/dependencies/linkArtist.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artist) => ({link: relation('linkThing', 'localized.artist', artist)}), diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js index 66dc172d..001eec1f 100644 --- a/src/content/dependencies/linkArtistGallery.js +++ b/src/content/dependencies/linkArtistGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artist) => ({link: relation('linkThing', 'localized.artistGallery', artist)}), diff --git a/src/content/dependencies/linkArtistRollingWindow.js b/src/content/dependencies/linkArtistRollingWindow.js new file mode 100644 index 00000000..6ab516ac --- /dev/null +++ b/src/content/dependencies/linkArtistRollingWindow.js @@ -0,0 +1,6 @@ +export default { + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistRollingWindow', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js index 8cd6f359..fce89229 100644 --- a/src/content/dependencies/linkArtwork.js +++ b/src/content/dependencies/linkArtwork.js @@ -1,16 +1,9 @@ export default { - contentDependencies: ['linkAlbum', 'linkTrack'], - - query: (artwork) => ({ - referenceType: - artwork.thing.constructor[Symbol.for('Thing.referenceType')], - }), - - relations: (relation, query, artwork) => ({ + relations: (relation, artwork) => ({ link: - (query.referenceType === 'album' + (artwork.thing.isAlbum ? relation('linkAlbum', artwork.thing) - : query.referenceType === 'track' + : artwork.thing.isTrack ? relation('linkTrack', artwork.thing) : null), }), diff --git a/src/content/dependencies/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js index 5568ff84..e59b3641 100644 --- a/src/content/dependencies/linkCommentaryIndex.js +++ b/src/content/dependencies/linkCommentaryIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index c658d461..aa9bdef9 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateContributionTooltip', - 'generateTextWithTooltip', - 'linkArtist', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contribution) => ({ artistLink: relation('linkArtist', contribution.artist), @@ -24,13 +16,15 @@ export default { }), slots: { + content: {type: 'html', mutable: false}, + showAnnotation: {type: 'boolean', default: false}, showExternalLinks: {type: 'boolean', default: false}, showChronology: {type: 'boolean', default: false}, trimAnnotation: {type: 'boolean', default: false}, - preventWrapping: {type: 'boolean', default: true}, + preventWrapping: {type: 'boolean', default: false}, preventTooltip: {type: 'boolean', default: false}, chronologyKind: {type: 'string'}, @@ -46,6 +40,10 @@ export default { language.encapsulate('misc.artistLink', workingCapsule => { const workingOptions = {}; + if (!html.isBlank(slots.content)) { + relations.artistLink.setSlot('content', slots.content); + } + // Filling slots early is necessary to actually give the tooltip // content. Otherwise, the coming-up html.isBlank() always reports // the tooltip as blank! diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index c132baaf..ad8d4f23 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,9 +1,20 @@ import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; export default { - extraDependencies: ['html', 'language', 'wikiData'], + sprawl: ({wikiInfo}) => ({ + canonicalBase: + wikiInfo.canonicalBase, - data: (url) => ({url}), + canonicalMediaBase: + wikiInfo.canonicalMediaBase, + }), + + data: (sprawl, url) => ({ + url, + + canonicalBase: + sprawl.canonicalBase, + }), slots: { content: { @@ -50,19 +61,33 @@ export default { }, }, - generate(data, slots, {html, language}) { + generate(data, slots, {html, language, to}) { + const {url} = data; + let urlIsValid; try { - new URL(data.url); + new URL(url); urlIsValid = true; - } catch (error) { + } catch { urlIsValid = false; } + let href; + if (urlIsValid) { + const {canonicalBase, canonicalMediaBase} = data; + if (canonicalMediaBase && url.startsWith(canonicalMediaBase)) { + href = to('media.path', url.slice(canonicalMediaBase.length)); + } else if (canonicalBase && url.startsWith(canonicalBase)) { + href = to('shared.path', url.slice(canonicalBase.length)); + } else { + href = url; + } + } + let formattedLink; if (urlIsValid) { formattedLink = - language.formatExternalLink(data.url, { + language.formatExternalLink(url, { style: slots.style, context: slots.context, }); @@ -70,7 +95,7 @@ export default { // Fall back to platform if nothing matched the desired style. if (html.isBlank(formattedLink) && slots.style !== 'platform') { formattedLink = - language.formatExternalLink(data.url, { + language.formatExternalLink(url, { style: 'platform', context: slots.context, }); @@ -85,7 +110,7 @@ export default { let linkContent; if (urlIsValid) { - linkAttributes.set('href', data.url); + linkAttributes.set('href', href); if (html.isBlank(slots.content)) { linkContent = formattedLink; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js index 93dd5a28..cfc01079 100644 --- a/src/content/dependencies/linkFlash.js +++ b/src/content/dependencies/linkFlash.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, flash) => ({link: relation('linkThing', 'localized.flash', flash)}), diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js index 82c23325..069bedf4 100644 --- a/src/content/dependencies/linkFlashAct.js +++ b/src/content/dependencies/linkFlashAct.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateUnsafeMunchy', 'linkThing'], - relations: (relation, flashAct) => ({ unsafeMunchy: relation('generateUnsafeMunchy'), diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js index 6dd0710e..9c1b076e 100644 --- a/src/content/dependencies/linkFlashIndex.js +++ b/src/content/dependencies/linkFlashIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js index b77ca65a..6407ef25 100644 --- a/src/content/dependencies/linkFlashSide.js +++ b/src/content/dependencies/linkFlashSide.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkFlashAct'], - relations: (relation, flashSide) => ({ link: relation('linkFlashAct', flashSide.acts[0]), diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js index ebab1b5b..10bec2fb 100644 --- a/src/content/dependencies/linkGroup.js +++ b/src/content/dependencies/linkGroup.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, group) => ({link: relation('linkThing', 'localized.groupInfo', group)}), diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js index 90303ed1..0b5bd85c 100644 --- a/src/content/dependencies/linkGroupDynamically.js +++ b/src/content/dependencies/linkGroupDynamically.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkGroupGallery', 'linkGroup'], - extraDependencies: ['pagePath'], - relations: (relation, group) => ({ galleryLink: relation('linkGroupGallery', group), infoLink: relation('linkGroup', group), diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js index bc3c0580..1a6161c1 100644 --- a/src/content/dependencies/linkGroupExtra.js +++ b/src/content/dependencies/linkGroupExtra.js @@ -1,13 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html'], - relations(relation, group) { const relations = {}; diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js index 86c4a0f3..957756d8 100644 --- a/src/content/dependencies/linkGroupGallery.js +++ b/src/content/dependencies/linkGroupGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, group) => ({link: relation('linkThing', 'localized.groupGallery', group)}), diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js index ac66919a..4eb2dce6 100644 --- a/src/content/dependencies/linkListing.js +++ b/src/content/dependencies/linkListing.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkThing'], - extraDependencies: ['language'], - relations: (relation, listing) => ({link: relation('linkThing', 'localized.listing', listing)}), diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js index 1bfaf46e..209066a9 100644 --- a/src/content/dependencies/linkListingIndex.js +++ b/src/content/dependencies/linkListingIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js index 1fb32dd9..9ef7ac0e 100644 --- a/src/content/dependencies/linkNewsEntry.js +++ b/src/content/dependencies/linkNewsEntry.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, newsEntry) => ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), diff --git a/src/content/dependencies/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js index e911a384..4414afc6 100644 --- a/src/content/dependencies/linkNewsIndex.js +++ b/src/content/dependencies/linkNewsIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js index ec856631..5a16256e 100644 --- a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js +++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js @@ -3,9 +3,6 @@ import {sortAlbumsTracksChronologically, sortContributionsChronologically} import {chunkArtistTrackContributions} from '#wiki-data'; export default { - contentDependencies: ['generateColorStyleAttribute'], - extraDependencies: ['html', 'language'], - query(track, artist) { const relevantInfoPageChunkingContributions = track.allReleases diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js index d71c69f8..344b7d2c 100644 --- a/src/content/dependencies/linkPathFromMedia.js +++ b/src/content/dependencies/linkPathFromMedia.js @@ -1,17 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: ['linkTemplate'], - - extraDependencies: [ - 'checkIfImagePathHasCachedThumbnails', - 'getDimensionsOfImagePath', - 'getSizeOfMediaFile', - 'getThumbnailsAvailableForDimensions', - 'html', - 'to', - ], - relations: (relation) => ({link: relation('linkTemplate')}), diff --git a/src/content/dependencies/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js index dab3ac1f..b4a90c07 100644 --- a/src/content/dependencies/linkPathFromRoot.js +++ b/src/content/dependencies/linkPathFromRoot.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - relations: (relation) => ({link: relation('linkTemplate')}), diff --git a/src/content/dependencies/linkPathFromSite.js b/src/content/dependencies/linkPathFromSite.js index 64676465..67a43059 100644 --- a/src/content/dependencies/linkPathFromSite.js +++ b/src/content/dependencies/linkPathFromSite.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - relations: (relation) => ({link: relation('linkTemplate')}), diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js index c456b808..f8b3f3c8 100644 --- a/src/content/dependencies/linkReferencedArtworks.js +++ b/src/content/dependencies/linkReferencedArtworks.js @@ -1,21 +1,9 @@ -import Thing from '#thing'; - export default { - contentDependencies: [ - 'linkAlbumReferencedArtworks', - 'linkTrackReferencedArtworks', - ], - - query: (artwork) => ({ - referenceType: - artwork.thing.constructor[Thing.referenceType], - }), - - relations: (relation, query, artwork) => ({ + relations: (relation, artwork) => ({ link: - (query.referenceType === 'album' + (artwork.thing.isAlbum ? relation('linkAlbumReferencedArtworks', artwork.thing) - : query.referenceType === 'track' + : artwork.thing.isTrack ? relation('linkTrackReferencedArtworks', artwork.thing) : null), }), diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js index 0cfca4db..6b7e4f9a 100644 --- a/src/content/dependencies/linkReferencingArtworks.js +++ b/src/content/dependencies/linkReferencingArtworks.js @@ -1,21 +1,9 @@ -import Thing from '#thing'; - export default { - contentDependencies: [ - 'linkAlbumReferencingArtworks', - 'linkTrackReferencingArtworks', - ], - - query: (artwork) => ({ - referenceType: - artwork.thing.constructor[Thing.referenceType], - }), - - relations: (relation, query, artwork) => ({ + relations: (relation, artwork) => ({ link: - (query.referenceType === 'album' + (artwork.thing.isAlbum ? relation('linkAlbumReferencingArtworks', artwork.thing) - : query.referenceType === 'track' + : artwork.thing.isTrack ? relation('linkTrackReferencingArtworks', artwork.thing) : null), }), diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js index 032af6c9..c3ac69fa 100644 --- a/src/content/dependencies/linkStaticPage.js +++ b/src/content/dependencies/linkStaticPage.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, staticPage) => ({link: relation('linkThing', 'localized.staticPage', staticPage)}), diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js index d5506e60..10f8ba44 100644 --- a/src/content/dependencies/linkStationaryIndex.js +++ b/src/content/dependencies/linkStationaryIndex.js @@ -1,9 +1,6 @@ // Not to be confused with "html.Stationery". export default { - contentDependencies: ['linkTemplate'], - extraDependencies: ['language'], - relations(relation) { return { linkTemplate: relation('linkTemplate'), diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index 4f853dc4..10466b43 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -3,13 +3,6 @@ import {empty} from '#sugar'; import striptags from 'striptags'; export default { - extraDependencies: [ - 'appendIndexHTML', - 'html', - 'language', - 'to', - ], - slots: { href: {type: 'string'}, path: {validate: v => v.validateArrayItems(v.isString)}, diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js index 3902f380..166a857d 100644 --- a/src/content/dependencies/linkThing.js +++ b/src/content/dependencies/linkThing.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateTextWithTooltip', - 'generateTooltip', - 'linkTemplate', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, _pathKey, thing) => ({ linkTemplate: relation('linkTemplate'), @@ -20,11 +11,15 @@ export default { tooltip: relation('generateTooltip'), + + name: + relation('generateName', thing), }), data: (pathKey, thing) => ({ name: thing.name, nameShort: thing.nameShort ?? thing.shortName, + nameText: thing.nameText, path: (pathKey @@ -75,22 +70,21 @@ export default { hash: {type: 'string'}, }, - generate(data, relations, slots, {html, language}) { + generate(data, relations, slots, {html}) { const path = slots.path ?? data.path; const linkAttributes = slots.attributes; const wrapperAttributes = html.attributes(); - const showShortName = - (slots.preferShortName - ? data.nameShort && data.nameShort !== data.name - : false); - const name = - (showShortName - ? data.nameShort - : data.name); + relations.name.slot('preferShortName', slots.preferShortName); + + const showShortName = + slots.preferShortName && + !data.nameText && + data.nameShort && + data.nameShort !== data.name; const showWikiTooltip = (slots.tooltipStyle === 'auto' @@ -114,7 +108,7 @@ export default { const content = (html.isBlank(slots.content) - ? language.sanitize(name) + ? name : slots.content); if (slots.color !== false) { diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js index d5d96726..8ee715f0 100644 --- a/src/content/dependencies/linkTrack.js +++ b/src/content/dependencies/linkTrack.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.track', track)}), diff --git a/src/content/dependencies/linkTrackAsRelease.js b/src/content/dependencies/linkTrackAsRelease.js new file mode 100644 index 00000000..7a114ad9 --- /dev/null +++ b/src/content/dependencies/linkTrackAsRelease.js @@ -0,0 +1,20 @@ +export default { + relations: (relation, track) => ({ + trackLink: + relation('linkTrack', track), + }), + + data: (track) => ({ + albumName: + track.album.name, + + albumColor: + track.album.color, + }), + + generate: (data, relations, {language}) => + relations.trackLink.slots({ + content: language.sanitize(data.albumName), + color: data.albumColor, + }), +}; diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js index bbcf1c34..088bbe09 100644 --- a/src/content/dependencies/linkTrackDynamically.js +++ b/src/content/dependencies/linkTrackDynamically.js @@ -1,9 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['pagePath'], - relations: (relation, track) => ({ infoLink: relation('linkTrack', track), }), diff --git a/src/content/dependencies/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js index b4cb08fe..6da6504e 100644 --- a/src/content/dependencies/linkTrackReferencedArtworks.js +++ b/src/content/dependencies/linkTrackReferencedArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}), diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js index c9c9f4d1..4d113ba7 100644 --- a/src/content/dependencies/linkTrackReferencingArtworks.js +++ b/src/content/dependencies/linkTrackReferencingArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}), diff --git a/src/content/dependencies/linkWikiHomepage.js b/src/content/dependencies/linkWikiHomepage.js index d8d3d0a0..91fbe410 100644 --- a/src/content/dependencies/linkWikiHomepage.js +++ b/src/content/dependencies/linkWikiHomepage.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - extraDependencies: ['wikiData'], - sprawl({wikiInfo}) { return {wikiShortName: wikiInfo.nameShort}; }, diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js index c83ffc97..eaf9eecf 100644 --- a/src/content/dependencies/listAlbumsByDate.js +++ b/src/content/dependencies/listAlbumsByDate.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js index d462ad46..940da67d 100644 --- a/src/content/dependencies/listAlbumsByDateAdded.js +++ b/src/content/dependencies/listAlbumsByDateAdded.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {chunkByProperties} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js index c60685ab..8de2bb84 100644 --- a/src/content/dependencies/listAlbumsByDuration.js +++ b/src/content/dependencies/listAlbumsByDuration.js @@ -3,16 +3,17 @@ import {filterByCount, stitchArrays} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, query({albumData}, spec) { - const albums = sortAlphabetically(albumData.slice()); - const durations = albums.map(album => getTotalDuration(album.tracks)); + const albums = + sortAlphabetically( + albumData.filter(album => !album.hideDuration)); + + const durations = + albums.map(album => getTotalDuration(album.tracks)); filterByCount(albums, durations); sortByCount(albums, durations, {greatestFirst: true}); diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js index 21419537..a7939292 100644 --- a/src/content/dependencies/listAlbumsByName.js +++ b/src/content/dependencies/listAlbumsByName.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js index 798e6c2e..b1f62a82 100644 --- a/src/content/dependencies/listAlbumsByTracks.js +++ b/src/content/dependencies/listAlbumsByTracks.js @@ -2,21 +2,25 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, query({albumData}, spec) { - const albums = sortAlphabetically(albumData.slice()); - const counts = albums.map(album => album.tracks.length); + const albums = + sortAlphabetically( + albumData.filter(album => !album.hideDuration)); + + const counts = + albums.map(album => album.tracks.length); filterByCount(albums, counts); sortByCount(albums, counts, {greatestFirst: true}); - return {spec, albums, counts}; + const styles = + albums.map(album => album.style); + + return {spec, albums, counts, styles}; }, relations(relation, query) { @@ -32,6 +36,7 @@ export default { data(query) { return { counts: query.counts, + styles: query.styles, }; }, @@ -42,10 +47,19 @@ export default { stitchArrays({ link: relations.albumLinks, count: data.counts, - }).map(({link, count}) => ({ - album: link, - tracks: language.countTracks(count, {unit: true}), - })), + style: data.styles, + }).map(({link, count, style}) => { + const row = { + album: link, + tracks: language.countTracks(count, {unit: true}), + }; + + if (style === 'single') { + row.stringsKey = 'single'; + } + + return row; + }), }); }, }; diff --git a/src/content/dependencies/listAllAdditionalFiles.js b/src/content/dependencies/listAllAdditionalFiles.js index a6e34b9a..2d338916 100644 --- a/src/content/dependencies/listAllAdditionalFiles.js +++ b/src/content/dependencies/listAllAdditionalFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'additionalFiles')}), diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js index 8ec69f1d..f298233c 100644 --- a/src/content/dependencies/listAllAdditionalFilesTemplate.js +++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js @@ -1,13 +1,6 @@ import {sortChronologically} from '#sort'; export default { - contentDependencies: [ - 'generateListingPage', - 'generateListAllAdditionalFilesAlbumSection', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({albumData}) => ({albumData}), query: (sprawl, spec, property) => ({ diff --git a/src/content/dependencies/listAllMidiProjectFiles.js b/src/content/dependencies/listAllMidiProjectFiles.js index 31a70ef0..109cf2e7 100644 --- a/src/content/dependencies/listAllMidiProjectFiles.js +++ b/src/content/dependencies/listAllMidiProjectFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'midiProjectFiles')}), diff --git a/src/content/dependencies/listAllSheetMusicFiles.js b/src/content/dependencies/listAllSheetMusicFiles.js index 166b2068..4f3bdb96 100644 --- a/src/content/dependencies/listAllSheetMusicFiles.js +++ b/src/content/dependencies/listAllSheetMusicFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'sheetMusicFiles')}), diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js index 93dd4ce8..98f81019 100644 --- a/src/content/dependencies/listArtTagNetwork.js +++ b/src/content/dependencies/listArtTagNetwork.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtTagInfo'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({artTagData}) { return {artTagData}; }, diff --git a/src/content/dependencies/listArtTagsByName.js b/src/content/dependencies/listArtTagsByName.js index 1df9dfff..10e9e873 100644 --- a/src/content/dependencies/listArtTagsByName.js +++ b/src/content/dependencies/listArtTagsByName.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtTagGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl({artTagData}) { return {artTagData}; }, diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js index eca7f1c6..5131580f 100644 --- a/src/content/dependencies/listArtTagsByUses.js +++ b/src/content/dependencies/listArtTagsByUses.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtTagGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl: ({artTagData}) => ({artTagData}), diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js index eff2dba3..ab7bde6c 100644 --- a/src/content/dependencies/listArtistsByCommentaryEntries.js +++ b/src/content/dependencies/listArtistsByCommentaryEntries.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData}) { return {artistData}; }, diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js index 41944959..2f8d6391 100644 --- a/src/content/dependencies/listArtistsByContributions.js +++ b/src/content/dependencies/listArtistsByContributions.js @@ -1,18 +1,8 @@ import {sortAlphabetically, sortByCount} from '#sort'; - -import { - accumulateSum, - empty, - filterByCount, - filterMultipleArrays, - stitchArrays, - unique, -} from '#sugar'; +import {empty, filterByCount, filterMultipleArrays, stitchArrays} + from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({artistData, wikiInfo}) { return { artistData, @@ -41,37 +31,46 @@ export default { query[countsKey] = counts; }; + const countContributions = (artist, keys) => { + const contribs = + keys + .flatMap(key => artist[key]) + .filter(contrib => contrib.countInContributionTotals); + + const things = + new Set(contribs.map(contrib => contrib.thing)); + + return things.size; + }; + queryContributionInfo( 'artistsByTrackContributions', 'countsByTrackContributions', artist => - (unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - ]).flat() - .map(({thing}) => thing) - )).length); + countContributions(artist, [ + 'trackArtistContributions', + 'trackContributorContributions', + ])); queryContributionInfo( 'artistsByArtworkContributions', 'countsByArtworkContributions', artist => - accumulateSum( - [ - artist.albumCoverArtistContributions, - artist.albumWallpaperArtistContributions, - artist.albumBannerArtistContributions, - artist.trackCoverArtistContributions, - ], - contribs => contribs.length)); + countContributions(artist, [ + 'albumCoverArtistContributions', + 'albumWallpaperArtistContributions', + 'albumBannerArtistContributions', + 'trackCoverArtistContributions', + ])); if (sprawl.enableFlashesAndGames) { queryContributionInfo( 'artistsByFlashContributions', 'countsByFlashContributions', artist => - artist.flashContributorContributions.length); + countContributions(artist, [ + 'flashContributorContributions', + ])); } return query; diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js index 6b2a18a0..1d550b26 100644 --- a/src/content/dependencies/listArtistsByDuration.js +++ b/src/content/dependencies/listArtistsByDuration.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData}) { return {artistData}; }, diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js index 17096cfc..44564b4b 100644 --- a/src/content/dependencies/listArtistsByGroup.js +++ b/src/content/dependencies/listArtistsByGroup.js @@ -10,9 +10,6 @@ import { } from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData, wikiInfo}) { return {artistData, wikiInfo}; }, diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js index 2a8d1b4c..dc7341cf 100644 --- a/src/content/dependencies/listArtistsByLatestContribution.js +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -11,15 +11,6 @@ import { const {Album, Flash} = T; export default { - contentDependencies: [ - 'generateListingPage', - 'linkAlbum', - 'linkArtist', - 'linkFlash', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) => ({albumData, artistData, flashData, trackData, enableFlashesAndGames: wikiInfo.enableFlashesAndGames}), diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index 93218492..8bee4947 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -3,9 +3,6 @@ import {stitchArrays} from '#sugar'; import {getArtistNumContributions} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl: ({artistData, wikiInfo}) => ({artistData, wikiInfo}), diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js index 4adfb6d9..64814640 100644 --- a/src/content/dependencies/listGroupsByAlbums.js +++ b/src/content/dependencies/listGroupsByAlbums.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js index 43919bef..4c10a1e4 100644 --- a/src/content/dependencies/listGroupsByCategory.js +++ b/src/content/dependencies/listGroupsByCategory.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupCategoryData}) { return {groupCategoryData}; }, diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js index c79e1bc4..089915c2 100644 --- a/src/content/dependencies/listGroupsByDuration.js +++ b/src/content/dependencies/listGroupsByDuration.js @@ -3,9 +3,6 @@ import {filterByCount, stitchArrays} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js index 48319314..2d83a354 100644 --- a/src/content/dependencies/listGroupsByLatestAlbum.js +++ b/src/content/dependencies/listGroupsByLatestAlbum.js @@ -2,15 +2,6 @@ import {compareDates, sortChronologically} from '#sort'; import {filterMultipleArrays, sortMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateListingPage', - 'linkAlbum', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js index 696a49bd..e3308158 100644 --- a/src/content/dependencies/listGroupsByName.js +++ b/src/content/dependencies/listGroupsByName.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js index 0b5e4e97..c9d97614 100644 --- a/src/content/dependencies/listGroupsByTracks.js +++ b/src/content/dependencies/listGroupsByTracks.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {accumulateSum, filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 79bba441..81eca274 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -2,14 +2,6 @@ import {sortChronologically} from '#sort'; import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateListingPage', - 'generateListRandomPageLinksAlbumLink', - 'linkGroup', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}), query(sprawl, spec) { diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js index b2405034..f6858ada 100644 --- a/src/content/dependencies/listTracksByAlbum.js +++ b/src/content/dependencies/listTracksByAlbum.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js index dcfaeaf0..9d63f19b 100644 --- a/src/content/dependencies/listTracksByDate.js +++ b/src/content/dependencies/listTracksByDate.js @@ -2,9 +2,6 @@ import {sortAlbumsTracksChronologically} from '#sort'; import {chunkByProperties, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl: ({trackData}) => ({trackData}), query({trackData}, spec) { diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js index 64feb4f1..95fd28b2 100644 --- a/src/content/dependencies/listTracksByDuration.js +++ b/src/content/dependencies/listTracksByDuration.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js index c1ea32a1..ad44c7b2 100644 --- a/src/content/dependencies/listTracksByDurationInAlbum.js +++ b/src/content/dependencies/listTracksByDurationInAlbum.js @@ -2,9 +2,6 @@ import {sortByCount, sortChronologically} from '#sort'; import {filterByCount, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js index 773b0473..a9c2c504 100644 --- a/src/content/dependencies/listTracksByName.js +++ b/src/content/dependencies/listTracksByName.js @@ -1,9 +1,6 @@ import {sortAlphabetically} from '#sort'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js index 5838ded0..8a57e1a6 100644 --- a/src/content/dependencies/listTracksByTimesReferenced.js +++ b/src/content/dependencies/listTracksByTimesReferenced.js @@ -2,9 +2,6 @@ import {sortAlbumsTracksChronologically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js index 8ca0d993..db5472db 100644 --- a/src/content/dependencies/listTracksInFlashesByAlbum.js +++ b/src/content/dependencies/listTracksInFlashesByAlbum.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js index 6ab954ed..325b3cb5 100644 --- a/src/content/dependencies/listTracksInFlashesByFlash.js +++ b/src/content/dependencies/listTracksInFlashesByFlash.js @@ -2,9 +2,6 @@ import {sortFlashesChronologically} from '#sort'; import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], - extraDependencies: ['wikiData'], - sprawl({flashData}) { return {flashData}; }, diff --git a/src/content/dependencies/listTracksNeedingLyrics.js b/src/content/dependencies/listTracksNeedingLyrics.js new file mode 100644 index 00000000..d21fcd06 --- /dev/null +++ b/src/content/dependencies/listTracksNeedingLyrics.js @@ -0,0 +1,7 @@ +export default { + relations: (relation, spec) => + ({page: relation('listTracksWithExtra', spec, 'needsLyrics', 'truthy')}), + + generate: (relations) => + relations.page, +}; diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js index c7f42f9d..09d8ee21 100644 --- a/src/content/dependencies/listTracksWithExtra.js +++ b/src/content/dependencies/listTracksWithExtra.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js index e6ab9d7d..79d76bf3 100644 --- a/src/content/dependencies/listTracksWithLyrics.js +++ b/src/content/dependencies/listTracksWithLyrics.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listTracksWithExtra'], - relations: (relation, spec) => ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}), diff --git a/src/content/dependencies/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js index 418af4c2..9a48f6ae 100644 --- a/src/content/dependencies/listTracksWithMidiProjectFiles.js +++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listTracksWithExtra'], - relations: (relation, spec) => ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}), diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js index 0c6761eb..f0ba4196 100644 --- a/src/content/dependencies/listTracksWithSheetMusicFiles.js +++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listTracksWithExtra'], - relations: (relation, spec) => ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}), diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index fcdc3aa4..8e902647 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -1,5 +1,6 @@ import {basename} from 'node:path'; +import {logWarn} from '#cli'; import {bindFind} from '#find'; import {replacerSpec, parseContentNodes} from '#replacer'; @@ -28,14 +29,36 @@ const commonMarkedOptions = { const multilineMarked = new Marked({ ...commonMarkedOptions, + + renderer: { + code({text}) { + let lines = text + .replace(/^\n+/, '') + .replace(/\n+$/, '') + .split('\n'); + + lines = lines + .map(line => line + .replace(/^ +/, spaces => ' '.repeat(spaces.length)) + .replaceAll(/ {2,}/g, spaces => ' '.repeat(spaces.length))); + + return ( + `<pre class="content-code"><span><code>` + + (lines.length > 1 ? '\n' : '') + + lines.join('<br>\n') + + (lines.length > 1 ? '\n' : '') + + `</pre></span></code>` + ); + }, + }, }); const inlineMarked = new Marked({ ...commonMarkedOptions, renderer: { - paragraph(text) { - return text; + paragraph({tokens}) { + return this.parser.parseInline(tokens); }, }, }); @@ -57,27 +80,38 @@ function getArg(node, argKey) { } export default { - contentDependencies: [ - ...( - Object.values(replacerSpec) - .map(description => description.link) - .filter(Boolean)), - 'image', - 'generateTextWithTooltip', - 'generateTooltip', - 'linkExternal', - ], - - extraDependencies: ['html', 'language', 'to', 'wikiData'], - sprawl(wikiData, content) { - const find = bindFind(wikiData); + const find = + bindFind(wikiData, { + mode: 'quiet', + fuzz: { + capitalization: true, + kebab: true, + }, + }); - const parsedNodes = parseContentNodes(content ?? ''); + const {result: parsedNodes, error} = + parseContentNodes(content ?? '', {errorMode: 'return'}); return { + error, + nodes: parsedNodes .map(node => { + if (node.type === 'tooltip') { + return { + i: node.i, + iEnd: node.iEnd, + type: 'tooltip', + data: { + // No recursion yet. Sorry! + tooltip: node.data.content[0].data, + label: node.data.label[0].data, + link: null, + }, + }; + } + if (node.type !== 'tag') { return node; } @@ -98,7 +132,7 @@ export default { } if (spec.link) { - let data = {link: spec.link}; + let data = {link: spec.link, replacerKey, replacerValue}; determineData: { // No value at all: this is an index link. @@ -137,9 +171,16 @@ export default { data.label = enteredLabel ?? - (transformName && data.thing.name - ? transformName(data.thing.name, node, content) - : null); + + (transformName && data.thing.name && + replacerKeyImplied && replacerValue === data.thing.name + + ? transformName(data.thing.name, node, content) + : null) ?? + + (replacerKeyImplied + ? replacerValue + : null); data.hash = enteredHash ?? null; @@ -177,8 +218,8 @@ export default { ...node, data: { ...node.data, - replacerKey: node.data.replacerKey.data, - replacerValue: node.data.replacerValue[0].data, + replacerKey, + replacerValue, }, }; }), @@ -189,25 +230,11 @@ export default { return { content, + error: + sprawl.error, + nodes: - sprawl.nodes - .map(node => { - switch (node.type) { - // Replace internal link nodes with a stub. It'll be replaced - // (by position) with an item from relations. - // - // TODO: This should be where label and hash get passed through, - // rather than in relations... (in which case there's no need to - // handle it specially here, and we can really just return - // data.nodes = sprawl.nodes) - case 'internal-link': - return {type: 'internal-link'}; - - // Other nodes will get processed in generate. - default: - return node; - } - }), + sprawl.nodes, }; }, @@ -299,9 +326,29 @@ export default { validate: v => v.is('small', 'medium', 'large'), default: 'large', }, + + substitute: { + validate: v => + v.strictArrayOf( + v.validateProperties({ + match: v.validateProperties({ + replacerKey: v.isString, + replacerValue: v.isString, + }), + + substitute: v.isHTML, + + apply: v.optional(v.isFunction), + })), + }, }, - generate(data, relations, slots, {html, language, to}) { + generate(data, relations, slots, {html, language, niceShowAggregate, to}) { + if (data.error) { + logWarn`Error in content text.`; + niceShowAggregate(data.error); + } + let imageIndex = 0; let internalLinkIndex = 0; let externalLinkIndex = 0; @@ -309,6 +356,24 @@ export default { let offsetTextNode = 0; + const substitutions = + (slots.substitute + ? slots.substitute.slice() + : []); + + const pickSubstitution = node => { + const index = + substitutions.findIndex(({match}) => + match.replacerKey === node.data.replacerKey && + match.replacerValue === node.data.replacerValue); + + if (index === -1) { + return null; + } + + return substitutions.splice(index, 1).at(0); + }; + const contentFromNodes = data.nodes.map((node, index) => { const nextNode = data.nodes[index + 1]; @@ -327,6 +392,25 @@ export default { } }; + const substitution = pickSubstitution(node); + + if (substitution) { + const source = + substitution.substitute; + + let substitute = source; + + if (substitution.apply) { + const result = substitution.apply(source, node); + + if (result !== undefined) { + substitute = result; + } + } + + return {type: 'substitution', data: substitute}; + } + switch (node.type) { case 'text': { const text = node.data.slice(offsetTextNode); @@ -360,9 +444,8 @@ export default { height && {height}, style && {style}, - align === 'center' && - !link && - {class: 'align-center'}, + align && !link && + {class: 'align-' + align}, pixelate && {class: 'pixelate'}); @@ -373,8 +456,8 @@ export default { {href: link}, {target: '_blank'}, - align === 'center' && - {class: 'align-center'}, + align && + {class: 'align-' + align}, {title: language.encapsulate('misc.external.opensInNewTab', capsule => @@ -424,8 +507,8 @@ export default { inline: false, data: html.tag('div', {class: 'content-image-container'}, - align === 'center' && - {class: 'align-center'}, + align && + {class: 'align-' + align}, image), }; @@ -437,22 +520,31 @@ export default { ? to('media.path', node.src.slice('media/'.length)) : node.src); - const {width, height, align, pixelate} = node; + const {width, height, align, inline, pixelate} = node; - const content = - html.tag('div', {class: 'content-video-container'}, - align === 'center' && - {class: 'align-center'}, + const video = + html.tag('video', + src && {src}, + width && {width}, + height && {height}, - html.tag('video', - src && {src}, - width && {width}, - height && {height}, + {controls: true}, - {controls: true}, + align && inline && + {class: 'align-' + align}, + + pixelate && + {class: 'pixelate'}); + + const content = + (inline + ? video + : html.tag('div', {class: 'content-video-container'}, + align && + {class: 'align-' + align}, + + video)); - pixelate && - {class: 'pixelate'})); return { type: 'processed-video', @@ -466,15 +558,14 @@ export default { ? to('media.path', node.src.slice('media/'.length)) : node.src); - const {align, inline} = node; + const {align, inline, nameless} = node; const audio = html.tag('audio', src && {src}, - align === 'center' && - inline && - {class: 'align-center'}, + align && inline && + {class: 'align-' + align}, {controls: true}); @@ -482,13 +573,14 @@ export default { (inline ? audio : html.tag('div', {class: 'content-audio-container'}, - align === 'center' && - {class: 'align-center'}, + align && + {class: 'align-' + align}, [ - html.tag('a', {class: 'filename'}, - src && {href: src}, - language.sanitize(basename(node.src))), + !nameless && + html.tag('a', {class: 'filename'}, + src && {href: src}, + language.sanitize(basename(node.src))), audio, ])); @@ -537,7 +629,7 @@ export default { try { link.getSlotDescription('preferShortName'); hasPreferShortNameSlot = true; - } catch (error) { + } catch { hasPreferShortNameSlot = false; } @@ -550,7 +642,7 @@ export default { try { link.getSlotDescription('tooltipStyle'); hasTooltipStyleSlot = true; - } catch (error) { + } catch { hasTooltipStyleSlot = false; } @@ -574,9 +666,12 @@ export default { } case 'external-link': { - const {label} = node.data; const externalLink = relations.externalLinks[externalLinkIndex++]; + const label = + node.data.label ?? + node.data.href.replace(/^https?:\/\//, ''); + if (slots.textOnly) { return {type: 'text', data: label}; } @@ -794,20 +889,37 @@ export default { // This is separated into its own function just since we're gonna reuse // it in a minute if everything goes to heck in lyrics mode. const transformMultiline = () => { - const markedInput = - extractNonTextNodes() - // Compress multiple line breaks into single line breaks, - // except when they're preceding or following indented - // text (by at least two spaces). - .replace(/(?<! .*)\n{2,}(?!^ )/gm, '\n') /* eslint-disable-line no-regex-spaces */ - // Expand line breaks which don't follow a list, quote, - // or <br> / " ", and which don't precede or follow - // indented text (by at least two spaces). - .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ - // Expand line breaks which are at the end of a list. - .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n') - // Expand line breaks which are at the end of a quote. - .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + let fencedCode = []; + + const fencedCodePlaceholder = + `<span class="INSERT-FENCED-CODE"></span>`; + + let markedInput = extractNonTextNodes(); + + markedInput = markedInput + .replaceAll(/```(?:[\s\S](?!```))*\n```/g, (match) => { + fencedCode.push(match); + return fencedCodePlaceholder; + }); + + markedInput = markedInput + // Compress multiple line breaks into single line breaks, + // except when they're preceding or following indented + // text (by at least two spaces) or blockquotes. + .replace(/(?<!^ .*|^>.*)\n{2,}(?!^ |^>)/gm, '\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which don't follow a list, quote, + // or <br> / " ", and which don't precede or follow + // indented text (by at least two spaces). + .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n') + // Expand line breaks which are at the end of a quote. + .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + + fencedCode = fencedCode.reverse(); + + markedInput = markedInput + .replaceAll(fencedCodePlaceholder, () => fencedCode.pop()); const markedOutput = multilineMarked.parse(markedInput); diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index a089e325..3f70af30 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -12,6 +12,7 @@ export default class CacheableObject { static propertyDependants = Symbol.for('CacheableObject.propertyDependants'); static cacheValid = Symbol.for('CacheableObject.cacheValid'); + static cachedValue = Symbol.for('CacheableObject.cachedValue'); static updateValue = Symbol.for('CacheableObject.updateValues'); constructor({seal = true} = {}) { @@ -243,13 +244,13 @@ export class CacheableObjectPropertyValueError extends Error { try { inspectOldValue = inspect(oldValue); - } catch (error) { + } catch { inspectOldValue = colors.red(`(couldn't inspect)`); } try { inspectNewValue = inspect(newValue); - } catch (error) { + } catch { inspectNewValue = colors.red(`(couldn't inspect)`); } diff --git a/src/data/checks.js b/src/data/checks.js index 075f929f..fd2c4931 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -11,6 +11,7 @@ import Thing from '#thing'; import thingConstructors from '#things'; import { + annotateError, annotateErrorWithIndex, conditionallySuppressError, decorateErrorWithIndex, @@ -59,7 +60,7 @@ export function reportDirectoryErrors(wikiData, { : [thing.directory]); for (const directory of directories) { - if (directory === null || directory === undefined) { + if (directory === '' || directory === null || directory === undefined) { missingDirectoryThings.add(thing); continue; } @@ -165,6 +166,69 @@ function getFieldPropertyMessage(yamlDocumentSpec, property) { return fieldPropertyMessage; } +function decoAnnotateFindErrors(findFn) { + function annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary(error) { + const matches = error[Symbol.for('hsmusic.find.multipleNameMatches')]; + if (!matches) return; + + const notSoSecondary = + matches + .map(match => match.thing ?? match) + .filter(match => + match.isTrack && + match.isMainRelease && + CacheableObject.getUpdateValue(match, 'mainRelease')); + + if (empty(notSoSecondary)) return; + + let {message} = error; + message += (message.includes('\n') ? '\n\n' : '\n'); + message += colors.bright(colors.yellow('<!>')) + ' '; + message += colors.yellow(`Some of these tracks are meant to be secondary releases,`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`but another error is keeping that from processing correctly!`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`Probably look for an error to do with "Main Release", first.`); + Object.assign(error, {message}); + } + + return (...args) => { + try { + return findFn(...args); + } catch (caughtError) { + throw annotateError(caughtError, ...[ + annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary, + ]); + } + }; +} + +function decoSuppressFindErrors(findFn, {property}) { + void property; + + return conditionallySuppressError(_error => { + // We're not suppressing any errors at the moment. + // An old suppression is kept below for reference. + + /* + if (property === 'sampledTracks') { + // Suppress "didn't match anything" errors in particular, just for samples. + // In hsmusic-data we have a lot of "stub" sample data which don't have + // corresponding tracks yet, so it won't be useful to report such reference + // errors until we take the time to address that. But other errors, like + // malformed reference strings or miscapitalized existing tracks, should + // still be reported, as samples of existing tracks *do* display on the + // website! + if (error.message.includes(`Didn't match anything`)) { + return true; + } + } + */ + + return false; + }, findFn); +} + // Warn about references across data which don't match anything. This involves // using the find() functions on all references, setting it to 'error' mode, and // collecting everything in a structured logged (which gets logged if there are @@ -185,16 +249,20 @@ export function filterReferenceErrors(wikiData, { artTags: '_artTag', referencedArtworks: '_artwork', commentary: '_content', - creditSources: '_content', + creditingSources: '_content', }], ['artTagData', { directDescendantArtTags: 'artTag', }], + ['artworkData', { + referencedArtworks: '_artwork', + }], + ['flashData', { commentary: '_content', - creditSources: '_content', + creditingSources: '_content', }], ['groupCategoryData', { @@ -229,13 +297,15 @@ export function filterReferenceErrors(wikiData, { artistContribs: '_contrib', contributorContribs: '_contrib', coverArtistContribs: '_contrib', + previousProductionTracks: '_trackMainReleasesOnly', referencedTracks: '_trackMainReleasesOnly', sampledTracks: '_trackMainReleasesOnly', artTags: '_artTag', referencedArtworks: '_artwork', - mainReleaseTrack: '_trackMainReleasesOnly', + mainRelease: '_mainRelease', commentary: '_content', - creditSources: '_content', + creditingSources: '_content', + referencingSources: '_content', lyrics: '_content', }], @@ -341,15 +411,112 @@ export function filterReferenceErrors(wikiData, { }; break; + case '_mainRelease': + findFn = ref => { + // Mocking what's going on in `withMainRelease`. + + if (ref === 'same name single') { + // Accessing the current thing here. + try { + return boundFind.albumSinglesOnly(thing.name, { + fuzz: { + capitalization: true, + kebab: true, + }, + }); + } catch (caughtError) { + throw new Error( + `Didn't match a single with the same name`, + {cause: caughtError}); + } + } + + let track, trackError; + let album, albumError; + + try { + track = boundFind.trackMainReleasesOnly(ref); + } catch (caughtError) { + trackError = new Error( + `Didn't match a track`, {cause: caughtError}); + } + + try { + album = boundFind.album(ref); + } catch (caughtError) { + albumError = new Error( + `Didn't match an album`, {cause: caughtError}); + } + + if (track && album) { + if (album.tracks.includes(track)) { + return track; + } else { + throw new Error( + `Unrelated album and track matched for reference "${ref}". Please resolve:\n` + + `- ${inspect(track)}\n` + + `- ${inspect(album)}\n` + + `Returning null for this reference.`); + } + } + + if (track) { + return track; + } + + if (album) { + // At this point verification depends on the thing itself, + // which is currently in lexical scope, but if this code + // gets refactored, there might be trouble here... + + if (thing.mainReleaseTrack === null) { + if (album === thing.album) { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but this is the album that includes this secondary release, itself.\n` + + `Please resolve by pointing to aonther album here, or by removing this\n` + + `Main Release field, if this track is meant to be the main release.`); + } else { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but none of its tracks automatically match this secondary release.\n` + + `Please resolve by specifying the track here, instead of the album.`); + } + } else { + return album; + } + } + + const aggregateCause = + new AggregateError([albumError, trackError]); + + aggregateCause[Symbol.for('hsmusic.aggregate.translucent')] = true; + + throw new Error(`Trouble matching "${ref}"`, { + cause: aggregateCause, + }); + } + + break; + case '_trackArtwork': findFn = ref => boundFind.track(ref.reference); break; case '_trackMainReleasesOnly': findFn = trackRef => { - const track = boundFind.track(trackRef); - const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack'); + let track = boundFind.trackMainReleasesOnly(trackRef, {mode: 'quiet'}); + if (track) { + return track; + } + // Will error normally, if this can't unambiguously resolve + // or doesn't match any track. + track = boundFind.track(trackRef); + + const mainRef = CacheableObject.getUpdateValue(track, 'mainRelease'); if (mainRef) { // It's possible for the main release to not actually exist, in this case. // It should still be reported since the 'Main Release' field was present. @@ -380,27 +547,8 @@ export function filterReferenceErrors(wikiData, { break; } - const suppress = fn => conditionallySuppressError(error => { - // We're not suppressing any errors at the moment. - // An old suppression is kept below for reference. - - /* - if (property === 'sampledTracks') { - // Suppress "didn't match anything" errors in particular, just for samples. - // In hsmusic-data we have a lot of "stub" sample data which don't have - // corresponding tracks yet, so it won't be useful to report such reference - // errors until we take the time to address that. But other errors, like - // malformed reference strings or miscapitalized existing tracks, should - // still be reported, as samples of existing tracks *do* display on the - // website! - if (error.message.includes(`Didn't match anything`)) { - return true; - } - } - */ - - return false; - }, fn); + findFn = decoSuppressFindErrors(findFn, {property}); + findFn = decoAnnotateFindErrors(findFn); const fieldPropertyMessage = getFieldPropertyMessage( @@ -456,10 +604,10 @@ export function filterReferenceErrors(wikiData, { value, {message: errorMessage}, decorateErrorWithIndex(refs => (refs.length === 1 - ? suppress(findFn)(refs[0]) + ? findFn(refs[0]) : filterAggregate( refs, {message: `Errors in entry's artist references`}, - decorateErrorWithIndex(suppress(findFn))) + decorateErrorWithIndex(findFn)) .aggregate .close()))); @@ -471,19 +619,18 @@ export function filterReferenceErrors(wikiData, { if (Array.isArray(value)) { newPropertyValue = filter( value, {message: errorMessage}, - decorateErrorWithIndex(suppress(findFn))); + decorateErrorWithIndex(findFn)); break determineNewPropertyValue; } - nest({message: errorMessage}, - suppress(({call}) => { - try { - call(findFn, value); - } catch (error) { - newPropertyValue = null; - throw error; - } - })); + nest({message: errorMessage}, ({call}) => { + try { + call(findFn, value); + } catch (error) { + newPropertyValue = null; + throw error; + } + }); } if (writeProperty) { @@ -507,7 +654,11 @@ export class ContentNodeError extends Error { message, }) { const headingLine = - `(${where}) ${message}`; + (message.includes('\n\n') + ? `(${where})\n\n` + message + '\n' + : message.includes('\n') + ? `(${where})\n` + message + : `(${where}) ${message}`); const textUpToNode = containingLine.slice(0, columnNumber); @@ -552,6 +703,11 @@ export function reportContentTextErrors(wikiData, { description: 'description', }; + const artworkShape = { + source: 'artwork source', + originDetails: 'artwork origin details', + }; + const commentaryShape = { body: 'commentary body', artistText: 'commentary artist text', @@ -568,6 +724,8 @@ export function reportContentTextErrors(wikiData, { ['albumData', { additionalFiles: additionalFileShape, commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtworks: artworkShape, }], ['artTagData', { @@ -580,6 +738,8 @@ export function reportContentTextErrors(wikiData, { ['flashData', { commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtwork: artworkShape, }], ['flashActData', { @@ -609,10 +769,12 @@ export function reportContentTextErrors(wikiData, { ['trackData', { additionalFiles: additionalFileShape, commentary: commentaryShape, - creditSources: commentaryShape, + creditingSources: commentaryShape, + referencingSources: commentaryShape, lyrics: lyricsShape, midiProjectFiles: additionalFileShape, sheetMusicFiles: additionalFileShape, + trackArtworks: artworkShape, }], ['wikiInfo', { @@ -621,7 +783,15 @@ export function reportContentTextErrors(wikiData, { }], ]; - const boundFind = bindFind(wikiData, {mode: 'error'}); + const boundFind = + bindFind(wikiData, { + mode: 'error', + fuzz: { + capitalization: true, + kebab: true, + }, + }); + const findArtistOrAlias = bindFindArtistOrAlias(boundFind); function* processContent(input) { @@ -662,6 +832,9 @@ export function reportContentTextErrors(wikiData, { break; } + findFn = decoSuppressFindErrors(findFn, {property: null}); + findFn = decoAnnotateFindErrors(findFn); + const findRef = (replacerKeyImplied ? replacerValue @@ -682,7 +855,7 @@ export function reportContentTextErrors(wikiData, { } else if (node.type === 'external-link') { try { new URL(node.data.href); - } catch (error) { + } catch { yield { index, length, message: @@ -753,6 +926,31 @@ export function reportContentTextErrors(wikiData, { const topMessage = `Content text errors` + fieldPropertyMessage; + const checkShapeEntries = (entry, callProcessContentOpts) => { + for (const [key, annotation] of Object.entries(shape)) { + const value = entry[key]; + + // TODO: This should be an undefined/null check, like above, + // but it's not, because sometimes the stuff we're checking + // here isn't actually coded as a Thing - so the properties + // might really be undefined instead of null. Terrifying and + // awful. And most of all, citation needed. + if (!value) continue; + + callProcessContent({ + ...callProcessContentOpts, + + // TODO: `nest` isn't provided by `callProcessContentOpts` + //`but `push` is - this is to match the old code, but + // what's the deal here? + nest, + + value, + message: `Error in ${colors.green(annotation)}`, + }); + } + }; + if (shape === '_content') { callProcessContent({ nest, @@ -760,26 +958,18 @@ export function reportContentTextErrors(wikiData, { value, message: topMessage, }); - } else { + } else if (Array.isArray(value)) { nest({message: topMessage}, ({push}) => { for (const [index, entry] of value.entries()) { - for (const [key, annotation] of Object.entries(shape)) { - const value = entry[key]; - - // TODO: Should this check undefined/null similar to above? - if (!value) continue; - - callProcessContent({ - nest, - push, - value, - message: `Error in ${colors.green(annotation)}`, - annotateError: error => - annotateErrorWithIndex(error, index), - }); - } + checkShapeEntries(entry, { + push, + annotateError: error => + annotateErrorWithIndex(error, index), + }); } }); + } else { + checkShapeEntries(value, {push}); } } }); diff --git a/src/data/composite.js b/src/data/composite.js index f31c4069..e5873cf5 100644 --- a/src/data/composite.js +++ b/src/data/composite.js @@ -1416,7 +1416,7 @@ export function compositeFrom(description) { export function displayCompositeCacheAnalysis() { const showTimes = (cache, key) => { - const times = cache.times[key].slice().sort(); + const times = cache.times[key].toSorted(); const all = times; const worst10pc = times.slice(-times.length / 10); diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js index efc3ecae..e67aa887 100644 --- a/src/data/composite/data/withLengthOfList.js +++ b/src/data/composite/data/withLengthOfList.js @@ -1,5 +1,4 @@ import {input, templateCompositeFrom} from '#composite'; -import {stitchArrays} from '#sugar'; function getOutputName({ [input.staticDependency('list')]: list, diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js index dfc6864f..de1d37c3 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1,2 +1,2 @@ -export {default as withHasCoverArt} from './withHasCoverArt.js'; +export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withTracks} from './withTracks.js'; diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/things/album/withCoverArtDate.js index a114d5ff..978f566a 100644 --- a/src/data/composite/wiki-data/withCoverArtDate.js +++ b/src/data/composite/things/album/withCoverArtDate.js @@ -2,8 +2,7 @@ import {input, templateCompositeFrom} from '#composite'; import {isDate} from '#validators'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withResolvedContribs from './withResolvedContribs.js'; +import {withHasArtwork} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `withCoverArtDate`, @@ -19,14 +18,14 @@ export default templateCompositeFrom({ outputs: ['#coverArtDate'], steps: () => [ - withResolvedContribs({ - from: 'coverArtistContribs', - date: input.value(null), + withHasArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', }), raiseOutputWithoutDependency({ - dependency: '#resolvedContribs', - mode: input.value('empty'), + dependency: '#hasArtwork', + mode: input.value('falsy'), output: input.value({'#coverArtDate': null}), }), diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js index 3693c10f..b5e5e167 100644 --- a/src/data/composite/things/artwork/index.js +++ b/src/data/composite/things/artwork/index.js @@ -1,5 +1,7 @@ +export {default as withArtTags} from './withArtTags.js'; export {default as withAttachedArtwork} from './withAttachedArtwork.js'; export {default as withContainingArtworkList} from './withContainingArtworkList.js'; +export {default as withContentWarningArtTags} from './withContentWarningArtTags.js'; export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js'; export {default as withDate} from './withDate.js'; export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js'; diff --git a/src/data/composite/things/artwork/withArtTags.js b/src/data/composite/things/artwork/withArtTags.js new file mode 100644 index 00000000..1fed3c31 --- /dev/null +++ b/src/data/composite/things/artwork/withArtTags.js @@ -0,0 +1,99 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +import withPropertyFromAttachedArtwork + from './withPropertyFromAttachedArtwork.js'; + +export default templateCompositeFrom({ + annotation: `withArtTags`, + + inputs: { + from: input({ + type: 'array', + acceptsNull: true, + defaultDependency: 'artTags', + }), + }, + + outputs: ['#artTags'], + + steps: () => [ + withResolvedReferenceList({ + list: input('from'), + find: soupyFind.input('artTag'), + }), + + withResultOfAvailabilityCheck({ + from: '#resolvedReferenceList', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#resolvedReferenceList'], + compute: (continuation, { + ['#availability']: availability, + ['#resolvedReferenceList']: resolvedReferenceList, + }) => + (availability + ? continuation.raiseOutput({ + '#artTags': resolvedReferenceList, + }) + : continuation()), + }, + + withPropertyFromAttachedArtwork({ + property: input.value('artTags'), + }), + + withResultOfAvailabilityCheck({ + from: '#attachedArtwork.artTags', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#attachedArtwork.artTags'], + compute: (continuation, { + ['#availability']: availability, + ['#attachedArtwork.artTags']: attachedArtworkArtTags, + }) => + (availability + ? continuation.raiseOutput({ + '#artTags': attachedArtworkArtTags, + }) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'artTagsFromThingProperty', + output: input.value({'#artTags': []}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artTagsFromThingProperty', + }).outputs({ + ['#value']: '#thing.artTags', + }), + + withResultOfAvailabilityCheck({ + from: '#thing.artTags', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#thing.artTags'], + compute: (continuation, { + ['#availability']: availability, + ['#thing.artTags']: thingArtTags, + }) => + (availability + ? continuation({'#artTags': thingArtTags}) + : continuation({'#artTags': []})), + }, + ], +}); diff --git a/src/data/composite/things/artwork/withContentWarningArtTags.js b/src/data/composite/things/artwork/withContentWarningArtTags.js new file mode 100644 index 00000000..4c07e837 --- /dev/null +++ b/src/data/composite/things/artwork/withContentWarningArtTags.js @@ -0,0 +1,27 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withFilteredList, withPropertyFromList} from '#composite/data'; + +import withArtTags from './withArtTags.js'; + +export default templateCompositeFrom({ + annotation: `withContentWarningArtTags`, + + outputs: ['#contentWarningArtTags'], + + steps: () => [ + withArtTags(), + + withPropertyFromList({ + list: '#artTags', + property: input.value('isContentWarning'), + }), + + withFilteredList({ + list: '#artTags', + filter: '#artTags.isContentWarning', + }).outputs({ + '#filteredList': '#contentWarningArtTags', + }), + ], +}); diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js index 36abb3fe..e9425c95 100644 --- a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js +++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js @@ -1,7 +1,6 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; import {withRecontextualizedContributionList} from '#composite/wiki-data'; import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js'; diff --git a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js index 62799d43..69da8c75 100644 --- a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js +++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js @@ -52,6 +52,7 @@ export default templateCompositeFrom({ withMappedList({ list: '#artistTags', map: input.value(node => + 'artist:' + node.data.replacerValue[0].data), }).outputs({ '#mappedList': '#artistReferences', diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js index 5eb8e3d5..0c5a0294 100644 --- a/src/data/composite/things/content/withAnnotationParts.js +++ b/src/data/composite/things/content/withAnnotationParts.js @@ -1,6 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; -import {parseContentNodes} from '#replacer'; -import {transposeArrays} from '#sugar'; +import {empty, transposeArrays} from '#sugar'; import {is} from '#validators'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; @@ -34,6 +33,16 @@ export default templateCompositeFrom({ }), { + dependencies: ['#contentNodeLists'], + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + }) => continuation({ + ['#contentNodeLists']: + nodeLists.filter(list => !empty(list)), + }), + }, + + { dependencies: ['#contentNodeLists', input('mode')], compute: (continuation, { ['#contentNodeLists']: nodeLists, diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js index d310e8ea..292306b7 100644 --- a/src/data/composite/things/content/withSourceText.js +++ b/src/data/composite/things/content/withSourceText.js @@ -1,5 +1,4 @@ import {input, templateCompositeFrom} from '#composite'; -import {parseContentNodes} from '#replacer'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js index f1e8dbc0..f85ff9ea 100644 --- a/src/data/composite/things/content/withSourceURLs.js +++ b/src/data/composite/things/content/withSourceURLs.js @@ -1,5 +1,4 @@ import {input, templateCompositeFrom} from '#composite'; -import {parseContentNodes} from '#replacer'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; import {withFilteredList, withMappedList} from '#composite/data'; diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js index 9b22be2e..31d86b8b 100644 --- a/src/data/composite/things/contribution/index.js +++ b/src/data/composite/things/contribution/index.js @@ -1,6 +1,4 @@ export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js'; -export {default as thingPropertyMatches} from './thingPropertyMatches.js'; -export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js'; export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js'; export {default as withContributionArtist} from './withContributionArtist.js'; export {default as withContributionContext} from './withContributionContext.js'; diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js deleted file mode 100644 index 1e9019b8..00000000 --- a/src/data/composite/things/contribution/thingPropertyMatches.js +++ /dev/null @@ -1,46 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `thingPropertyMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - { - dependencies: ['thing', 'thingProperty'], - - compute: (continuation, {thing, thingProperty}) => - continuation({ - ['#thingProperty']: - (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork' - ? thing.artistContribsFromThingProperty - : thingProperty), - }), - }, - - exitWithoutDependency({ - dependency: '#thingProperty', - value: input.value(false), - }), - - { - dependencies: [ - '#thingProperty', - input('value'), - ], - - compute: ({ - ['#thingProperty']: thingProperty, - [input('value')]: value, - }) => - thingProperty === value, - }, - ], -}); diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js deleted file mode 100644 index 4042e78f..00000000 --- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js +++ /dev/null @@ -1,66 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `thingReferenceTypeMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - exitWithoutDependency({ - dependency: 'thing', - value: input.value(false), - }), - - withPropertyFromObject({ - object: 'thing', - property: input.value('constructor'), - }), - - { - dependencies: [ - '#thing.constructor', - input('value'), - ], - - compute: (continuation, { - ['#thing.constructor']: constructor, - [input('value')]: value, - }) => - (constructor[Symbol.for('Thing.referenceType')] === value - ? continuation.exit(true) - : constructor[Symbol.for('Thing.referenceType')] === 'artwork' - ? continuation() - : continuation.exit(false)), - }, - - withPropertyFromObject({ - object: 'thing', - property: input.value('thing'), - }), - - withPropertyFromObject({ - object: '#thing.thing', - property: input.value('constructor'), - }), - - { - dependencies: [ - '#thing.thing.constructor', - input('value'), - ], - - compute: ({ - ['#thing.thing.constructor']: constructor, - [input('value')]: value, - }) => - constructor[Symbol.for('Thing.referenceType')] === value, - }, - ], -}); diff --git a/src/data/composite/things/language/index.js b/src/data/composite/things/language/index.js new file mode 100644 index 00000000..f22cdaf6 --- /dev/null +++ b/src/data/composite/things/language/index.js @@ -0,0 +1 @@ +export {default as withStrings} from './withStrings.js'; diff --git a/src/data/composite/things/language/withStrings.js b/src/data/composite/things/language/withStrings.js new file mode 100644 index 00000000..3b8d46b3 --- /dev/null +++ b/src/data/composite/things/language/withStrings.js @@ -0,0 +1,111 @@ +import {logWarn} from '#cli'; +import {input, templateCompositeFrom} from '#composite'; +import {empty, withEntries} from '#sugar'; +import {languageOptionRegex} from '#wiki-data'; + +import {withResultOfAvailabilityCheck} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `withStrings`, + + inputs: { + from: input({defaultDependency: 'strings'}), + }, + + outputs: ['#strings'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('from'), + }).outputs({ + '#availability': '#stringsAvailability', + }), + + withResultOfAvailabilityCheck({ + from: 'inheritedStrings', + }).outputs({ + '#availability': '#inheritedStringsAvailability', + }), + + { + dependencies: [ + '#stringsAvailability', + '#inheritedStringsAvailability', + ], + + compute: (continuation, { + ['#stringsAvailability']: stringsAvailability, + ['#inheritedStringsAvailability']: inheritedStringsAvailability, + }) => + (stringsAvailability || inheritedStringsAvailability + ? continuation() + : continuation.raiseOutput({'#strings': null})), + }, + + { + dependencies: [input('from'), '#inheritedStringsAvailability'], + compute: (continuation, { + [input('from')]: strings, + ['#inheritedStringsAvailability']: inheritedStringsAvailability, + }) => + (inheritedStringsAvailability + ? continuation() + : continuation.raiseOutput({'#strings': strings})), + }, + + { + dependencies: ['inheritedStrings', '#stringsAvailability'], + compute: (continuation, { + ['inheritedStrings']: inheritedStrings, + ['#stringsAvailability']: stringsAvailability, + }) => + (stringsAvailability + ? continuation() + : continuation.raiseOutput({'#strings': inheritedStrings})), + }, + + { + dependencies: [input('from'), 'inheritedStrings', 'code'], + compute(continuation, { + [input('from')]: strings, + ['inheritedStrings']: inheritedStrings, + ['code']: code, + }) { + const validStrings = { + ...inheritedStrings, + ...strings, + }; + + const optionsFromTemplate = template => + Array.from(template.matchAll(languageOptionRegex)) + .map(({groups}) => groups.name); + + for (const [key, providedTemplate] of Object.entries(strings)) { + const inheritedTemplate = inheritedStrings[key]; + if (!inheritedTemplate) continue; + + const providedOptions = optionsFromTemplate(providedTemplate); + const inheritedOptions = optionsFromTemplate(inheritedTemplate); + + const missingOptionNames = + inheritedOptions.filter(name => !providedOptions.includes(name)); + + const misplacedOptionNames = + providedOptions.filter(name => !inheritedOptions.includes(name)); + + if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) { + logWarn`Not using ${code ?? '(no code)'} string ${key}:`; + if (!empty(missingOptionNames)) + logWarn`- Missing options: ${missingOptionNames.join(', ')}`; + if (!empty(misplacedOptionNames)) + logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + + validStrings[key] = inheritedStrings[key]; + } + } + + return continuation({'#strings': validStrings}); + }, + }, + ], +}); diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js index e034b7a5..0ca52b6c 100644 --- a/src/data/composite/things/track-section/withContinueCountingFrom.js +++ b/src/data/composite/things/track-section/withContinueCountingFrom.js @@ -1,4 +1,4 @@ -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import withStartCountingFrom from './withStartCountingFrom.js'; diff --git a/src/data/composite/things/track/alwaysReferenceByDirectory.js b/src/data/composite/things/track/alwaysReferenceByDirectory.js new file mode 100644 index 00000000..a342d38b --- /dev/null +++ b/src/data/composite/things/track/alwaysReferenceByDirectory.js @@ -0,0 +1,69 @@ +// Controls how find.track works - it'll never be matched by a reference +// just to the track's name, which means you don't have to always reference +// some *other* (much more commonly referenced) track by directory instead +// of more naturally by name. + +import {input, templateCompositeFrom} from '#composite'; +import {isBoolean} from '#validators'; +import {getKebabCase} from '#wiki-data'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exitWithoutDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import withMainReleaseTrack from './withMainReleaseTrack.js'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: `alwaysReferenceByDirectory`, + + compose: false, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromAlbum({ + property: input.value('alwaysReferenceTracksByDirectory'), + }), + + // Falsy mode means this exposes true if the album's property is true, + // but continues if the property is false (which is also the default). + exposeDependencyOrContinue({ + dependency: '#album.alwaysReferenceTracksByDirectory', + mode: input.value('falsy'), + }), + + exitWithoutDependency({ + dependency: 'mainRelease', + value: input.value(false), + }), + + withMainReleaseTrack(), + + exitWithoutDependency({ + dependency: '#mainReleaseTrack', + value: input.value(false), + }), + + withPropertyFromObject({ + object: '#mainReleaseTrack', + property: input.value('name'), + }), + + { + dependencies: ['name', '#mainReleaseTrack.name'], + compute: ({ + ['name']: name, + ['#mainReleaseTrack.name']: mainReleaseName, + }) => + getKebabCase(name) === + getKebabCase(mainReleaseName), + }, + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index e789e736..1c203cd9 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,14 +1,15 @@ +export {default as alwaysReferenceByDirectory} from './alwaysReferenceByDirectory.js'; export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js'; export {default as inheritFromMainRelease} from './inheritFromMainRelease.js'; export {default as withAllReleases} from './withAllReleases.js'; -export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; export {default as withContainingTrackSection} from './withContainingTrackSection.js'; export {default as withCoverArtistContribs} from './withCoverArtistContribs.js'; export {default as withDate} from './withDate.js'; export {default as withDirectorySuffix} from './withDirectorySuffix.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; export {default as withMainRelease} from './withMainRelease.js'; +export {default as withMainReleaseTrack} from './withMainReleaseTrack.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js'; diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js index b93bf753..bd54384f 100644 --- a/src/data/composite/things/track/withAllReleases.js +++ b/src/data/composite/things/track/withAllReleases.js @@ -8,10 +8,9 @@ import {input, templateCompositeFrom} from '#composite'; import {sortByDate} from '#sort'; -import {exitWithoutDependency} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import withMainRelease from './withMainRelease.js'; +import withMainReleaseTrack from './withMainReleaseTrack.js'; export default templateCompositeFrom({ annotation: `withAllReleases`, @@ -19,7 +18,7 @@ export default templateCompositeFrom({ outputs: ['#allReleases'], steps: () => [ - withMainRelease({ + withMainReleaseTrack({ selfIfMain: input.value(true), notFoundValue: input.value([]), }), @@ -29,18 +28,22 @@ export default templateCompositeFrom({ // `this.secondaryReleases` from within a data composition. // Oooooooooooooooooooooooooooooooooooooooooooooooo withPropertyFromObject({ - object: '#mainRelease', + object: '#mainReleaseTrack', property: input.value('secondaryReleases'), }), { - dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'], + dependencies: [ + '#mainReleaseTrack', + '#mainReleaseTrack.secondaryReleases', + ], + compute: (continuation, { - ['#mainRelease']: mainRelease, - ['#mainRelease.secondaryReleases']: secondaryReleases, + ['#mainReleaseTrack']: mainReleaseTrack, + ['#mainReleaseTrack.secondaryReleases']: secondaryReleases, }) => continuation({ ['#allReleases']: - sortByDate([mainRelease, ...secondaryReleases]), + sortByDate([mainReleaseTrack, ...secondaryReleases]), }), }, ], diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js deleted file mode 100644 index 60faeaf4..00000000 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ /dev/null @@ -1,97 +0,0 @@ -// Controls how find.track works - it'll never be matched by a reference -// just to the track's name, which means you don't have to always reference -// some *other* (much more commonly referenced) track by directory instead -// of more naturally by name. - -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {isBoolean} from '#validators'; - -import {withPropertyFromObject} from '#composite/data'; -import {withResolvedReference} from '#composite/wiki-data'; -import {soupyFind} from '#composite/wiki-properties'; - -import { - exitWithoutDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withAlwaysReferenceByDirectory`, - - outputs: ['#alwaysReferenceByDirectory'], - - steps: () => [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - withPropertyFromAlbum({ - property: input.value('alwaysReferenceTracksByDirectory'), - }), - - // Falsy mode means this exposes true if the album's property is true, - // but continues if the property is false (which is also the default). - exposeDependencyOrContinue({ - dependency: '#album.alwaysReferenceTracksByDirectory', - mode: input.value('falsy'), - }), - - // Remaining code is for defaulting to true if this track is a rerelease of - // another with the same name, so everything further depends on access to - // trackData as well as mainReleaseTrack. - - exitWithoutDependency({ - dependency: 'trackData', - mode: input.value('empty'), - value: input.value(false), - }), - - exitWithoutDependency({ - dependency: 'mainReleaseTrack', - value: input.value(false), - }), - - // It's necessary to use the custom trackMainReleasesOnly find function - // here, so as to avoid recursion issues - the find.track() function depends - // on accessing each track's alwaysReferenceByDirectory, which means it'll - // hit *this track* - and thus this step - and end up recursing infinitely. - // By definition, find.trackMainReleasesOnly excludes tracks which have - // an mainReleaseTrack update value set, which means even though it does - // still access each of tracks' `alwaysReferenceByDirectory` property, it - // won't access that of *this* track - it will never proceed past the - // `exitWithoutDependency` step directly above, so there's no opportunity - // for recursion. - withResolvedReference({ - ref: 'mainReleaseTrack', - data: 'trackData', - find: input.value(find.trackMainReleasesOnly), - }).outputs({ - '#resolvedReference': '#mainRelease', - }), - - exitWithoutDependency({ - dependency: '#mainRelease', - value: input.value(false), - }), - - withPropertyFromObject({ - object: '#mainRelease', - property: input.value('name'), - }), - - { - dependencies: ['name', '#mainRelease.name'], - compute: (continuation, { - name, - ['#mainRelease.name']: mainReleaseName, - }) => continuation({ - ['#alwaysReferenceByDirectory']: - name === mainReleaseName, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withDate.js b/src/data/composite/things/track/withDate.js index b5a770e9..1851c0d2 100644 --- a/src/data/composite/things/track/withDate.js +++ b/src/data/composite/things/track/withDate.js @@ -12,6 +12,14 @@ export default templateCompositeFrom({ steps: () => [ { + dependencies: ['disableDate'], + compute: (continuation, {disableDate}) => + (disableDate + ? continuation.raiseOutput({'#date': null}) + : continuation()), + }, + + { dependencies: ['dateFirstReleased'], compute: (continuation, {dateFirstReleased}) => (dateFirstReleased diff --git a/src/data/composite/things/track/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js index c063e158..c3651491 100644 --- a/src/data/composite/things/track/withDirectorySuffix.js +++ b/src/data/composite/things/track/withDirectorySuffix.js @@ -1,8 +1,9 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withContainingTrackSection from './withContainingTrackSection.js'; import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js'; export default templateCompositeFrom({ @@ -16,21 +17,16 @@ export default templateCompositeFrom({ raiseOutputWithoutDependency({ dependency: '#suffixDirectoryFromAlbum', mode: input.value('falsy'), - output: input.value({['#directorySuffix']: null}), + output: input.value({'#directorySuffix': null}), }), - withPropertyFromAlbum({ + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', property: input.value('directorySuffix'), + }).outputs({ + '#trackSection.directorySuffix': '#directorySuffix', }), - - { - dependencies: ['#album.directorySuffix'], - compute: (continuation, { - ['#album.directorySuffix']: directorySuffix, - }) => continuation({ - ['#directorySuffix']: - directorySuffix, - }), - }, ], }); diff --git a/src/data/composite/things/track/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js index 3a91edae..67a312ae 100644 --- a/src/data/composite/things/track/withMainRelease.js +++ b/src/data/composite/things/track/withMainRelease.js @@ -1,13 +1,15 @@ -// Just includes the main release of this track as a dependency. -// If this track isn't a secondary release, then it'll provide null, unless -// the {selfIfMain} option is set, in which case it'll provide this track -// itself. This will early exit (with notFoundValue) if the main release -// is specified by reference and that reference doesn't resolve to anything. +// Resolves this track's `mainRelease` reference, using weird-ass atypical +// machinery that operates on soupyFind and does not operate on findMixed, +// let alone a prim and proper standalone find spec. +// +// Raises null only if there is no `mainRelease` reference provided at all. +// This will early exit (with notFoundValue) if the reference doesn't resolve. +// import {input, templateCompositeFrom} from '#composite'; -import {exitWithoutDependency, withResultOfAvailabilityCheck} - from '#composite/control-flow'; +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; import {withResolvedReference} from '#composite/wiki-data'; import {soupyFind} from '#composite/wiki-properties'; @@ -15,56 +17,121 @@ export default templateCompositeFrom({ annotation: `withMainRelease`, inputs: { - selfIfMain: input({type: 'boolean', defaultValue: false}), + from: input({ + defaultDependency: 'mainRelease', + acceptsNull: true, + }), + notFoundValue: input({defaultValue: null}), }, outputs: ['#mainRelease'], steps: () => [ - withResultOfAvailabilityCheck({ - from: 'mainReleaseTrack', + raiseOutputWithoutDependency({ + dependency: input('from'), + output: input.value({'#mainRelease': null}), }), { + dependencies: [input('from'), 'name'], + compute: (continuation, { + [input('from')]: ref, + ['name']: ownName, + }) => + (ref === 'same name single' + ? continuation({ + ['#albumOrTrackReference']: null, + ['#sameNameSingleReference']: ownName, + }) + : continuation({ + ['#albumOrTrackReference']: ref, + ['#sameNameSingleReference']: null, + })), + }, + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('trackMainReleasesOnly'), + }).outputs({ + '#resolvedReference': '#matchingTrack', + }), + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('album'), + }).outputs({ + '#resolvedReference': '#matchingAlbum', + }), + + withResolvedReference({ + ref: '#sameNameSingleReference', + find: soupyFind.input('albumSinglesOnly'), + findOptions: input.value({ + fuzz: { + capitalization: true, + kebab: true, + }, + }), + }).outputs({ + '#resolvedReference': '#sameNameSingle', + }), + + { + dependencies: ['#sameNameSingle'], + compute: (continuation, { + ['#sameNameSingle']: sameNameSingle, + }) => + (sameNameSingle + ? continuation.raiseOutput({ + ['#mainRelease']: + sameNameSingle, + }) + : continuation()), + }, + + { dependencies: [ - input.myself(), - input('selfIfMain'), - '#availability', + '#matchingTrack', + '#matchingAlbum', + input('notFoundValue'), ], compute: (continuation, { - [input.myself()]: track, - [input('selfIfMain')]: selfIfMain, - '#availability': availability, + ['#matchingTrack']: matchingTrack, + ['#matchingAlbum']: matchingAlbum, + [input('notFoundValue')]: notFoundValue, }) => - (availability + (matchingTrack && matchingAlbum ? continuation() - : continuation.raiseOutput({ + : matchingTrack ?? matchingAlbum + ? continuation.raiseOutput({ ['#mainRelease']: - (selfIfMain ? track : null), - })), + matchingTrack ?? matchingAlbum, + }) + : continuation.exit(notFoundValue)), }, - withResolvedReference({ - ref: 'mainReleaseTrack', - find: soupyFind.input('track'), - }), - - exitWithoutDependency({ - dependency: '#resolvedReference', - value: input('notFoundValue'), + withPropertyFromObject({ + object: '#matchingAlbum', + property: input.value('tracks'), }), { - dependencies: ['#resolvedReference'], + dependencies: [ + '#matchingAlbum.tracks', + '#matchingTrack', + input('notFoundValue'), + ], compute: (continuation, { - ['#resolvedReference']: resolvedReference, + ['#matchingAlbum.tracks']: matchingAlbumTracks, + ['#matchingTrack']: matchingTrack, + [input('notFoundValue')]: notFoundValue, }) => - continuation({ - ['#mainRelease']: resolvedReference, - }), + (matchingAlbumTracks.includes(matchingTrack) + ? continuation.raiseOutput({'#mainRelease': matchingTrack}) + : continuation.exit(notFoundValue)), }, ], }); diff --git a/src/data/composite/things/track/withMainReleaseTrack.js b/src/data/composite/things/track/withMainReleaseTrack.js new file mode 100644 index 00000000..6371e895 --- /dev/null +++ b/src/data/composite/things/track/withMainReleaseTrack.js @@ -0,0 +1,248 @@ +// Just provides the main release of this track as a dependency. +// If this track isn't a secondary release, then it'll provide null, unless +// the {selfIfMain} option is set, in which case it'll provide this track +// itself. This will early exit (with notFoundValue) if the main release +// is specified by reference and that reference doesn't resolve to anything. + +import {input, templateCompositeFrom} from '#composite'; +import {onlyItem} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +import { + exitWithoutDependency, + withAvailabilityFilter, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + withFilteredList, + withMappedList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import withMainRelease from './withMainRelease.js'; + +export default templateCompositeFrom({ + annotation: `withMainReleaseTrack`, + + inputs: { + selfIfMain: input({type: 'boolean', defaultValue: false}), + notFoundValue: input({defaultValue: null}), + }, + + outputs: ['#mainReleaseTrack'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: 'mainRelease', + }), + + { + dependencies: [ + input.myself(), + input('selfIfMain'), + '#availability', + ], + + compute: (continuation, { + [input.myself()]: track, + [input('selfIfMain')]: selfIfMain, + '#availability': availability, + }) => + (availability + ? continuation() + : continuation.raiseOutput({ + ['#mainReleaseTrack']: + (selfIfMain ? track : null), + })), + }, + + withMainRelease(), + + exitWithoutDependency({ + dependency: '#mainRelease', + value: input('notFoundValue'), + }), + + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('isTrack'), + }), + + { + dependencies: ['#mainRelease', '#mainRelease.isTrack'], + + compute: (continuation, { + ['#mainRelease']: mainRelease, + ['#mainRelease.isTrack']: mainReleaseIsTrack, + }) => + (mainReleaseIsTrack + ? continuation.raiseOutput({ + ['#mainReleaseTrack']: mainRelease, + }) + : continuation()), + }, + + { + dependencies: ['name', 'directory'], + compute: (continuation, { + ['name']: ownName, + ['directory']: ownDirectory, + }) => { + const ownNameKebabed = getKebabCase(ownName); + + return continuation({ + ['#mapItsNameLikeName']: + name => getKebabCase(name) === ownNameKebabed, + + ['#mapItsDirectoryLikeDirectory']: + (ownDirectory + ? directory => directory === ownDirectory + : () => false), + + ['#mapItsNameLikeDirectory']: + (ownDirectory + ? name => getKebabCase(name) === ownDirectory + : () => false), + + ['#mapItsDirectoryLikeName']: + directory => directory === ownNameKebabed, + }); + }, + }, + + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('tracks'), + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('mainRelease'), + internal: input.value(true), + }), + + withAvailabilityFilter({ + from: '#mainRelease.tracks.mainRelease', + }), + + withMappedList({ + list: '#availabilityFilter', + map: input.value(item => !item), + }).outputs({ + '#mappedList': '#availabilityFilter', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#availabilityFilter', + }).outputs({ + '#filteredList': '#mainRelease.tracks', + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('name'), + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('directory'), + internal: input.value(true), + }), + + withMappedList({ + list: '#mainRelease.tracks.name', + map: '#mapItsNameLikeName', + }).outputs({ + '#mappedList': '#filterItsNameLikeName', + }), + + withMappedList({ + list: '#mainRelease.tracks.directory', + map: '#mapItsDirectoryLikeDirectory', + }).outputs({ + '#mappedList': '#filterItsDirectoryLikeDirectory', + }), + + withMappedList({ + list: '#mainRelease.tracks.name', + map: '#mapItsNameLikeDirectory', + }).outputs({ + '#mappedList': '#filterItsNameLikeDirectory', + }), + + withMappedList({ + list: '#mainRelease.tracks.directory', + map: '#mapItsDirectoryLikeName', + }).outputs({ + '#mappedList': '#filterItsDirectoryLikeName', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsNameLikeName', + }).outputs({ + '#filteredList': '#matchingItsNameLikeName', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsDirectoryLikeDirectory', + }).outputs({ + '#filteredList': '#matchingItsDirectoryLikeDirectory', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsNameLikeDirectory', + }).outputs({ + '#filteredList': '#matchingItsNameLikeDirectory', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsDirectoryLikeName', + }).outputs({ + '#filteredList': '#matchingItsDirectoryLikeName', + }), + + { + dependencies: [ + '#matchingItsNameLikeName', + '#matchingItsDirectoryLikeDirectory', + '#matchingItsNameLikeDirectory', + '#matchingItsDirectoryLikeName', + ], + + compute: (continuation, { + ['#matchingItsNameLikeName']: NLN, + ['#matchingItsDirectoryLikeDirectory']: DLD, + ['#matchingItsNameLikeDirectory']: NLD, + ['#matchingItsDirectoryLikeName']: DLN, + }) => continuation({ + ['#mainReleaseTrack']: + onlyItem(DLD) ?? + onlyItem(NLN) ?? + onlyItem(DLN) ?? + onlyItem(NLD) ?? + null, + }), + }, + + { + dependencies: ['#mainReleaseTrack', input.myself()], + + compute: (continuation, { + ['#mainReleaseTrack']: mainReleaseTrack, + [input.myself()]: thisTrack, + }) => continuation({ + ['#mainReleaseTrack']: + (mainReleaseTrack === thisTrack + ? null + : mainReleaseTrack), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js index 0639742f..bb3e8983 100644 --- a/src/data/composite/things/track/withOtherReleases.js +++ b/src/data/composite/things/track/withOtherReleases.js @@ -3,9 +3,6 @@ import {input, templateCompositeFrom} from '#composite'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - import withAllReleases from './withAllReleases.js'; export default templateCompositeFrom({ diff --git a/src/data/composite/things/track/withPropertyFromMainRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js index 393a4c63..c6f65653 100644 --- a/src/data/composite/things/track/withPropertyFromMainRelease.js +++ b/src/data/composite/things/track/withPropertyFromMainRelease.js @@ -10,10 +10,10 @@ import {input, templateCompositeFrom} from '#composite'; import {withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import withMainRelease from './withMainRelease.js'; +import withMainReleaseTrack from './withMainReleaseTrack.js'; export default templateCompositeFrom({ - annotation: `inheritFromMainRelease`, + annotation: `withPropertyFromMainRelease`, inputs: { property: input({type: 'string'}), @@ -32,12 +32,12 @@ export default templateCompositeFrom({ : ['#mainReleaseValue'])), steps: () => [ - withMainRelease({ + withMainReleaseTrack({ notFoundValue: input('notFoundValue'), }), withResultOfAvailabilityCheck({ - from: '#mainRelease', + from: '#mainReleaseTrack', }), { @@ -61,7 +61,7 @@ export default templateCompositeFrom({ }, withPropertyFromObject({ - object: '#mainRelease', + object: '#mainReleaseTrack', property: input('property'), }), diff --git a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js index 7159a3f4..30c777b6 100644 --- a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js +++ b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js @@ -1,8 +1,9 @@ import {input, templateCompositeFrom} from '#composite'; import {withResultOfAvailabilityCheck} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withContainingTrackSection from './withContainingTrackSection.js'; export default templateCompositeFrom({ annotation: `withSuffixDirectoryFromAlbum`, @@ -36,18 +37,13 @@ export default templateCompositeFrom({ : continuation()), }, - withPropertyFromAlbum({ + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', property: input.value('suffixTrackDirectories'), + }).outputs({ + '#trackSection.suffixTrackDirectories': '#suffixDirectoryFromAlbum', }), - - { - dependencies: ['#album.suffixTrackDirectories'], - compute: (continuation, { - ['#album.suffixTrackDirectories']: suffixTrackDirectories, - }) => continuation({ - ['#suffixDirectoryFromAlbum']: - suffixTrackDirectories, - }), - }, ], }); diff --git a/src/data/composite/wiki-data/exitWithoutArtwork.js b/src/data/composite/wiki-data/exitWithoutArtwork.js new file mode 100644 index 00000000..8e799fda --- /dev/null +++ b/src/data/composite/wiki-data/exitWithoutArtwork.js @@ -0,0 +1,45 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList, isThing, strictArrayOf} from '#validators'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +import withHasArtwork from './withHasArtwork.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutArtwork`, + + inputs: { + contribs: input({ + validate: isContributionList, + defaultValue: null, + }), + + artwork: input({ + validate: isThing, + defaultValue: null, + }), + + artworks: input({ + validate: strictArrayOf(isThing), + defaultValue: null, + }), + + value: input({ + defaultValue: null, + }), + }, + + steps: () => [ + withHasArtwork({ + contribs: input('contribs'), + artwork: input('artwork'), + artworks: input('artworks'), + }), + + exitWithoutDependency({ + dependency: '#hasArtwork', + mode: input.value('falsy'), + value: input('value'), + }), + ], +}); diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 38afc2ac..d70d7c56 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -5,8 +5,10 @@ // export {default as exitWithoutContribs} from './exitWithoutContribs.js'; +export {default as exitWithoutArtwork} from './exitWithoutArtwork.js'; export {default as gobbleSoupyFind} from './gobbleSoupyFind.js'; export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js'; +export {default as inputFindOptions} from './inputFindOptions.js'; export {default as inputNotFoundMode} from './inputNotFoundMode.js'; export {default as inputSoupyFind} from './inputSoupyFind.js'; export {default as inputSoupyReverse} from './inputSoupyReverse.js'; @@ -16,8 +18,8 @@ export {default as withClonedThings} from './withClonedThings.js'; export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; export {default as withContentNodes} from './withContentNodes.js'; export {default as withContributionListSums} from './withContributionListSums.js'; -export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; +export {default as withHasArtwork} from './withHasArtwork.js'; export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; export {default as withRedatedContributionList} from './withRedatedContributionList.js'; export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js'; diff --git a/src/data/composite/wiki-data/inputFindOptions.js b/src/data/composite/wiki-data/inputFindOptions.js new file mode 100644 index 00000000..07ed4bce --- /dev/null +++ b/src/data/composite/wiki-data/inputFindOptions.js @@ -0,0 +1,5 @@ +import {input} from '#composite'; + +export default function inputFindOptions() { + return input({type: 'object', defaultValue: null}); +} diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js index 6187d55b..28d719e2 100644 --- a/src/data/composite/wiki-data/withConstitutedArtwork.js +++ b/src/data/composite/wiki-data/withConstitutedArtwork.js @@ -1,6 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import thingConstructors from '#things'; -import {isContributionList} from '#validators'; export default templateCompositeFrom({ annotation: `withConstitutedArtwork`, diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/wiki-data/withHasArtwork.js index fd3f2894..9c22f439 100644 --- a/src/data/composite/things/album/withHasCoverArt.js +++ b/src/data/composite/wiki-data/withHasArtwork.js @@ -1,7 +1,5 @@ -// TODO: This shouldn't be coded as an Album-specific thing, -// or even really to do with cover artworks in particular, either. - import {input, templateCompositeFrom} from '#composite'; +import {isContributionList, isThing, strictArrayOf} from '#validators'; import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} from '#composite/control-flow'; @@ -9,13 +7,30 @@ import {fillMissingListItems, withFlattenedList, withPropertyFromList} from '#composite/data'; export default templateCompositeFrom({ - annotation: 'withHasCoverArt', + annotation: 'withHasArtwork', + + inputs: { + contribs: input({ + validate: isContributionList, + defaultValue: null, + }), + + artwork: input({ + validate: isThing, + defaultValue: null, + }), + + artworks: input({ + validate: strictArrayOf(isThing), + defaultValue: null, + }), + }, - outputs: ['#hasCoverArt'], + outputs: ['#hasArtwork'], steps: () => [ withResultOfAvailabilityCheck({ - from: 'coverArtistContribs', + from: input('contribs'), mode: input.value('empty'), }), @@ -26,19 +41,37 @@ export default templateCompositeFrom({ }) => (availability ? continuation.raiseOutput({ - ['#hasCoverArt']: true, + ['#hasArtwork']: true, }) : continuation()), }, + { + dependencies: [input('artwork'), input('artworks')], + compute: (continuation, { + [input('artwork')]: artwork, + [input('artworks')]: artworks, + }) => + continuation({ + ['#artworks']: + (artwork && artworks + ? [artwork, ...artworks] + : artwork + ? [artwork] + : artworks + ? artworks + : []), + }), + }, + raiseOutputWithoutDependency({ - dependency: 'coverArtworks', + dependency: '#artworks', mode: input.value('empty'), - output: input.value({'#hasCoverArt': false}), + output: input.value({'#hasArtwork': false}), }), withPropertyFromList({ - list: 'coverArtworks', + list: '#artworks', property: input.value('artistContribs'), internal: input.value(true), }), @@ -46,19 +79,19 @@ export default templateCompositeFrom({ // Since we're getting the update value for each artwork's artistContribs, // it may not be set at all, and in that case won't be exposing as []. fillMissingListItems({ - list: '#coverArtworks.artistContribs', + list: '#artworks.artistContribs', fill: input.value([]), }), withFlattenedList({ - list: '#coverArtworks.artistContribs', + list: '#artworks.artistContribs', }), withResultOfAvailabilityCheck({ from: '#flattenedList', mode: input.value('empty'), }).outputs({ - '#availability': '#hasCoverArt', + '#availability': '#hasArtwork', }), ], }); diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js index 9cc52f29..670dc422 100644 --- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js @@ -7,6 +7,7 @@ import {withPropertyFromList} from '#composite/data'; import {raiseOutputWithoutDependency, withAvailabilityFilter} from '#composite/control-flow'; +import inputFindOptions from './inputFindOptions.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputWikiData from './inputWikiData.js'; @@ -28,6 +29,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), notFoundMode: inputNotFoundMode(), }, @@ -61,6 +63,7 @@ export default templateCompositeFrom({ list: '#references', data: input('data'), find: input('find'), + findOptions: input('findOptions'), notFoundMode: input.value('null'), }), diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js index 6f422194..d9a05367 100644 --- a/src/data/composite/wiki-data/withResolvedReference.js +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -8,6 +8,7 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputFindOptions from './inputFindOptions.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; @@ -17,8 +18,9 @@ export default templateCompositeFrom({ inputs: { ref: input({type: 'string', acceptsNull: true}), - data: inputWikiData({allowMixedTypes: false}), + data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), }, outputs: ['#resolvedReference'], @@ -36,21 +38,35 @@ export default templateCompositeFrom({ }), { + dependencies: [input('findOptions')], + compute: (continuation, { + [input('findOptions')]: findOptions, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet'} + : {mode: 'quiet'}), + }), + }, + + { dependencies: [ input('ref'), input('data'), '#find', + '#findOptions', ], compute: (continuation, { [input('ref')]: ref, [input('data')]: data, ['#find']: findFunction, + ['#findOptions']: findOptions, }) => continuation({ ['#resolvedReference']: (data - ? findFunction(ref, data, {mode: 'quiet'}) ?? null - : findFunction(ref, {mode: 'quiet'}) ?? null), + ? findFunction(ref, data, findOptions) ?? null + : findFunction(ref, findOptions) ?? null), }), }, ], diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js index 9dc960dd..14ce6919 100644 --- a/src/data/composite/wiki-data/withResolvedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -11,6 +11,7 @@ import {raiseOutputWithoutDependency, withAvailabilityFilter} import {withMappedList} from '#composite/data'; import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputFindOptions from './inputFindOptions.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; @@ -27,6 +28,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), notFoundMode: inputNotFoundMode(), }, @@ -47,15 +49,28 @@ export default templateCompositeFrom({ }), { - dependencies: [input('data'), '#find'], + dependencies: [input('findOptions')], + compute: (continuation, { + [input('findOptions')]: findOptions, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet'} + : {mode: 'quiet'}), + }), + }, + + { + dependencies: [input('data'), '#find', '#findOptions'], compute: (continuation, { [input('data')]: data, ['#find']: findFunction, + ['#findOptions']: findOptions, }) => continuation({ ['#map']: (data - ? ref => findFunction(ref, data, {mode: 'quiet'}) - : ref => findFunction(ref, {mode: 'quiet'})), + ? ref => findFunction(ref, data, findOptions) + : ref => findFunction(ref, findOptions)), }), }, diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js index 8e6c96a1..aea0f22c 100644 --- a/src/data/composite/wiki-properties/annotatedReferenceList.js +++ b/src/data/composite/wiki-properties/annotatedReferenceList.js @@ -9,8 +9,13 @@ import { } from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedAnnotatedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -25,6 +30,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), reference: input.staticValue({type: 'string', defaultValue: 'reference'}), annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}), @@ -57,6 +63,7 @@ export default templateCompositeFrom({ data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}), diff --git a/src/data/composite/wiki-properties/canonicalBase.js b/src/data/composite/wiki-properties/canonicalBase.js new file mode 100644 index 00000000..81740d6c --- /dev/null +++ b/src/data/composite/wiki-properties/canonicalBase.js @@ -0,0 +1,16 @@ +import {isURL} from '#validators'; + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isURL}, + expose: { + transform: (value) => + (value === null + ? null + : value.endsWith('/') + ? value + : value + '/'), + }, + }; +} diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index e8f109d3..57a2b8f2 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -4,6 +4,7 @@ // #composite/data, and #composite/wiki-data. export {default as annotatedReferenceList} from './annotatedReferenceList.js'; +export {default as canonicalBase} from './canonicalBase.js'; export {default as color} from './color.js'; export {default as commentatorArtists} from './commentatorArtists.js'; export {default as constitutibleArtwork} from './constitutibleArtwork.js'; diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js index 4f8207b5..663349ee 100644 --- a/src/data/composite/wiki-properties/referenceList.js +++ b/src/data/composite/wiki-properties/referenceList.js @@ -11,8 +11,13 @@ import {input, templateCompositeFrom} from '#composite'; import {validateReferenceList} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedReferenceList} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -27,6 +32,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), }, update: @@ -39,6 +45,7 @@ export default templateCompositeFrom({ list: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReferenceList'}), diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js index 9ba2e393..4f243493 100644 --- a/src/data/composite/wiki-properties/referencedArtworkList.js +++ b/src/data/composite/wiki-properties/referencedArtworkList.js @@ -1,6 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; -import {isDate} from '#validators'; import annotatedReferenceList from './annotatedReferenceList.js'; diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js index f532ebbe..25b97907 100644 --- a/src/data/composite/wiki-properties/singleReference.js +++ b/src/data/composite/wiki-properties/singleReference.js @@ -8,11 +8,19 @@ // import {input, templateCompositeFrom} from '#composite'; -import {isThingClass, validateReference} from '#validators'; +import {validateReference} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedReference} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReference, +} from '#composite/wiki-data'; + +import {referenceListInputDescriptions, referenceListUpdateDescription} + from './helpers/reference-list-helpers.js'; export default templateCompositeFrom({ annotation: `singleReference`, @@ -20,25 +28,24 @@ export default templateCompositeFrom({ compose: false, inputs: { - class: input.staticValue({validate: isThingClass}), + ...referenceListInputDescriptions(), + data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), - data: inputWikiData({allowMixedTypes: false}), + findOptions: inputFindOptions(), }, - update: ({ - [input.staticValue('class')]: thingClass, - }) => ({ - validate: - validateReference( - thingClass[Symbol.for('Thing.referenceType')]), - }), + update: + referenceListUpdateDescription({ + validateReferenceList: validateReference, + }), steps: () => [ withResolvedReference({ ref: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReference'}), diff --git a/src/data/language.js b/src/data/language.js index 3edf7e51..e97267c0 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -4,12 +4,10 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; -import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. import yaml from 'js-yaml'; import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} from '#aggregate'; -import {externalLinkSpec} from '#external-links'; import {colors, logWarn} from '#cli'; import {empty, splitKeys, withEntries} from '#sugar'; import T from '#things'; @@ -248,19 +246,8 @@ async function processLanguageSpecFromFile(file, processLanguageSpecOpts) { } } -export function initializeLanguageObject() { - const language = new Language(); - - language.escapeHTML = string => - he.encode(string, {useNamedReferences: true}); - - language.externalLinkSpec = externalLinkSpec; - - return language; -} - export async function processLanguageFile(file) { - const language = initializeLanguageObject(); + const language = new Language() const properties = await processLanguageSpecFromFile(file); return Object.assign(language, properties); } @@ -271,7 +258,7 @@ export function watchLanguageFile(file, { const basename = path.basename(file); const events = new EventEmitter(); - const language = initializeLanguageObject(); + const language = new Language(); let emittedReady = false; let successfullyAppliedLanguage = false; diff --git a/src/data/thing.js b/src/data/thing.js index 66f73de5..4fbad5f5 100644 --- a/src/data/thing.js +++ b/src/data/thing.js @@ -60,7 +60,7 @@ export default class Thing extends CacheableObject { if (this.name) { name = colors.green(`"${this.name}"`); } - } catch (error) { + } catch { name = colors.yellow(`couldn't get name`); } @@ -69,7 +69,7 @@ export default class Thing extends CacheableObject { if (this.directory) { reference = colors.blue(Thing.getReference(this)); } - } catch (error) { + } catch { reference = colors.yellow(`couldn't get reference`); } @@ -84,7 +84,13 @@ export default class Thing extends CacheableObject { } if (!thing.directory) { - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + if (thing.name) { + throw TypeError( + `Passed ${thing.constructor.name} (named ${inspect(thing.name)}) ` + + `is missing its directory`); + } else { + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + } } return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js index 2ddc688a..b15f62e0 100644 --- a/src/data/things/additional-file.js +++ b/src/data/things/additional-file.js @@ -2,13 +2,12 @@ import {input} from '#composite'; import Thing from '#thing'; import {isString, validateArrayItems} from '#validators'; -import {contentString, simpleString, thing} from '#composite/wiki-properties'; - import {exposeConstant, exposeUpdateValueOrContinue} from '#composite/control-flow'; +import {contentString, simpleString, thing} from '#composite/wiki-properties'; export class AdditionalFile extends Thing { - static [Thing.getPropertyDescriptors] = ({}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose thing: thing(), @@ -26,6 +25,14 @@ export class AdditionalFile extends Thing { value: input.value([]), }), ], + + // Expose only + + isAdditionalFile: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js index b96fcd50..99f3ee46 100644 --- a/src/data/things/additional-name.js +++ b/src/data/things/additional-name.js @@ -1,15 +1,25 @@ +import {input} from '#composite'; import Thing from '#thing'; -import {contentString, simpleString, thing} from '#composite/wiki-properties'; +import {exposeConstant} from '#composite/control-flow'; +import {contentString, thing} from '#composite/wiki-properties'; export class AdditionalName extends Thing { - static [Thing.getPropertyDescriptors] = ({}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose thing: thing(), name: contentString(), annotation: contentString(), + + // Expose only + + isAdditionalName: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { diff --git a/src/data/things/album.js b/src/data/things/album.js index a4c2f6a5..58d5253c 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -8,9 +8,18 @@ import {colors} from '#cli'; import {input} from '#composite'; import {traverse} from '#node-utils'; import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {accumulateSum, empty} from '#sugar'; +import {empty} from '#sugar'; import Thing from '#thing'; -import {isColor, isDate, isDirectory, isNumber} from '#validators'; + +import { + is, + isBoolean, + isColor, + isContributionList, + isDate, + isDirectory, + isNumber, +} from '#validators'; import { parseAdditionalFiles, @@ -25,12 +34,22 @@ import { parseWallpaperParts, } from '#yaml'; -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import {exitWithoutContribs, withDirectory, withCoverArtDate} - from '#composite/wiki-data'; +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + exitWithoutArtwork, + withDirectory, + withHasArtwork, + withResolvedContribs, +} from '#composite/wiki-data'; import { color, @@ -47,7 +66,6 @@ import { name, referencedArtworkList, referenceList, - reverseReferenceList, simpleDate, simpleString, soupyFind, @@ -59,7 +77,7 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withHasCoverArt, withTracks} from '#composite/things/album'; +import {withCoverArtDate, withTracks} from '#composite/things/album'; import {withAlbum, withContinueCountingFrom, withStartCountingFrom} from '#composite/things/track-section'; @@ -74,11 +92,16 @@ export class Album extends Thing { CommentaryEntry, CreditingSourcesEntry, Group, - Track, TrackSection, WikiInfo, }) => ({ - // Update & expose + // > Update & expose - Internal relationships + + trackSections: thingList({ + class: input.value(TrackSection), + }), + + // > Update & expose - Identifying metadata name: name('Unnamed Album'), directory: directory(), @@ -99,20 +122,109 @@ export class Album extends Thing { alwaysReferenceTracksByDirectory: flag(false), suffixTrackDirectories: flag(false), - color: color(), - urls: urls(), + style: [ + exposeUpdateValueOrContinue({ + validate: input.value(is(...[ + 'album', + 'single', + ])), + }), - additionalNames: thingList({ - class: input.value(AdditionalName), - }), + exposeConstant({ + value: input.value('album'), + }), + ], bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), + date: simpleDate(), - trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), + // > Update & expose - Credits and contributors + + artistContribs: contributionList({ + date: 'date', + artistProperty: input.value('albumArtistContributions'), + }), + + trackArtistText: contentString(), + + trackArtistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + date: 'date', + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#trackArtistContribs', + mode: input.value('empty'), + }), + + withResolvedContribs({ + from: 'artistContribs', + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + date: 'date', + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', + }), + + exposeDependency({dependency: '#trackArtistContribs'}), + ], + + // > Update & expose - General configuration + + countTracksInArtistTotals: flag(true), + + showAlbumInTracksWithoutArtists: flag(false), + + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + hideDuration: flag(false), + + // > Update & expose - General metadata + + color: color(), + + urls: urls(), + + // > Update & expose - Artworks + + coverArtworks: [ + // This works, lol, because this array describes `expose.transform` for + // the coverArtworks property, and compositions generally access the + // update value, not what's exposed by property access out in the open. + // There's no recursion going on here. + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + ], + + coverArtistContribs: [ + withCoverArtDate(), + + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumCoverArtistContributions'), + }), + ], + coverArtDate: [ withCoverArtDate({ from: input.updateValue({ @@ -124,52 +236,61 @@ export class Album extends Thing { ], coverArtFileExtension: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), + fileExtension('jpg'), ], - trackCoverArtFileExtension: fileExtension('jpg'), + coverArtDimensions: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), - wallpaperFileExtension: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - fileExtension('jpg'), + dimensions(), ], - bannerFileExtension: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - fileExtension('jpg'), - ], + artTags: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + value: input.value([]), + }), - wallpaperStyle: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - simpleString(), + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), ], - wallpaperParts: [ - exitWithoutContribs({ - contribs: 'wallpaperArtistContribs', + referencedArtworks: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', value: input.value([]), }), - wallpaperParts(), + referencedArtworkList(), ], - bannerStyle: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - simpleString(), - ], + trackCoverArtistContribs: contributionList({ + // May be null, indicating cover art was added for tracks on the date + // each track specifies, or else the track's own release date. + date: 'trackArtDate', - coverArtDimensions: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - dimensions(), - ], + // This is the "correct" value, but it gets overwritten - with the same + // value - regardless. + artistProperty: input.value('trackCoverArtistContributions'), + }), - trackDimensions: dimensions(), + trackArtDate: simpleDate(), - bannerDimensions: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - dimensions(), - ], + trackCoverArtFileExtension: fileExtension('jpg'), + + trackDimensions: dimensions(), wallpaperArtwork: [ exitWithoutDependency({ @@ -182,119 +303,115 @@ export class Album extends Thing { .call(this, 'Wallpaper Artwork'), ], - bannerArtwork: [ - exitWithoutDependency({ - dependency: 'bannerArtistContribs', - mode: input.value('empty'), - value: input.value(null), - }), + wallpaperArtistContribs: [ + withCoverArtDate(), - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Banner Artwork'), + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumWallpaperArtistContributions'), + }), ], - coverArtworks: [ - withHasCoverArt(), - - exitWithoutDependency({ - dependency: '#hasCoverArt', - mode: input.value('falsy'), - value: input.value([]), + wallpaperFileExtension: [ + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', }), - constitutibleArtworkList.fromYAMLFieldSpec - .call(this, 'Cover Artwork'), + fileExtension('jpg'), ], - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), + wallpaperStyle: [ + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', + }), - commentary: thingList({ - class: input.value(CommentaryEntry), - }), + simpleString(), + ], - creditSources: thingList({ - class: input.value(CreditingSourcesEntry), - }), + wallpaperParts: [ + // kinda nonsensical or at least unlikely lol, but y'know + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', + value: input.value([]), + }), - additionalFiles: thingList({ - class: input.value(AdditionalFile), - }), + wallpaperParts(), + ], - trackSections: thingList({ - class: input.value(TrackSection), - }), + bannerArtwork: [ + exitWithoutDependency({ + dependency: 'bannerArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), - artistContribs: contributionList({ - date: 'date', - artistProperty: input.value('albumArtistContributions'), - }), + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], - coverArtistContribs: [ + bannerArtistContribs: [ withCoverArtDate(), contributionList({ date: '#coverArtDate', - artistProperty: input.value('albumCoverArtistContributions'), + artistProperty: input.value('albumBannerArtistContributions'), }), ], - trackCoverArtistContribs: contributionList({ - // May be null, indicating cover art was added for tracks on the date - // each track specifies, or else the track's own release date. - date: 'trackArtDate', - - // This is the "correct" value, but it gets overwritten - with the same - // value - regardless. - artistProperty: input.value('trackCoverArtistContributions'), - }), + bannerFileExtension: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', + }), - wallpaperArtistContribs: [ - withCoverArtDate(), + fileExtension('jpg'), + ], - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumWallpaperArtistContributions'), + bannerDimensions: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', }), - ], - bannerArtistContribs: [ - withCoverArtDate(), + dimensions(), + ], - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumBannerArtistContributions'), + bannerStyle: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', }), + + simpleString(), ], + // > Update & expose - Groups + groups: referenceList({ class: input.value(Group), find: soupyFind.input('group'), }), - artTags: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), + // > Update & expose - Content entries - referenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), - }), - ], + commentary: thingList({ + class: input.value(CommentaryEntry), + }), - referencedArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), - referencedArtworkList(), - ], + // > Update & expose - Additional files - // Update only + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), + + // > Update only find: soupyFind(), reverse: soupyReverse(), @@ -309,13 +426,23 @@ export class Album extends Thing { class: input.value(WikiInfo), }), - // Expose only + // > Expose only + + isAlbum: [ + exposeConstant({ + value: input.value(true), + }), + ], commentatorArtists: commentatorArtists(), hasCoverArt: [ - withHasCoverArt(), - exposeDependency({dependency: '#hasCoverArt'}), + withHasArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), + + exposeDependency({dependency: '#hasArtwork'}), ], hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), @@ -383,6 +510,20 @@ export class Album extends Thing { : [album.name]), }, + albumSinglesOnly: { + referencing: ['album'], + + bindTo: 'albumData', + + incldue: album => + album.style === 'single', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + albumWithArtwork: { referenceTypes: [ 'album', @@ -396,8 +537,8 @@ export class Album extends Thing { album.hasCoverArt, getMatchableNames: album => - (album.alwaysReferenceByDirectory - ? [] + (album.alwaysReferenceByDirectory + ? [] : [album.name]), }, @@ -459,6 +600,9 @@ export class Album extends Thing { albumArtistContributionsBy: soupyReverse.contributionsBy('albumData', 'artistContribs'), + albumTrackArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'trackArtistContribs'), + albumCoverArtistContributionsBy: soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), @@ -478,21 +622,15 @@ export class Album extends Thing { static [Thing.yamlDocumentSpec] = { fields: { - 'Album': {property: 'name'}, + // Identifying metadata + 'Album': {property: 'name'}, 'Directory': {property: 'directory'}, 'Directory Suffix': {property: 'directorySuffix'}, 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Always Reference Tracks By Directory': { - property: 'alwaysReferenceTracksByDirectory', - }, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'}, + 'Style': {property: 'style'}, 'Bandcamp Album ID': { property: 'bandcampAlbumIdentifier', @@ -504,18 +642,61 @@ export class Album extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + 'Date': { property: 'date', transform: parseDate, }, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + // Credits and contributors + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Track Artist Text': { + property: 'trackArtistText', + }, + + 'Track Artists': { + property: 'trackArtistContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Show Album In Tracks Without Artists': { + property: 'showAlbumInTracksWithoutArtists', + }, 'Has Track Numbers': {property: 'hasTrackNumbers'}, 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + 'Hide Duration': {property: 'hideDuration'}, + + // General metadata + + 'Color': {property: 'color'}, + + 'URLs': {property: 'urls'}, + + // Artworks + // (Note - this YAML section is deliberately ordered differently + // than the corresponding property descriptors.) + 'Cover Artwork': { property: 'coverArtworks', transform: @@ -559,27 +740,29 @@ export class Album extends Thing { }), }, + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + 'Cover Art Date': { property: 'coverArtDate', transform: parseDate, }, - 'Default Track Cover Art Date': { - property: 'trackArtDate', - transform: parseDate, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, }, - 'Date Added': { - property: 'dateAddedToWiki', - transform: parseDate, + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, }, - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, - - 'Cover Art Dimensions': { - property: 'coverArtDimensions', - transform: parseDimensions, + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, }, 'Default Track Dimensions': { @@ -593,7 +776,6 @@ export class Album extends Thing { }, 'Wallpaper Style': {property: 'wallpaperStyle'}, - 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, 'Wallpaper Parts': { property: 'wallpaperParts', @@ -605,58 +787,70 @@ export class Album extends Thing { transform: parseContributors, }, - 'Banner Style': {property: 'bannerStyle'}, - 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Banner Dimensions': { property: 'bannerDimensions', transform: parseDimensions, }, - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, + 'Banner Style': {property: 'bannerStyle'}, - 'Credit Sources': { - property: 'creditSources', - transform: parseCreditingSources, - }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, - }, + 'Art Tags': {property: 'artTags'}, 'Referenced Artworks': { property: 'referencedArtworks', transform: parseAnnotatedReferences, }, - 'Franchises': {ignore: true}, + // Groups - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Groups': {property: 'groups'}, + + // Content entries + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Default Track Cover Artists': { - property: 'trackCoverArtistContribs', - transform: parseContributors, + // Additional files + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, }, - 'Groups': {property: 'groups'}, - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ + {message: `Move commentary on singles to the track`, fields: [ + ['Style', 'single'], + 'Commentary', + ]}, + + {message: `Move crediting sources on singles to the track`, fields: [ + ['Style', 'single'], + 'Crediting Sources', + ]}, + + {message: `Move additional names on singles to the track`, fields: [ + ['Style', 'single'], + 'Additional Names', + ]}, + {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [ 'Wallpaper Parts', 'Wallpaper Style', @@ -696,6 +890,7 @@ export class Album extends Thing { const artworkData = []; const commentaryData = []; const creditingSourceData = []; + const referencingSourceData = []; const lyricsData = []; for (const {header: album, entries} of results) { @@ -709,8 +904,6 @@ export class Album extends Thing { isDefaultTrackSection: true, }); - const albumRef = Thing.getReference(album); - const closeCurrentTrackSection = () => { if ( currentTrackSection.isDefaultTrackSection && @@ -744,7 +937,8 @@ export class Album extends Thing { artworkData.push(...entry.trackArtworks); commentaryData.push(...entry.commentary); - creditingSourceData.push(...entry.creditSources); + creditingSourceData.push(...entry.creditingSources); + referencingSourceData.push(...entry.referencingSources); // TODO: As exposed, Track.lyrics tries to inherit from the main // release, which is impossible before the data's been linked. @@ -767,7 +961,7 @@ export class Album extends Thing { } commentaryData.push(...album.commentary); - creditingSourceData.push(...album.creditSources); + creditingSourceData.push(...album.creditingSources); album.trackSections = trackSections; } @@ -780,6 +974,7 @@ export class Album extends Thing { artworkData, commentaryData, creditingSourceData, + referencingSourceData, lyricsData, }; }, @@ -835,19 +1030,55 @@ export class Album extends Thing { artwork.fileExtension, ]; } + + // As of writing, albums don't even have a `duration` property... + // so this function will never be called... but the message stands... + countOwnContributionInDurationTotals(_contrib) { + return false; + } } export class TrackSection extends Thing { static [Thing.friendlyName] = `Track Section`; static [Thing.referenceType] = `track-section`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Track}) => ({ // Update & expose name: name('Unnamed Track Section'), unqualifiedDirectory: directory(), + directorySuffix: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDirectory), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('directorySuffix'), + }), + + exposeDependency({dependency: '#album.directorySuffix'}), + ], + + suffixTrackDirectories: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('suffixTrackDirectories'), + }), + + exposeDependency({dependency: '#album.suffixTrackDirectories'}), + ], + color: [ exposeUpdateValueOrContinue({ validate: input.value(isColor), @@ -873,6 +1104,21 @@ export class TrackSection extends Thing { dateOriginallyReleased: simpleDate(), + countTracksInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + isDefaultTrackSection: flag(false), description: contentString(), @@ -892,6 +1138,12 @@ export class TrackSection extends Thing { // Expose only + isTrackSection: [ + exposeConstant({ + value: input.value(true), + }), + ], + directory: [ withAlbum(), @@ -953,6 +1205,9 @@ export class TrackSection extends Thing { static [Thing.yamlDocumentSpec] = { fields: { 'Section': {property: 'name'}, + 'Directory Suffix': {property: 'directorySuffix'}, + 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Color': {property: 'color'}, 'Start Counting From': {property: 'startCountingFrom'}, @@ -961,6 +1216,8 @@ export class TrackSection extends Thing { transform: parseDate, }, + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + 'Description': {property: 'description'}, }, }; diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 0ec1ff31..fff724cb 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,15 +1,23 @@ +export const DATA_ART_TAGS_DIRECTORY = 'art-tags'; export const ART_TAG_DATA_FILE = 'tags.yaml'; +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; + import {input} from '#composite'; -import find from '#find'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {unique} from '#sugar'; import {isName} from '#validators'; import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { annotatedReferenceList, @@ -24,7 +32,6 @@ import { soupyReverse, thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withAllDescendantArtTags, withAncestorArtTagBaobabTree} @@ -34,11 +41,7 @@ export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({ - AdditionalName, - Album, - Track, - }) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose name: name('Unnamed Art Tag'), @@ -85,6 +88,12 @@ export class ArtTag extends Thing { // Expose only + isArtTag: [ + exposeConstant({ + value: input.value(true), + }), + ], + descriptionShort: [ exitWithoutDependency({ dependency: 'description', @@ -180,13 +189,25 @@ export class ArtTag extends Thing { }; static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, + documentModes: {allTogether}, thingConstructors: {ArtTag}, }) => ({ title: `Process art tags file`, - file: ART_TAG_DATA_FILE, - documentMode: allInOne, + files: dataPath => + Promise.allSettled([ + readFile(path.join(dataPath, ART_TAG_DATA_FILE)) + .then(() => [ART_TAG_DATA_FILE]), + + traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: DATA_ART_TAGS_DIRECTORY, + }), + ]).then(results => results + .filter(({status}) => status === 'fulfilled') + .flatMap(({value}) => value)), + + documentMode: allTogether, documentThing: ArtTag, save: (results) => ({artTagData: results}), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 9e329c74..24c99698 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -5,14 +5,21 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import {sortAlphabetically} from '#sort'; import {stitchArrays} from '#sugar'; import Thing from '#thing'; -import {isName, validateArrayItems} from '#validators'; +import {validateArrayItems} from '#validators'; import {getKebabCase} from '#wiki-data'; -import {parseArtwork} from '#yaml'; +import {parseArtistAliases, parseArtwork} from '#yaml'; -import {exitWithoutDependency} from '#composite/control-flow'; +import { + sortAlbumsTracksChronologically, + sortArtworksChronologically, + sortAlphabetically, + sortContributionsChronologically, +} from '#sort'; + +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; +import {withReverseReferenceList} from '#composite/wiki-data'; import { constitutibleArtwork, @@ -25,8 +32,9 @@ import { singleReference, soupyFind, soupyReverse, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {artistTotalDuration} from '#composite/things/artist'; @@ -35,7 +43,7 @@ export class Artist extends Thing { static [Thing.referenceType] = 'artist'; static [Thing.wikiDataArray] = 'artistData'; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: name('Unnamed Artist'), @@ -57,17 +65,14 @@ export class Artist extends Thing { .call(this, 'Avatar Artwork'), ], - aliasNames: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isName)}, - expose: {transform: (names) => names ?? []}, - }, - isAlias: flag(), - aliasedArtist: singleReference({ + artistAliases: thingList({ + class: input.value(Artist), + }), + + aliasedArtist: thing({ class: input.value(Artist), - find: soupyFind.input('artist'), }), // Update only @@ -77,6 +82,12 @@ export class Artist extends Thing { // Expose only + isArtist: [ + exposeConstant({ + value: input.value(true), + }), + ], + trackArtistContributions: reverseReferenceList({ reverse: soupyReverse.input('trackArtistContributionsBy'), }), @@ -97,6 +108,10 @@ export class Artist extends Thing { reverse: soupyReverse.input('albumArtistContributionsBy'), }), + albumTrackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumTrackArtistContributionsBy'), + }), + albumCoverArtistContributions: reverseReferenceList({ reverse: soupyReverse.input('albumCoverArtistContributionsBy'), }), @@ -125,6 +140,102 @@ export class Artist extends Thing { reverse: soupyReverse.input('groupsCloselyLinkedTo'), }), + musicContributions: [ + withReverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackContributorContribs', + }), + + { + dependencies: [ + '#trackArtistContribs', + '#trackContributorContribs', + ], + + compute: (continuation, { + ['#trackArtistContribs']: trackArtistContribs, + ['#trackContributorContribs']: trackContributorContribs, + }) => continuation({ + ['#contributions']: [ + ...trackArtistContribs, + ...trackContributorContribs, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortAlbumsTracksChronologically), + }, + ], + + artworkContributions: [ + withReverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackCoverArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumCoverArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumWallpaperArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumBannerArtistContribs', + }), + + { + dependencies: [ + '#trackCoverArtistContribs', + '#albumCoverArtistContribs', + '#albumWallpaperArtistContribs', + '#albumBannerArtistContribs', + ], + + compute: (continuation, { + ['#trackCoverArtistContribs']: trackCoverArtistContribs, + ['#albumCoverArtistContribs']: albumCoverArtistContribs, + ['#albumWallpaperArtistContribs']: albumWallpaperArtistContribs, + ['#albumBannerArtistContribs']: albumBannerArtistContribs, + }) => continuation({ + ['#contributions']: [ + ...trackCoverArtistContribs, + ...albumCoverArtistContribs, + ...albumWallpaperArtistContribs, + ...albumBannerArtistContribs, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortArtworksChronologically), + }, + ], + totalDuration: artistTotalDuration(), }); @@ -139,8 +250,6 @@ export class Artist extends Thing { hasAvatar: S.id, avatarFileExtension: S.id, - aliasNames: S.id, - tracksAsCommentator: S.toRefs, albumsAsCommentator: S.toRefs, }); @@ -171,17 +280,9 @@ export class Artist extends Thing { // in the original's alias list. This is honestly a bit awkward, but it // avoids artist aliases conflicting with each other when checking for // duplicate directories. - for (const aliasName of originalArtist.aliasNames) { - // These are trouble. We should be accessing aliases' directories - // directly, but artists currently don't expose a reverse reference - // list for aliases. (This is pending a cleanup of "reverse reference" - // behavior in general.) It doesn't actually cause problems *here* - // because alias directories are computed from their names 100% of the - // time, but that *is* an assumption this code makes. - if (aliasName === artist.name) continue; - if (artist.directory === getKebabCase(aliasName)) { - return []; - } + for (const alias of originalArtist.artistAliases) { + if (alias === artist) break; + if (alias.directory === artist.directory) return []; } // And, aliases never return just a blank string. This part is pretty @@ -221,7 +322,10 @@ export class Artist extends Thing { 'Has Avatar': {property: 'hasAvatar'}, 'Avatar File Extension': {property: 'avatarFileExtension'}, - 'Aliases': {property: 'aliasNames'}, + 'Aliases': { + property: 'artistAliases', + transform: parseArtistAliases, + }, 'Dead URLs': {ignore: true}, @@ -241,26 +345,7 @@ export class Artist extends Thing { save(results) { const artists = results; - - const artistRefs = - artists.map(artist => Thing.getReference(artist)); - - const artistAliasNames = - artists.map(artist => artist.aliasNames); - - const artistAliases = - stitchArrays({ - originalArtistRef: artistRefs, - aliasNames: artistAliasNames, - }).flatMap(({originalArtistRef, aliasNames}) => - aliasNames.map(name => { - const alias = new Artist(); - alias.name = name; - alias.isAlias = true; - alias.aliasedArtist = originalArtistRef; - return alias; - })); - + const artistAliases = artists.flatMap(artist => artist.artistAliases); const artistData = [...artists, ...artistAliases]; const artworkData = @@ -287,7 +372,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js index ac70159c..916aac0a 100644 --- a/src/data/things/artwork.js +++ b/src/data/things/artwork.js @@ -1,5 +1,6 @@ import {inspect} from 'node:util'; +import {colors} from '#cli'; import {input} from '#composite'; import find from '#find'; import Thing from '#thing'; @@ -24,7 +25,7 @@ import { parseDimensions, } from '#yaml'; -import {withIndexInList, withPropertyFromObject} from '#composite/data'; +import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; import { exitWithoutDependency, @@ -38,7 +39,6 @@ import { withRecontextualizedContributionList, withResolvedAnnotatedReferenceList, withResolvedContribs, - withResolvedReferenceList, } from '#composite/wiki-data'; import { @@ -54,20 +54,18 @@ import { } from '#composite/wiki-properties'; import { + withArtTags, withAttachedArtwork, withContainingArtworkList, + withContentWarningArtTags, withContribsFromAttachedArtwork, - withPropertyFromAttachedArtwork, withDate, } from '#composite/things/artwork'; export class Artwork extends Thing { static [Thing.referenceType] = 'artwork'; - static [Thing.getPropertyDescriptors] = ({ - ArtTag, - Contribution, - }) => ({ + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ // Update & expose unqualifiedDirectory: directory({ @@ -79,6 +77,8 @@ export class Artwork extends Thing { label: simpleString(), source: contentString(), + originDetails: contentString(), + showFilename: simpleString(), dateFromThingProperty: simpleString(), @@ -171,6 +171,7 @@ export class Artwork extends Thing { withResolvedContribs({ from: input.updateValue({validate: isContributionList}), date: '#date', + thingProperty: input.thisProperty(), artistProperty: 'artistContribsArtistProperty', }), @@ -206,50 +207,21 @@ export class Artwork extends Thing { }), ], + style: simpleString(), + artTagsFromThingProperty: simpleString(), artTags: [ - withResolvedReferenceList({ - list: input.updateValue({ + withArtTags({ + from: input.updateValue({ validate: validateReferenceList(ArtTag[Thing.referenceType]), }), - - find: soupyFind.input('artTag'), - }), - - exposeDependencyOrContinue({ - dependency: '#resolvedReferenceList', - mode: input.value('empty'), - }), - - withPropertyFromAttachedArtwork({ - property: input.value('artTags'), - }), - - exposeDependencyOrContinue({ - dependency: '#attachedArtwork.artTags', - }), - - exitWithoutDependency({ - dependency: 'artTagsFromThingProperty', - value: input.value([]), }), - withPropertyFromObject({ - object: 'thing', - property: 'artTagsFromThingProperty', - }).outputs({ - ['#value']: '#artTags', - }), - - exposeDependencyOrContinue({ + exposeDependency({ dependency: '#artTags', }), - - exposeConstant({ - value: input.value([]), - }), ], referencedArtworksFromThingProperty: simpleString(), @@ -323,6 +295,12 @@ export class Artwork extends Thing { // Expose only + isArtwork: [ + exposeConstant({ + value: input.value(true), + }), + ], + referencedByArtworks: reverseReferenceList({ reverse: soupyReverse.input('artworksWhichReference'), }), @@ -371,6 +349,42 @@ export class Artwork extends Thing { attachingArtworks: reverseReferenceList({ reverse: soupyReverse.input('artworksWhichAttach'), }), + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + contentWarningArtTags: [ + withContentWarningArtTags(), + + exposeDependency({ + dependency: '#contentWarningArtTags', + }), + ], + + contentWarnings: [ + withContentWarningArtTags(), + + withPropertyFromList({ + list: '#contentWarningArtTags', + property: input.value('name'), + }), + + exposeDependency({ + dependency: '#contentWarningArtTags.name', + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -385,6 +399,8 @@ export class Artwork extends Thing { 'Label': {property: 'label'}, 'Source': {property: 'source'}, + 'Origin Details': {property: 'originDetails'}, + 'Show Filename': {property: 'showFilename'}, 'Date': { property: 'date', @@ -398,6 +414,8 @@ export class Artwork extends Thing { transform: parseContributors, }, + 'Style': {property: 'style'}, + 'Tags': {property: 'artTags'}, 'Referenced Artworks': { @@ -456,6 +474,18 @@ export class Artwork extends Thing { return this.thing.getOwnArtworkPath(this); } + countOwnContributionInContributionTotals(contrib) { + if (this.attachAbove) { + return false; + } + + if (contrib.annotation?.startsWith('edits for wiki')) { + return false; + } + + return true; + } + [inspect.custom](depth, options, inspect) { const parts = []; diff --git a/src/data/things/content.js b/src/data/things/content.js index cf8fa1f4..a3dfc183 100644 --- a/src/data/things/content.js +++ b/src/data/things/content.js @@ -1,10 +1,9 @@ import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; import {is, isDate} from '#validators'; import {parseDate} from '#yaml'; -import {contentString, referenceList, simpleDate, soupyFind, thing} +import {contentString, simpleDate, soupyFind, thing} from '#composite/wiki-properties'; import { @@ -27,7 +26,7 @@ import { } from '#composite/things/content'; export class ContentEntry extends Thing { - static [Thing.getPropertyDescriptors] = ({Artist}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose thing: thing(), @@ -51,6 +50,10 @@ export class ContentEntry extends Thing { }, accessKind: [ + exitWithoutDependency({ + dependency: 'accessDate', + }), + exposeUpdateValueOrContinue({ validate: input.value( is(...[ @@ -74,7 +77,7 @@ export class ContentEntry extends Thing { }, exposeConstant({ - value: input.value(null), + value: input.value('accessed'), }), ], @@ -106,6 +109,12 @@ export class ContentEntry extends Thing { // Expose only + isContentEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + annotationParts: [ withAnnotationParts({ mode: input.value('strings'), @@ -148,6 +157,12 @@ export class CommentaryEntry extends ContentEntry { static [Thing.getPropertyDescriptors] = () => ({ // Expose only + isCommentaryEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + isWikiEditorCommentary: hasAnnotationPart({ part: input.value('wiki editor'), }), @@ -156,12 +171,26 @@ export class CommentaryEntry extends ContentEntry { export class LyricsEntry extends ContentEntry { static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + // Expose only + isLyricsEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + isWikiLyrics: hasAnnotationPart({ part: input.value('wiki lyrics'), }), + helpNeeded: hasAnnotationPart({ + part: input.value('help needed'), + }), + hasSquareBracketAnnotations: [ withHasAnnotationPart({ part: input.value('wiki lyrics'), @@ -185,6 +214,34 @@ export class LyricsEntry extends ContentEntry { }, ], }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); } -export class CreditingSourcesEntry extends ContentEntry {} +export class CreditingSourcesEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCreditingSourcesEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); +} + +export class ReferencingSourcesEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isReferencingSourceEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); +} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js index c92fafb4..e1e248cb 100644 --- a/src/data/things/contribution.js +++ b/src/data/things/contribution.js @@ -5,10 +5,18 @@ import {colors} from '#cli'; import {input} from '#composite'; import {empty} from '#sugar'; import Thing from '#thing'; -import {isStringNonEmpty, isThing, validateReference} from '#validators'; +import {isBoolean, isStringNonEmpty, isThing, validateReference} + from '#validators'; -import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; -import {flag, simpleDate, soupyFind} from '#composite/wiki-properties'; +import {simpleDate, soupyFind} from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { withFilteredList, @@ -19,8 +27,6 @@ import { import { inheritFromContributionPresets, - thingPropertyMatches, - thingReferenceTypeMatches, withContainingReverseContributionList, withContributionArtist, withContributionContext, @@ -70,7 +76,26 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], countInDurationTotals: [ @@ -78,7 +103,37 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'thing', + property: input.value('duration'), + }), + + exitWithoutDependency({ + dependency: '#thing.duration', + mode: input.value('falsy'), + value: input.value(false), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], // Update only @@ -87,6 +142,12 @@ export class Contribution extends Thing { // Expose only + isContribution: [ + exposeConstant({ + value: input.value(true), + }), + ], + context: [ withContributionContext(), @@ -167,38 +228,6 @@ export class Contribution extends Thing { }), ], - isArtistContribution: thingPropertyMatches({ - value: input.value('artistContribs'), - }), - - isContributorContribution: thingPropertyMatches({ - value: input.value('contributorContribs'), - }), - - isCoverArtistContribution: thingPropertyMatches({ - value: input.value('coverArtistContribs'), - }), - - isBannerArtistContribution: thingPropertyMatches({ - value: input.value('bannerArtistContribs'), - }), - - isWallpaperArtistContribution: thingPropertyMatches({ - value: input.value('wallpaperArtistContribs'), - }), - - isForTrack: thingReferenceTypeMatches({ - value: input.value('track'), - }), - - isForAlbum: thingReferenceTypeMatches({ - value: input.value('album'), - }), - - isForFlash: thingReferenceTypeMatches({ - value: input.value('flash'), - }), - previousBySameArtist: [ withContainingReverseContributionList().outputs({ '#containingReverseContributionList': '#list', @@ -238,6 +267,21 @@ export class Contribution extends Thing { dependency: '#nearbyItem', }), ], + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], }); [inspect.custom](depth, options, inspect) { @@ -259,7 +303,7 @@ export class Contribution extends Thing { let artist; try { artist = this.artist; - } catch (_error) { + } catch { // Computing artist might crash for any reason - don't distract from // other errors as a result of inspecting this contribution. } diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 11b19ebc..73b22746 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -43,7 +43,6 @@ import { thing, thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withFlashAct} from '#composite/things/flash'; @@ -57,7 +56,6 @@ export class Flash extends Thing { CommentaryEntry, CreditingSourcesEntry, Track, - FlashAct, WikiInfo, }) => ({ // Update & expose @@ -135,7 +133,7 @@ export class Flash extends Thing { class: input.value(CommentaryEntry), }), - creditSources: thingList({ + creditingSources: thingList({ class: input.value(CreditingSourcesEntry), }), @@ -151,6 +149,12 @@ export class Flash extends Thing { // Expose only + isFlash: [ + exposeConstant({ + value: input.value(true), + }), + ], + commentatorArtists: commentatorArtists(), act: [ @@ -257,8 +261,8 @@ export class Flash extends Thing { transform: parseCommentary, }, - 'Credit Sources': { - property: 'creditSources', + 'Crediting Sources': { + property: 'creditingSources', transform: parseCreditingSources, }, @@ -319,6 +323,12 @@ export class FlashAct extends Thing { // Expose only + isFlashAct: [ + exposeConstant({ + value: input.value(true), + }), + ], + side: [ withFlashSide(), exposeDependency({dependency: '#flashSide'}), @@ -374,6 +384,14 @@ export class FlashSide extends Thing { // Update only find: soupyFind(), + + // Expose only + + isFlashSide: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -461,7 +479,7 @@ export class FlashSide extends Thing { const artworkData = flashData.map(flash => flash.coverArtwork); const commentaryData = flashData.flatMap(flash => flash.commentary); - const creditingSourceData = flashData.flatMap(flash => flash.creditSources); + const creditingSourceData = flashData.flatMap(flash => flash.creditingSources); return { flashData, diff --git a/src/data/things/group.js b/src/data/things/group.js index 4b4c306c..0935dc93 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -5,21 +5,31 @@ import {inspect} from 'node:util'; import {colors} from '#cli'; import {input} from '#composite'; import Thing from '#thing'; -import {is} from '#validators'; +import {is, isBoolean} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; +import {withPropertyFromObject} from '#composite/data'; +import {withUniqueReferencingThing} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + import { annotatedReferenceList, color, contentString, directory, + flag, name, referenceList, soupyFind, + soupyReverse, thing, thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { @@ -31,6 +41,33 @@ export class Group extends Thing { name: name('Unnamed Group'), directory: directory(), + excludeFromGalleryTabs: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withUniqueReferencingThing({ + reverse: soupyReverse.input('groupCategoriesWhichInclude'), + }).outputs({ + '#uniqueReferencingThing': '#category', + }), + + withPropertyFromObject({ + object: '#category', + property: input.value('excludeGroupsFromGalleryTabs'), + }), + + exposeDependencyOrContinue({ + dependency: '#category.excludeGroupsFromGalleryTabs', + }), + + exposeConstant({ + value: input.value(false), + }), + ], + + divideAlbumsByStyle: flag(false), + description: contentString(), urls: urls(), @@ -55,10 +92,16 @@ export class Group extends Thing { // Update only find: soupyFind(), - reverse: soupyFind(), + reverse: soupyReverse(), // Expose only + isGroup: [ + exposeConstant({ + value: input.value(true), + }), + ], + descriptionShort: { flags: {expose: true}, @@ -134,6 +177,10 @@ export class Group extends Thing { fields: { 'Group': {property: 'name'}, 'Directory': {property: 'directory'}, + + 'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'}, + 'Divide Albums By Style': {property: 'divideAlbumsByStyle'}, + 'Description': {property: 'description'}, 'URLs': {property: 'urls'}, @@ -218,6 +265,8 @@ export class GroupCategory extends Thing { name: name('Unnamed Group Category'), directory: directory(), + excludeGroupsFromGalleryTabs: flag(false), + color: color(), groups: referenceList({ @@ -228,6 +277,14 @@ export class GroupCategory extends Thing { // Update only find: soupyFind(), + + // Expose only + + isGroupCategory: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.reverseSpecs] = { @@ -242,7 +299,12 @@ export class GroupCategory extends Thing { static [Thing.yamlDocumentSpec] = { fields: { 'Category': {property: 'name'}, + 'Color': {property: 'color'}, + + 'Exclude Groups From Gallery Tabs': { + property: 'excludeGroupsFromGalleryTabs', + }, }, }; } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 82bad2d3..2456ca95 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -17,7 +17,7 @@ import { validateReference, } from '#validators'; -import {exposeDependency} from '#composite/control-flow'; +import {exposeConstant, exposeDependency} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; import { @@ -47,6 +47,14 @@ export class HomepageLayout extends Thing { sections: thingList({ class: input.value(HomepageLayoutSection), }), + + // Expose only + + isHomepageLayout: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -63,7 +71,6 @@ export class HomepageLayout extends Thing { thingConstructors: { HomepageLayout, HomepageLayoutSection, - HomepageLayoutAlbumsRow, }, }) => ({ title: `Process homepage layout file`, @@ -157,6 +164,14 @@ export class HomepageLayoutSection extends Thing { rows: thingList({ class: input.value(HomepageLayoutRow), }), + + // Expose only + + isHomepageLayoutSection: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -183,6 +198,12 @@ export class HomepageLayoutRow extends Thing { // Expose only + isHomepageLayoutRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + type: { flags: {expose: true}, @@ -234,6 +255,12 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow { // Expose only + isHomepageLayoutActionsRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + type: { flags: {expose: true}, expose: {compute: () => 'actions'}, @@ -250,7 +277,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow { export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { static [Thing.friendlyName] = `Homepage Album Carousel Row`; - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose @@ -262,6 +289,12 @@ export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { // Expose only + isHomepageLayoutAlbumCarouselRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + type: { flags: {expose: true}, expose: {compute: () => 'album carousel'}, @@ -322,6 +355,12 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { // Expose only + isHomepageLayoutAlbumGridRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + type: { flags: {expose: true}, expose: {compute: () => 'album grid'}, diff --git a/src/data/things/language.js b/src/data/things/language.js index 4e23cf7f..43f69f3d 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,24 +1,27 @@ -import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; import {withAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; -import {logWarn} from '#cli'; +import {input} from '#composite'; import * as html from '#html'; -import {empty} from '#sugar'; +import {accumulateSum, empty, withEntries} from '#sugar'; import {isLanguageCode} from '#validators'; import Thing from '#thing'; +import {languageOptionRegex} from '#wiki-data'; import { + externalLinkSpec, getExternalLinkStringOfStyleFromDescriptors, getExternalLinkStringsFromDescriptors, isExternalLinkContext, - isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; +import {exitWithoutDependency, exposeConstant, exposeDependency} + from '#composite/control-flow'; import {externalFunction, flag, name} from '#composite/wiki-properties'; -export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; +import {withStrings} from '#composite/things/language'; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ @@ -60,52 +63,17 @@ export class Language extends Thing { // 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', 'code'], - transform(strings, {inheritedStrings, code}) { - if (!strings && !inheritedStrings) return null; - if (!inheritedStrings) return strings; - - const validStrings = { - ...inheritedStrings, - ...strings, - }; - - const optionsFromTemplate = template => - Array.from(template.matchAll(languageOptionRegex)) - .map(({groups}) => groups.name); - - for (const [key, providedTemplate] of Object.entries(strings)) { - const inheritedTemplate = inheritedStrings[key]; - if (!inheritedTemplate) continue; - - const providedOptions = optionsFromTemplate(providedTemplate); - const inheritedOptions = optionsFromTemplate(inheritedTemplate); - - const missingOptionNames = - inheritedOptions.filter(name => !providedOptions.includes(name)); - - const misplacedOptionNames = - providedOptions.filter(name => !inheritedOptions.includes(name)); - - if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) { - logWarn`Not using ${code ?? '(no code)'} string ${key}:`; - if (!empty(missingOptionNames)) - logWarn`- Missing options: ${missingOptionNames.join(', ')}`; - if (!empty(misplacedOptionNames)) - logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; - validStrings[key] = inheritedStrings[key]; - } - } - - return validStrings; - }, - }, - }, + strings: [ + withStrings({ + from: input.updateValue({ + validate: t => typeof t === 'object', + }), + }), + + exposeDependency({ + dependency: '#strings', + }), + ], // May be provided to specify "default" strings, generally (but not // necessarily) inherited from another Language object. @@ -114,19 +82,14 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, - // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to #external-links for info. - externalLinkSpec: { - flags: {update: true, expose: true}, - update: {validate: isExternalLinkSpec}, - }, - - // Update only - - escapeHTML: externalFunction(), - // Expose only + isLanguage: [ + exposeConstant({ + value: input.value(true), + }), + ], + onlyIfOptions: { flags: {expose: true}, expose: { @@ -136,12 +99,14 @@ export class Language extends Thing { intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), + intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}), intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}), intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}), validKeys: { flags: {expose: true}, @@ -159,19 +124,20 @@ export class Language extends Thing { }, // TODO: This currently isn't used. Is it still needed? - 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)]) - ); - }, + strings_htmlEscaped: [ + withStrings(), + + exitWithoutDependency({ + dependency: '#strings', + }), + + { + dependencies: ['#strings'], + compute: ({'#strings': strings}) => + withEntries(strings, entries => entries + .map(([key, value]) => [key, html.escape(value)])), }, - }, + ], }); static #intlHelper (constructor, opts) { @@ -198,12 +164,25 @@ export class Language extends Thing { } } + countWords(text) { + this.assertIntlAvailable('intl_wordSegmenter'); + + const string = html.resolve(text, {normalize: 'plain'}); + const segments = this.intl_wordSegmenter.segment(string); + + return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0); + } + getUnitForm(value) { this.assertIntlAvailable('intl_pluralCardinal'); return this.intl_pluralCardinal.select(value); } formatString(...args) { + if (typeof args.at(-1) === 'function') { + throw new Error(`Passed function - did you mean language.encapsulate() instead?`); + } + const hasOptions = typeof args.at(-1) === 'object' && args.at(-1) !== null; @@ -310,7 +289,7 @@ export class Language extends Thing { return undefined; } - return optionValue; + return this.sanitize(optionValue); }, }); @@ -375,26 +354,22 @@ export class Language extends Thing { partInProgress += template.slice(lastIndex, match.index); - // Sanitize string arguments in particular. These are taken to come from - // (raw) data and may include special characters that aren't meant to be - // rendered as HTML markup. - const sanitizedInsertion = - this.#sanitizeValueForInsertion(insertion); - - if (typeof sanitizedInsertion === 'string') { - // Join consecutive strings together. - partInProgress += sanitizedInsertion; - } else if ( - sanitizedInsertion instanceof html.Tag && - sanitizedInsertion.contentOnly - ) { - // Collapse string-only tag contents onto the current string part. - partInProgress += sanitizedInsertion.toString(); - } else { - // Push the string part in progress, then the insertion as-is. - outputParts.push(partInProgress); - outputParts.push(sanitizedInsertion); + const insertionItems = html.smush(insertion).content; + if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { + // Push the insertion exactly as it is, rather than manipulating. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertion); partInProgress = ''; + } else for (const insertionItem of insertionItems) { + if (typeof insertionItem === 'string') { + // Join consecutive strings together. + partInProgress += insertionItem; + } else { + // Push the string part in progress, then the insertion as-is. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertionItem); + partInProgress = ''; + } } lastIndex = match.index + match[0].length; @@ -426,14 +401,9 @@ export class Language extends Thing { // html.Tag objects - gets left as-is, preserving the value exactly as it's // provided. #sanitizeValueForInsertion(value) { - const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); - if (!escapeHTML) { - throw new Error(`escapeHTML unavailable`); - } - switch (typeof value) { case 'string': - return escapeHTML(value); + return html.escape(value); case 'number': case 'boolean': @@ -510,6 +480,15 @@ export class Language extends Thing { return this.intl_dateYear.format(date); } + formatMonthDay(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateMonthDay'); + return this.intl_dateMonthDay.format(date); + } + formatYearRange(startDate, endDate) { // formatYearRange expects both values to be present, but if both are null // or both are undefined, that's just blank content. @@ -688,10 +667,6 @@ export class Language extends Thing { style = 'platform', context = 'generic', } = {}) { - if (!this.externalLinkSpec) { - throw new TypeError(`externalLinkSpec unavailable`); - } - // Null or undefined url is blank content. if (url === null || url === undefined) { return html.blank(); @@ -700,7 +675,7 @@ export class Language extends Thing { isExternalLinkContext(context); if (style === 'all') { - return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { language: this, context, }); @@ -709,7 +684,7 @@ export class Language extends Thing { isExternalLinkStyle(style); const result = - getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, { language: this, context, }); @@ -865,6 +840,18 @@ export class Language extends Thing { } } + typicallyLowerCase(string) { + // Utter nonsense implementation, so this only works on strings, + // not actual HTML content, and may rudely disrespect *intentful* + // capitalization of whatever goes into it. + + if (typeof string !== 'string') return string; + if (string.length <= 1) return string; + if (/^\S+?[A-Z]/.test(string)) return string; + + return string[0].toLowerCase() + string.slice(1); + } + // Utility function to quickly provide a useful string key // (generally a prefix) to stuff nested beneath it. encapsulate(...args) { @@ -923,7 +910,6 @@ Object.assign(Language.prototype, { countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43d1638e..28289f53 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,9 +1,11 @@ export const NEWS_DATA_FILE = 'news.yaml'; +import {input} from '#composite'; import {sortChronologically} from '#sort'; import Thing from '#thing'; import {parseDate} from '#yaml'; +import {exposeConstant} from '#composite/control-flow'; import {contentString, directory, name, simpleDate} from '#composite/wiki-properties'; @@ -22,6 +24,12 @@ export class NewsEntry extends Thing { // Expose only + isNewsEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + contentShort: { flags: {expose: true}, diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js index b169a541..8ed3861a 100644 --- a/src/data/things/sorting-rule.js +++ b/src/data/things/sorting-rule.js @@ -22,6 +22,7 @@ import { reorderDocumentsInYAMLSourceText, } from '#yaml'; +import {exposeConstant} from '#composite/control-flow'; import {flag} from '#composite/wiki-properties'; function isSelectFollowingEntry(value) { @@ -47,6 +48,14 @@ export class SortingRule extends Thing { flags: {update: true, expose: true}, update: {validate: isStringNonEmpty}, }, + + // Expose only + + isSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -119,6 +128,14 @@ export class ThingSortingRule extends SortingRule { validate: strictArrayOf(isStringNonEmpty), }, }, + + // Expose only + + isThingSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, { @@ -129,7 +146,7 @@ export class ThingSortingRule extends SortingRule { sort(sortable) { if (this.properties) { - for (const property of this.properties.slice().reverse()) { + for (const property of this.properties.toReversed()) { const get = thing => thing[property]; const lc = property.toLowerCase(); @@ -218,6 +235,14 @@ export class DocumentSortingRule extends ThingSortingRule { flags: {update: true, expose: true}, update: {validate: isStringNonEmpty}, }, + + // Expose only + + isDocumentSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, { @@ -261,10 +286,8 @@ export class DocumentSortingRule extends ThingSortingRule { } static async* applyAll(rules, {wikiData, dataPath, dry}) { - rules = - rules - .slice() - .sort((a, b) => a.filename.localeCompare(b.filename, 'en')); + rules = rules + .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en')); for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) { const initialLayout = getThingLayoutForFilename(filename, wikiData); diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 52a09c31..28167df2 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -2,11 +2,13 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; import * as path from 'node:path'; +import {input} from '#composite'; import {traverse} from '#node-utils'; import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {isName} from '#validators'; +import {exposeConstant} from '#composite/control-flow'; import {contentString, directory, flag, name, simpleString} from '#composite/wiki-properties'; @@ -36,6 +38,14 @@ export class StaticPage extends Thing { content: contentString(), absoluteLinks: flag(), + + // Expose only + + isStaticPage: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.findSpecs] = { diff --git a/src/data/things/track.js b/src/data/things/track.js index 557ba2a7..0d565086 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -4,8 +4,16 @@ import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; import Thing from '#thing'; -import {isBoolean, isColor, isContributionList, isDate, isFileExtension} - from '#validators'; + +import { + isBoolean, + isColor, + isContentString, + isContributionList, + isDate, + isFileExtension, + validateReference, +} from '#validators'; import { parseAdditionalFiles, @@ -15,15 +23,17 @@ import { parseCommentary, parseContributors, parseCreditingSources, + parseReferencingSources, parseDate, parseDimensions, parseDuration, parseLyrics, } from '#yaml'; -import {withPropertyFromObject} from '#composite/data'; +import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; import { + exitWithoutDependency, exposeConstant, exposeDependency, exposeDependencyOrContinue, @@ -52,7 +62,6 @@ import { reverseReferenceList, simpleDate, simpleString, - singleReference, soupyFind, soupyReverse, thing, @@ -62,17 +71,18 @@ import { } from '#composite/wiki-properties'; import { + alwaysReferenceByDirectory, exitWithoutUniqueCoverArt, inheritContributionListFromMainRelease, inheritFromMainRelease, withAllReleases, - withAlwaysReferenceByDirectory, withContainingTrackSection, withCoverArtistContribs, withDate, withDirectorySuffix, withHasUniqueCoverArt, withMainRelease, + withMainReleaseTrack, withOtherReleases, withPropertyFromAlbum, withSuffixDirectoryFromAlbum, @@ -91,14 +101,20 @@ export class Track extends Thing { Artwork, CommentaryEntry, CreditingSourcesEntry, - Flash, LyricsEntry, - TrackSection, + ReferencingSourcesEntry, WikiInfo, }) => ({ - // Update & expose + // > Update & expose - Internal relationships + + album: thing({ + class: input.value(Album), + }), + + // > Update & expose - Identifying metadata name: name('Unnamed Track'), + nameText: contentString(), directory: [ withDirectorySuffix(), @@ -130,138 +146,68 @@ export class Track extends Thing { }) ], - album: thing({ - class: input.value(Album), - }), - - additionalNames: thingList({ - class: input.value(AdditionalName), - }), - - bandcampTrackIdentifier: simpleString(), - bandcampArtworkIdentifier: simpleString(), - - duration: duration(), - urls: urls(), - dateFirstReleased: simpleDate(), + alwaysReferenceByDirectory: alwaysReferenceByDirectory(), - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withContainingTrackSection(), - - withPropertyFromObject({ - object: '#trackSection', - property: input.value('color'), + // Album or track. The exposed value is really just what's provided here, + // whether or not a matching track is found on a provided album, for + // example. When presenting or processing, read `mainReleaseTrack`. + mainRelease: [ + withMainRelease({ + from: input.updateValue({ + validate: + validateReference(['album', 'track']), + }), }), - exposeDependencyOrContinue({dependency: '#trackSection.color'}), - - withPropertyFromAlbum({ - property: input.value('color'), + exposeDependency({ + dependency: '#mainRelease', }), - - exposeDependency({dependency: '#album.color'}), ], - alwaysReferenceByDirectory: [ - withAlwaysReferenceByDirectory(), - exposeDependency({dependency: '#alwaysReferenceByDirectory'}), - ], + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), - // Disables presenting the track as though it has its own unique artwork. - // This flag should only be used in select circumstances, i.e. to override - // an album's trackCoverArtists. This flag supercedes that property, as well - // as the track's own coverArtists. - disableUniqueCoverArt: flag(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), - // File extension for track's corresponding media file. This represents the - // track's unique cover artwork, if any, and does not inherit the extension - // of the album's main artwork. It does inherit trackCoverArtFileExtension, - // if present on the album. - coverArtFileExtension: [ - exitWithoutUniqueCoverArt(), + dateFirstReleased: simpleDate(), + // > Update & expose - Credits and contributors + + artistText: [ exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), + validate: input.value(isContentString), }), withPropertyFromAlbum({ - property: input.value('trackCoverArtFileExtension'), + property: input.value('trackArtistText'), }), - exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), - - exposeConstant({ - value: input.value('jpg'), + exposeDependency({ + dependency: '#album.trackArtistText', }), ], - coverArtDate: [ - withTrackArtDate({ - from: input.updateValue({ - validate: isDate, - }), + artistTextInLists: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), }), - exposeDependency({dependency: '#trackArtDate'}), - ], - - coverArtDimensions: [ - exitWithoutUniqueCoverArt(), - - exposeUpdateValueOrContinue(), + exposeDependencyOrContinue({ + dependency: 'artistText', + }), withPropertyFromAlbum({ - property: input.value('trackDimensions'), + property: input.value('trackArtistText'), }), - exposeDependencyOrContinue({dependency: '#album.trackDimensions'}), - - dimensions(), - ], - - commentary: thingList({ - class: input.value(CommentaryEntry), - }), - - creditSources: thingList({ - class: input.value(CreditingSourcesEntry), - }), - - lyrics: [ - // TODO: Inherited lyrics are literally the same objects, so of course - // their .thing properties aren't going to point back to this one, and - // certainly couldn't be recontextualized... - inheritFromMainRelease(), - - thingList({ - class: input.value(LyricsEntry), + exposeDependency({ + dependency: '#album.trackArtistText', }), ], - additionalFiles: thingList({ - class: input.value(AdditionalFile), - }), - - sheetMusicFiles: thingList({ - class: input.value(AdditionalFile), - }), - - midiProjectFiles: thingList({ - class: input.value(AdditionalFile), - }), - - mainReleaseTrack: singleReference({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - artistContribs: [ - inheritContributionListFromMainRelease(), - withDate(), withResolvedContribs({ @@ -278,21 +224,25 @@ export class Track extends Thing { mode: input.value('empty'), }), + // Specifically inherit artist contributions later than artist contribs. + // Secondary releases' artists may differ from the main release. + inheritContributionListFromMainRelease(), + withPropertyFromAlbum({ - property: input.value('artistContribs'), + property: input.value('trackArtistContribs'), }), withRecontextualizedContributionList({ - list: '#album.artistContribs', + list: '#album.trackArtistContribs', artistProperty: input.value('trackArtistContributions'), }), withRedatedContributionList({ - list: '#album.artistContribs', + list: '#album.trackArtistContribs', date: '#date', }), - exposeDependency({dependency: '#album.artistContribs'}), + exposeDependency({dependency: '#album.trackArtistContribs'}), ], contributorContribs: [ @@ -306,38 +256,81 @@ export class Track extends Thing { }), ], - coverArtistContribs: [ - withCoverArtistContribs({ - from: input.updateValue({ - validate: isContributionList, - }), + // > Update & expose - General configuration + + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), }), - exposeDependency({dependency: '#coverArtistContribs'}), + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#trackSection.countTracksInArtistTotals'}), ], - referencedTracks: [ - inheritFromMainRelease({ - notFoundValue: input.value([]), + disableUniqueCoverArt: flag(), + disableDate: flag(), + + // > Update & expose - General metadata + + duration: duration(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), }), - referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('color'), + }), + + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + + withPropertyFromAlbum({ + property: input.value('color'), }), + + exposeDependency({dependency: '#album.color'}), ], - sampledTracks: [ - inheritFromMainRelease({ - notFoundValue: input.value([]), + needsLyrics: [ + exposeUpdateValueOrContinue({ + mode: input.value('falsy'), + validate: input.value(isBoolean), }), - referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), + exitWithoutDependency({ + dependency: 'lyrics', + mode: input.value('empty'), + value: input.value(false), }), + + withPropertyFromList({ + list: 'lyrics', + property: input.value('helpNeeded'), + }), + + { + dependencies: ['#lyrics.helpNeeded'], + compute: ({ + ['#lyrics.helpNeeded']: helpNeeded, + }) => + helpNeeded.includes(true) + }, ], + urls: urls(), + + // > Update & expose - Artworks + trackArtworks: [ exitWithoutUniqueCoverArt({ value: input.value([]), @@ -347,6 +340,58 @@ export class Track extends Thing { .call(this, 'Track Artwork'), ], + coverArtistContribs: [ + withCoverArtistContribs({ + from: input.updateValue({ + validate: isContributionList, + }), + }), + + exposeDependency({dependency: '#coverArtistContribs'}), + ], + + coverArtDate: [ + withTrackArtDate({ + from: input.updateValue({ + validate: isDate, + }), + }), + + exposeDependency({dependency: '#trackArtDate'}), + ], + + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtFileExtension'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: input.value('jpg'), + }), + ], + + coverArtDimensions: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue(), + + withPropertyFromAlbum({ + property: input.value('trackDimensions'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackDimensions'}), + + dimensions(), + ], + artTags: [ exitWithoutUniqueCoverArt({ value: input.value([]), @@ -366,7 +411,81 @@ export class Track extends Thing { referencedArtworkList(), ], - // Update only + // > Update & expose - Referenced tracks + + previousProductionTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + + referencedTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + + sampledTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + + // > Update & expose - Additional files + + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), + + sheetMusicFiles: thingList({ + class: input.value(AdditionalFile), + }), + + midiProjectFiles: thingList({ + class: input.value(AdditionalFile), + }), + + // > Update & expose - Content entries + + lyrics: [ + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... + inheritFromMainRelease(), + + thingList({ + class: input.value(LyricsEntry), + }), + ], + + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + referencingSources: thingList({ + class: input.value(ReferencingSourcesEntry), + }), + + // > Update only find: soupyFind(), reverse: soupyReverse(), @@ -376,17 +495,18 @@ export class Track extends Thing { class: input.value(Artwork), }), - // used for withAlwaysReferenceByDirectory (for some reason) - trackData: wikiData({ - class: input.value(Track), - }), - // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), - // Expose only + // > Expose only + + isTrack: [ + exposeConstant({ + value: input.value(true), + }), + ], commentatorArtists: commentatorArtists(), @@ -406,19 +526,27 @@ export class Track extends Thing { ], isMainRelease: [ - withMainRelease(), + withMainReleaseTrack(), exposeWhetherDependencyAvailable({ - dependency: '#mainRelease', + dependency: '#mainReleaseTrack', negate: input.value(true), }), ], isSecondaryRelease: [ - withMainRelease(), + withMainReleaseTrack(), exposeWhetherDependencyAvailable({ - dependency: '#mainRelease', + dependency: '#mainReleaseTrack', + }), + ], + + mainReleaseTrack: [ + withMainReleaseTrack(), + + exposeDependency({ + dependency: '#mainReleaseTrack', }), ], @@ -438,6 +566,38 @@ export class Track extends Thing { exposeDependency({dependency: '#otherReleases'}), ], + commentaryFromMainRelease: [ + withMainReleaseTrack(), + + exitWithoutDependency({ + dependency: '#mainReleaseTrack', + value: input.value([]), + }), + + withPropertyFromObject({ + object: '#mainReleaseTrack', + property: input.value('commentary'), + }), + + exposeDependency({ + dependency: '#mainReleaseTrack.commentary', + }), + ], + + groups: [ + withPropertyFromAlbum({ + property: input.value('groups'), + }), + + exposeDependency({ + dependency: '#album.groups', + }), + ], + + followingProductionTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreFollowingProductionsOf'), + }), + referencedByTracks: reverseReferenceList({ reverse: soupyReverse.input('tracksWhichReference'), }), @@ -453,14 +613,14 @@ export class Track extends Thing { static [Thing.yamlDocumentSpec] = { fields: { + // Identifying metadata + 'Track': {property: 'name'}, + 'Track Text': {property: 'nameText'}, 'Directory': {property: 'directory'}, 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Main Release': {property: 'mainRelease'}, 'Bandcamp Track ID': { property: 'bandcampTrackIdentifier', @@ -472,17 +632,86 @@ export class Track extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + // Credits and contributors + + 'Artist Text': {property: 'artistText'}, + 'Artist Text In Lists': {property: 'artistTextInLists'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Has Date': { + property: 'disableDate', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + // General metadata + 'Duration': { property: 'duration', transform: parseDuration, }, 'Color': {property: 'color'}, + + 'Needs Lyrics': { + property: 'needsLyrics', + }, + 'URLs': {property: 'urls'}, - 'Date First Released': { - property: 'dateFirstReleased', - transform: parseDate, + // Artworks + + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, }, 'Cover Art Date': { @@ -497,30 +726,20 @@ export class Track extends Thing { transform: parseDimensions, }, - 'Has Cover Art': { - property: 'disableUniqueCoverArt', - transform: value => - (typeof value === 'boolean' - ? !value - : value), - }, - - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Art Tags': {property: 'artTags'}, - 'Lyrics': { - property: 'lyrics', - transform: parseLyrics, + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, }, - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, + // Referenced tracks - 'Credit Sources': { - property: 'creditSources', - transform: parseCreditingSources, - }, + 'Previous Productions': {property: 'previousProductionTracks'}, + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + // Additional files 'Additional Files': { property: 'additionalFiles', @@ -537,54 +756,41 @@ export class Track extends Thing { transform: parseAdditionalFiles, }, - 'Main Release': {property: 'mainReleaseTrack'}, - 'Referenced Tracks': {property: 'referencedTracks'}, - 'Sampled Tracks': {property: 'sampledTracks'}, + // Content entries - 'Referenced Artworks': { - property: 'referencedArtworks', - transform: parseAnnotatedReferences, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, }, - 'Franchises': {ignore: true}, - 'Inherit Franchises': {ignore: true}, - - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, }, - 'Track Artwork': { - property: 'trackArtworks', - transform: - parseArtwork({ - thingProperty: 'trackArtworks', - dimensionsFromThingProperty: 'coverArtDimensions', - fileExtensionFromThingProperty: 'coverArtFileExtension', - dateFromThingProperty: 'coverArtDate', - artTagsFromThingProperty: 'artTags', - referencedArtworksFromThingProperty: 'referencedArtworks', - artistContribsFromThingProperty: 'coverArtistContribs', - artistContribsArtistProperty: 'trackCoverArtistContributions', - }), - }, - - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', + ]}, + {message: `Secondary releases inherit references from the main one`, fields: [ 'Main Release', 'Referenced Tracks', @@ -595,11 +801,6 @@ export class Track extends Thing { 'Sampled Tracks', ]}, - {message: `Secondary releases inherit artists from the main one`, fields: [ - 'Main Release', - 'Artists', - ]}, - {message: `Secondary releases inherit contributors from the main one`, fields: [ 'Main Release', 'Contributors', @@ -641,7 +842,7 @@ export class Track extends Thing { bindTo: 'trackData', include: track => - !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'), + !CacheableObject.getUpdateValue(track, 'mainRelease'), // It's still necessary to check alwaysReferenceByDirectory here, since // it may be set manually (with `Always Reference By Directory: true`), @@ -741,6 +942,13 @@ export class Track extends Thing { referencing: track => track.isSecondaryRelease ? [track] : [], referenced: track => [track.mainReleaseTrack], }, + + tracksWhichAreFollowingProductionsOf: { + bindTo: 'trackData', + + referencing: track => track, + referenced: track => track.previousProductionTracks, + }, }; // Track YAML loading is handled in album.js. @@ -771,12 +979,36 @@ export class Track extends Thing { ]; } + countOwnContributionInContributionTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + + countOwnContributionInDurationTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + [inspect.custom](depth) { const parts = []; parts.push(Thing.prototype[inspect.custom].apply(this)); - if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) { + if (CacheableObject.getUpdateValue(this, 'mainRelease')) { parts.unshift(`${colors.yellow('[secrelease]')} `); } diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 590598be..7fb6a350 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -2,7 +2,7 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; import Thing from '#thing'; -import {parseContributionPresets} from '#yaml'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; import { isBoolean, @@ -10,12 +10,21 @@ import { isContributionPresetList, isLanguageCode, isName, - isURL, } from '#validators'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, soupyFind} - from '#composite/wiki-properties'; +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; + +import { + canonicalBase, + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -55,18 +64,12 @@ export class WikiInfo extends Thing { update: {validate: isLanguageCode}, }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL}, - expose: { - transform: (value) => - (value === null - ? null - : value.endsWith('/') - ? value - : value + '/'), - }, - }, + canonicalBase: canonicalBase(), + canonicalMediaBase: canonicalBase(), + + wikiWallpaperFileExtension: fileExtension('jpg'), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), divideTrackListsByGroups: referenceList({ class: input.value(Group), @@ -106,24 +109,49 @@ export class WikiInfo extends Thing { default: false, }, }, + + // Expose only + + isWikiInfo: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { fields: { 'Name': {property: 'name'}, 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Canonical Media Base': {property: 'canonicalMediaBase'}, + + 'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'}, + + 'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'}, + + 'Wiki Wallpaper Parts': { + property: 'wikiWallpaperParts', + transform: parseWallpaperParts, + }, + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, 'Enable Listings': {property: 'enableListings'}, 'Enable News': {property: 'enableNews'}, 'Enable Art Tag UI': {property: 'enableArtTagUI'}, 'Enable Group UI': {property: 'enableGroupUI'}, + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Contribution Presets': { property: 'contributionPresets', transform: parseContributionPresets, diff --git a/src/data/yaml.js b/src/data/yaml.js index 2dd1f7e8..13dfd24d 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -87,7 +87,7 @@ function makeProcessDocument(thingConstructor, { // ] // // ...means A can't coexist with B or C, B can't coexist with A or C, and - // C can't coexist iwth A, B, or D - but it's okay for D to coexist with + // C can't coexist with A, B, or D - but it's okay for D to coexist with // A or B. // invalidFieldCombinations = [], @@ -182,9 +182,22 @@ function makeProcessDocument(thingConstructor, { const fieldCombinationErrors = []; - for (const {message, fields} of invalidFieldCombinations) { + for (const {message, fields: fieldsSpec} of invalidFieldCombinations) { const fieldsPresent = - presentFields.filter(field => fields.includes(field)); + fieldsSpec.flatMap(fieldSpec => { + if (Array.isArray(fieldSpec)) { + const [field, match] = fieldSpec; + if (!presentFields.includes(field)) return []; + if (typeof match === 'function') { + return match(document[field]) ? [field] : []; + } else { + return document[field] === match ? [field] : []; + } + } + + const field = fieldSpec; + return presentFields.includes(field) ? [field] : []; + }); if (fieldsPresent.length >= 2) { const filteredDocument = @@ -194,7 +207,10 @@ function makeProcessDocument(thingConstructor, { {preserveOriginalOrder: true}); fieldCombinationErrors.push( - new FieldCombinationError(filteredDocument, message)); + new FieldCombinationError( + filteredDocument, + fieldsSpec, + message)); for (const field of Object.keys(filteredDocument)) { skippedFields.add(field); @@ -250,7 +266,9 @@ function makeProcessDocument(thingConstructor, { // This variable would like to certify itself as "not into capitalism". let propertyValue = - (fieldSpecs[field].transform + (documentValue === null + ? null + : fieldSpecs[field].transform ? fieldSpecs[field].transform(documentValue, transformUtilities) : documentValue); @@ -416,19 +434,36 @@ export class FieldCombinationAggregateError extends AggregateError { } export class FieldCombinationError extends Error { - constructor(fields, message) { - const fieldNames = Object.keys(fields); + constructor(filteredDocument, fieldsSpec, message) { + const fieldNames = Object.keys(filteredDocument); const fieldNamesText = fieldNames - .map(field => colors.red(field)) + .map(field => { + if (fieldsSpec.includes(field)) { + return colors.red(field); + } + + const match = + fieldsSpec + .find(fieldSpec => + Array.isArray(fieldSpec) && + fieldSpec[0] === field) + .at(1); + + if (typeof match === 'function') { + return colors.red(`${field}: ${filteredDocument[field]}`); + } else { + return colors.red(`${field}: ${match}`); + } + }) .join(', '); const mainMessage = `Don't combine ${fieldNamesText}`; const causeMessage = (typeof message === 'function' - ? message(fields) + ? message(filteredDocument) : typeof message === 'string' ? message : null); @@ -440,7 +475,7 @@ export class FieldCombinationError extends Error { : null), }); - this.fields = fields; + this.fields = fieldNames; } } @@ -930,6 +965,10 @@ export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) { return parseContentEntries(CreditingSourcesEntry, value, {subdoc}); } +export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) { + return parseContentEntries(ReferencingSourcesEntry, value, {subdoc}); +} + export function parseLyrics(value, {subdoc, LyricsEntry}) { if ( typeof value === 'string' && @@ -943,6 +982,30 @@ export function parseLyrics(value, {subdoc, LyricsEntry}) { return parseContentEntries(LyricsEntry, value, {subdoc}); } +export function parseArtistAliases(value, {subdoc, Artist}) { + return parseArrayEntries(value, item => { + const config = { + bindInto: 'aliasedArtist', + provide: {isAlias: true}, + }; + + if (typeof item === 'string') { + return subdoc(Artist, {'Artist': item}, config); + } else if (typeof item === 'object' && !Array.isArray(item)) { + if (item['Name']) { + const clone = {...item}; + clone['Artist'] = item['Name']; + delete clone['Name']; + return subdoc(Artist, clone, config); + } else { + return subdoc(Artist, item, config); + } + } else { + return item; + } + }); +} + // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { @@ -972,6 +1035,12 @@ export const documentModes = { // array of processed documents (wiki objects). allInOne: Symbol('Document mode: allInOne'), + // allTogether: One or more documens, spread across any number of files. + // Expects files array (or function) and processDocument function. + // Calls save with an array of processed documents (wiki objects) - this is + // a flat array, *not* an array of the documents processed from *each* file. + allTogether: Symbol('Document mode: allTogether'), + // 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). @@ -1018,7 +1087,7 @@ export const documentModes = { export function getAllDataSteps() { try { thingConstructors; - } catch (error) { + } catch { throw new Error(`Thing constructors aren't ready yet, can't get all data steps`); } @@ -1082,6 +1151,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) { } } + case documentModes.allTogether: case documentModes.headerAndEntries: case documentModes.onePerFile: { if (!dataStep.files) { @@ -1237,7 +1307,8 @@ export function processThingsFromDataStep(documents, dataStep) { const {documentMode} = dataStep; switch (documentMode) { - case documentModes.allInOne: { + case documentModes.allInOne: + case documentModes.allTogether: { const result = []; const aggregate = openAggregate({message: `Errors processing documents`}); @@ -1512,6 +1583,10 @@ export function saveThingsFromDataStep(thingLists, dataStep) { return dataStep.save(thing); } + case documentModes.allTogether: { + return dataStep.save(thingLists.flat()); + } + case documentModes.headerAndEntries: case documentModes.onePerFile: { return dataStep.save(thingLists); @@ -1638,11 +1713,12 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { ['lyricsData', [/* find */]], + ['referencingSourceData', [/* find */]], + ['seriesData', [/* find */]], ['trackData', [ 'artworkData', - 'trackData', 'wikiInfo', ]], diff --git a/src/external-links.js b/src/external-links.js index 1055a391..06570067 100644 --- a/src/external-links.js +++ b/src/external-links.js @@ -4,11 +4,9 @@ import { anyOf, is, isBoolean, - isObject, isStringNonEmpty, looseArrayOf, optional, - validateAllPropertyValues, validateArrayItems, validateInstanceOf, validateProperties, @@ -32,6 +30,9 @@ export const externalLinkContexts = [ 'generic', 'group', 'track', + + 'composerRelease', + 'officialRelease', ]; export const isExternalLinkContext = @@ -257,6 +258,30 @@ export const externalLinkSpec = [ }, { + match: { + domain: '.bandcamp.com', + context: 'composerRelease', + }, + + platform: 'bandcamp.composerRelease', + handle: {domain: /^[^.]+/}, + + icon: 'bandcamp', + }, + + { + match: { + domain: '.bandcamp.com', + context: 'officialRelease', + }, + + platform: 'bandcamp.officialRelease', + handle: {domain: /^[^.]+/}, + + icon: 'bandcamp', + }, + + { match: {domain: '.bandcamp.com'}, platform: 'bandcamp', @@ -557,6 +582,17 @@ export const externalLinkSpec = [ }, { + match: {domains: ['reddit.com', 'old.reddit.com']}, + platform: 'reddit', + icon: 'globe', + + detail: { + substring: 'subreddit', + subreddit: {pathname: /^r\/[^/]+(?=\/)?/}, + }, + }, + + { match: {domain: 'soundcloud.com'}, platform: 'soundcloud', diff --git a/src/find-reverse.js b/src/find-reverse.js index 6a67ac0f..c99a4a71 100644 --- a/src/find-reverse.js +++ b/src/find-reverse.js @@ -11,7 +11,7 @@ export function getAllSpecs({ }) { try { thingConstructors; - } catch (error) { + } catch { throw new Error(`Thing constructors aren't ready yet, can't get all ${word} specs`); } @@ -52,7 +52,7 @@ export function findSpec(key, { try { thingConstructors; - } catch (error) { + } catch { throw new Error(`Thing constructors aren't ready yet, can't check if "${word}.${key}" available`); } diff --git a/src/find.js b/src/find.js index e7f5cda1..7b605e97 100644 --- a/src/find.js +++ b/src/find.js @@ -4,6 +4,7 @@ import {colors, logWarn} from '#cli'; import {compareObjects, stitchArrays, typeAppearance} from '#sugar'; import thingConstructors from '#things'; import {isFunction, validateArrayItems} from '#validators'; +import {getCaseSensitiveKebabCase} from '#wiki-data'; import * as fr from './find-reverse.js'; @@ -30,7 +31,34 @@ function warnOrThrow(mode, message) { export const keyRefRegex = new RegExp(String.raw`^(?:(?<key>[a-z-]*):(?=\S))?(?<ref>.*)$`); -export function processAvailableMatchesByName(data, { +function getFuzzHash(fuzz = {}) { + if (!fuzz) { + return 0; + } + + return ( + fuzz.capitalization << 0 + + fuzz.kebab << 1 + ); +} + +export function fuzzName(name, fuzz = {}) { + if (!fuzz) { + return name; + } + + if (fuzz.capitalization) { + name = name.toLowerCase(); + } + + if (fuzz.kebab) { + name = getCaseSensitiveKebabCase(name); + } + + return name; +} + +export function processAvailableMatchesByName(data, fuzz, { include = _thing => true, getMatchableNames = thing => @@ -50,17 +78,20 @@ export function processAvailableMatchesByName(data, { continue; } - const normalizedName = name.toLowerCase(); + const normalizedName = fuzzName(name, fuzz); if (normalizedName in results) { if (normalizedName in multipleNameMatches) { multipleNameMatches[normalizedName].push(thing); } else { - multipleNameMatches[normalizedName] = [results[normalizedName], thing]; + multipleNameMatches[normalizedName] = [ + results[normalizedName].thing, + thing, + ]; results[normalizedName] = null; } } else { - results[normalizedName] = thing; + results[normalizedName] = {thing, name}; } } } @@ -87,16 +118,16 @@ export function processAvailableMatchesByDirectory(data, { continue; } - results[directory] = thing; + results[directory] = {thing, directory}; } } return {results}; } -export function processAllAvailableMatches(data, spec) { +export function processAllAvailableMatches(data, fuzz, spec) { const {results: byName, multipleNameMatches} = - processAvailableMatchesByName(data, spec); + processAvailableMatchesByName(data, fuzz, spec); const {results: byDirectory} = processAvailableMatchesByDirectory(data, spec); @@ -109,21 +140,69 @@ function oopsMultipleNameMatches(mode, { normalizedName, multipleNameMatches, }) { + try { + return warnOrThrow(mode, + `Multiple matches for reference "${name}". Please resolve:\n` + + multipleNameMatches[normalizedName] + .map(match => `- ${inspect(match)}\n`) + .join('') + + `Returning null for this reference.`); + } catch (caughtError) { + throw Object.assign(caughtError, { + [Symbol.for('hsmusic.find.multipleNameMatches')]: + multipleNameMatches[normalizedName], + }); + } +} + +function oopsNameCapitalizationMismatch(mode, { + matchingName, + matchedName, +}) { + if (matchingName.length === matchedName.length) { + let a = '', b = ''; + for (let i = 0; i < matchingName.length; i++) { + if ( + matchingName[i] === matchedName[i] || + matchingName[i].toLowerCase() !== matchingName[i].toLowerCase() + ) { + a += matchingName[i]; + b += matchedName[i]; + } else { + a += colors.bright(colors.red(matchingName[i])); + b += colors.bright(colors.green(matchedName[i])); + } + } + + matchingName = a; + matchedName = b; + } + return warnOrThrow(mode, - `Multiple matches for reference "${name}". Please resolve:\n` + - multipleNameMatches[normalizedName] - .map(match => `- ${inspect(match)}\n`) - .join('') + + `Provided capitalization differs from the matched name. Please resolve:\n` + + `- provided: ${matchingName}\n` + + `- should be: ${matchedName}\n` + `Returning null for this reference.`); } -export function prepareMatchByName(mode, {byName, multipleNameMatches}) { +export function prepareMatchByName(mode, fuzz, {byName, multipleNameMatches}) { return (name) => { - const normalizedName = name.toLowerCase(); + const normalizedName = fuzzName(name, fuzz); const match = byName[normalizedName]; if (match) { - return match; + if ( + !fuzz?.capitalization && + name !== match.name && + name.toLowerCase === match.name.toLowerCase() + ) { + return oopsNameCapitalizationMismatch(mode, { + matchingName: name, + matchedName: match.name, + }); + } else { + return match.thing; + } } else if (multipleNameMatches[normalizedName]) { return oopsMultipleNameMatches(mode, { name, @@ -154,7 +233,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) { }); } - return byDirectory[directory]; + const match = byDirectory[directory]; + + if (match) { + return match.thing; + } else { + return null; + } }; } @@ -196,11 +281,18 @@ function findHelper({ // hasn't changed! const cache = new WeakMap(); - // 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'} = {}) => { + return (fullRef, data, { + // 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. + mode = 'warn', + + fuzz = { + capitalization: false, + kebab: false, + }, + } = {}) => { if (!fullRef) return null; if (typeof fullRef !== 'string') { @@ -211,19 +303,23 @@ function findHelper({ throw new TypeError(`Expected data to be present`); } - let subcache = cache.get(data); - if (!subcache) { - subcache = - processAllAvailableMatches(data, { + let dataSubcache = cache.get(data); + if (!dataSubcache) { + cache.set(data, dataSubcache = new Map()); + } + + const fuzzHash = getFuzzHash(fuzz); + let fuzzSubcache = dataSubcache.get(fuzzHash); + if (!fuzzSubcache) { + dataSubcache.set(fuzzHash, fuzzSubcache = + processAllAvailableMatches(data, fuzz, { include, getMatchableNames, getMatchableDirectories, - }); - - cache.set(data, subcache); + })); } - const {byDirectory, byName, multipleNameMatches} = subcache; + const {byDirectory, byName, multipleNameMatches} = fuzzSubcache; return matchHelper(fullRef, mode, { matchByDirectory: @@ -233,7 +329,7 @@ function findHelper({ }), matchByName: - prepareMatchByName(mode, { + prepareMatchByName(mode, fuzz, { byName, multipleNameMatches, }), @@ -312,7 +408,7 @@ function findMixedHelper(config) { const multipleNameMatches = Object.create(null); for (const spec of specs) { - processAvailableMatchesByName(data, { + processAvailableMatchesByName(data, null, { ...spec, results: byName, @@ -345,11 +441,17 @@ function findMixedHelper(config) { }); } - return byDirectory[referenceType][directory]; + const match = byDirectory[referenceType][directory]; + + if (match) { + return match.thing; + } else { + return null; + } }, matchByName: - prepareMatchByName(mode, { + prepareMatchByName(mode, null, { byName, multipleNameMatches, }), diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 97cf74a9..18f88ce6 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -162,10 +162,11 @@ import { import dimensionsOf from 'image-size'; -import CacheableObject from '#cacheable-object'; import {stringifyCache} from '#cli'; import {commandExists, isMain, promisifyProcess, traverse} from '#node-utils'; import {sortByName} from '#sort'; +import {delay, empty, filterMultipleArrays, queue, stitchArrays, unique} + from '#sugar'; import { colors, @@ -178,16 +179,6 @@ import { progressPromiseAll, } from '#cli'; -import { - delay, - empty, - chunkMultipleArrays, - filterMultipleArrays, - queue, - stitchArrays, - unique, -} from '#sugar'; - export const defaultMagickThreads = 8; function getSpecbustForCacheEntry(entry) { @@ -447,7 +438,7 @@ async function getImageMagickVersion(binary) { try { await promisifyProcess(proc, false); - } catch (error) { + } catch { return null; } @@ -556,42 +547,56 @@ async function determineThumbtacksNeededForFile({ return mismatchedWithinRightSize; } -async function generateImageThumbnail(imagePath, thumbtack, { +// Write all requested thumbtacks for a source image in one pass +// This saves a lot of disk reads which are probably the main bottleneck +function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) { + const args = [filePathInMedia, '-strip']; + + const basename = + path.basename(filePathInMedia, path.extname(filePathInMedia)); + + // do larger sizes first + thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size); + + for (const tack of thumbtacks) { + const {size, quality} = thumbnailSpec[tack]; + const filename = `${basename}.${tack}.jpg`; + const filePathInCache = path.join(dirnameInCache, filename); + args.push( + '(', '+clone', + '-resize', `${size}x${size}>`, + '-interlace', 'Plane', + '-quality', `${quality}%`, + '-write', filePathInCache, + '+delete', ')', + ); + } + + // throw away the (already written) image stream + args.push('null:'); + + return args; +} + +async function generateImageThumbnails(imagePath, thumbtacks, { mediaPath, mediaCachePath, spawnConvert, }) { + if (empty(thumbtacks)) return; + const filePathInMedia = path.join(mediaPath, imagePath); const dirnameInCache = path.join(mediaCachePath, path.dirname(imagePath)); - const filename = - path.basename(imagePath, path.extname(imagePath)) + - `.${thumbtack}.jpg`; - - const filePathInCache = - path.join(dirnameInCache, filename); - await mkdir(dirnameInCache, {recursive: true}); - const specEntry = thumbnailSpec[thumbtack]; - const {size, quality} = specEntry; - - const convertProcess = spawnConvert([ - filePathInMedia, - '-strip', - '-resize', - `${size}x${size}>`, - '-interlace', - 'Plane', - '-quality', - `${quality}%`, - filePathInCache, - ]); - - await promisifyProcess(convertProcess, false); + const convertArgs = + prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks); + + await promisifyProcess(spawnConvert(convertArgs), false); } export async function determineMediaCachePath({ @@ -628,7 +633,7 @@ export async function determineMediaCachePath({ try { const files = await readdir(mediaPath); mediaIncludesThumbnailCache = files.includes(CACHE_FILE); - } catch (error) { + } catch { mediaIncludesThumbnailCache = false; } @@ -861,7 +866,7 @@ export async function migrateThumbsIntoDedicatedCacheDirectory({ path.join(mediaPath, CACHE_FILE), path.join(mediaCachePath, CACHE_FILE)); logInfo`Moved thumbnail cache file.`; - } catch (error) { + } catch { logWarn`Failed to move cache file. (${CACHE_FILE})`; logWarn`Check its permissions, or try copying/pasting.`; } @@ -1100,33 +1105,23 @@ export default async function genThumbs({ const writeMessageFn = () => `Writing image thumbnails. [failed: ${numFailed}]`; - const generateCallImageIndices = - imageThumbtacksNeeded - .flatMap(({length}, index) => - Array.from({length}, () => index)); - - const generateCallImagePaths = - generateCallImageIndices - .map(index => imagePaths[index]); - - const generateCallThumbtacks = - imageThumbtacksNeeded.flat(); - const generateCallFns = stitchArrays({ - imagePath: generateCallImagePaths, - thumbtack: generateCallThumbtacks, - }).map(({imagePath, thumbtack}) => () => - generateImageThumbnail(imagePath, thumbtack, { + imagePath: imagePaths, + thumbtacks: imageThumbtacksNeeded, + }).map(({imagePath, thumbtacks}) => () => + generateImageThumbnails(imagePath, thumbtacks, { mediaPath, mediaCachePath, spawnConvert, }).catch(error => { numFailed++; - return ({error}); + return {error}; })); - logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`; + const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0); + + logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`; if (generateCallFns.length > 500) { logInfo`Go get a latte - this could take a while!`; } @@ -1135,37 +1130,30 @@ export default async function genThumbs({ await progressPromiseAll(writeMessageFn, queue(generateCallFns, magickThreads)); - let successfulIndices; + let successfulPaths; { - const erroredIndices = generateCallImageIndices.slice(); - const erroredPaths = generateCallImagePaths.slice(); - const erroredThumbtacks = generateCallThumbtacks.slice(); + const erroredPaths = imagePaths.slice(); const errors = generateCallResults.map(result => result?.error); const {removed} = filterMultipleArrays( - erroredIndices, erroredPaths, - erroredThumbtacks, errors, - (_index, _imagePath, _thumbtack, error) => error); + (_imagePath, error) => error); - successfulIndices = new Set(removed[0]); - - const chunks = - chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors, - (imagePath, lastImagePath) => imagePath !== lastImagePath); + ([successfulPaths] = removed); // TODO: This should obviously be an aggregate error. // ...Just like every other error report here, and those dang aggregates // should be constructable from within the queue, rather than after. - for (const [[imagePath], thumbtacks, errors] of chunks) { - logError`Failed to generate thumbnails for ${imagePath}:`; - for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) { - logError`- ${thumbtack}: ${error}`; - } - } + stitchArrays({ + imagePath: erroredPaths, + error: errors, + }).forEach(({imagePath, error}) => { + logError`Failed to generate thumbnails for ${imagePath}:`; + logError`- ${error}`; + }); if (empty(errors)) { logInfo`All needed thumbnails generated successfully - nice!`; @@ -1179,8 +1167,8 @@ export default async function genThumbs({ imagePaths, imageThumbtacksNeeded, imageDimensions, - (_imagePath, _thumbtacksNeeded, _dimensions, index) => - successfulIndices.has(index)); + (imagePath, _thumbtacksNeeded, _dimensions) => + successfulPaths.includes(imagePath)); for (const { imagePath, @@ -1251,6 +1239,11 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { .filter(part => part.asset) .map(part => fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))), + + wikiData.wikiInfo.wikiWallpaperParts + .filter(part => part.asset) + .map(part => + fromRoot.to('media.path', part.asset)), ].flat(); sortByName(paths, {getName: path => path}); diff --git a/src/html.js b/src/html.js index 9e4c39ab..4cac9525 100644 --- a/src/html.js +++ b/src/html.js @@ -2,6 +2,8 @@ import {inspect} from 'node:util'; +import striptags from 'striptags'; + import {withAggregate} from '#aggregate'; import {colors} from '#cli'; import {empty, typeAppearance, unique} from '#sugar'; @@ -39,6 +41,40 @@ export const selfClosingTags = [ 'wbr', ]; +// Every element under: +// https://html.spec.whatwg.org/multipage/text-level-semantics.html +export const textLevelSemanticTags = [ + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'dfn', + 'em', + 'i', + 'kbd', + 'mark', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', + 'wbr', +]; + // Not so comprehensive!! export const attributeSpec = { 'class': { @@ -53,6 +89,17 @@ export const attributeSpec = { }, }; +let disabledSlotValidation = false; +let disabledTagTracing = false; + +export function disableSlotValidation() { + disabledSlotValidation = true; +} + +export function disableTagTracing() { + disabledTagTracing = true; +} + // Pass to tag() as an attributes key to make tag() return a 8lank tag if the // provided content is empty. Useful for when you'll only 8e showing an element // according to the presence of content that would 8elong there. @@ -223,7 +270,11 @@ export function isBlank(content) { // could include content. These need to be checked too. // Check each of the templates one at a time. for (const template of result) { - const content = template.content; + // Resolve the content all the way down to a tag - + // if it's a template that returns another template, + // that won't do, because we need to detect if its + // final content is a tag marked onlyIfSiblings. + const content = normalize(template); if (content instanceof Tag && content.onlyIfSiblings) { continue; @@ -271,7 +322,11 @@ export const validators = { }, }; -export function blank() { +export function blank(...args) { + if (args.length) { + throw new Error(`Passed arguments - did you mean isBlank() instead?`) + } + return []; } @@ -340,6 +395,22 @@ export function normalize(content) { return Tag.normalize(content); } +export function escape(string, {attribute = false} = {}) { + // https://html.spec.whatwg.org/multipage/parsing.html#escapingString + + string = string + .replaceAll('&', '&') + .replaceAll('\u00a0', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>'); + + if (attribute) { + string = string.replaceAll('"', '"'); + } + + return string; +} + export class Tag { #tagName = ''; #content = null; @@ -352,8 +423,10 @@ export class Tag { this.attributes = attributes; this.content = content; - this.#traceError = new Error(); - } + if (!disabledTagTracing) { + this.#traceError = new Error(); + } +} clone() { return Reflect.construct(this.constructor, [ @@ -432,6 +505,7 @@ export class Tag { this.#content = contentArray; this.#content.toString = () => this.#stringifyContent(); + this.#content.toPlainText = () => this.#plainifyContent(); } get content() { @@ -583,7 +657,7 @@ export class Tag { try { this.content = this.content; - } catch (error) { + } catch { this.#setAttributeFlag(imaginarySibling, false); } } @@ -640,6 +714,10 @@ export class Tag { : '\n')); } + toPlainText() { + return this.content.toPlainText(); + } + #getContentJoiner() { if (this.joinChildren === undefined) { return '\n'; @@ -659,11 +737,8 @@ export class Tag { const joiner = this.#getContentJoiner(); - let content = ''; let blockwrapClosers = ''; - let seenSiblingIndependentContent = false; - const chunkwrapSplitter = (this.chunkwrap ? this.#getAttributeRaw('split') @@ -674,108 +749,64 @@ export class Tag { ? false : null); - let contentItems; - - determineContentItems: { - if (this.chunkwrap) { - contentItems = smush(this).content; - break determineContentItems; - } - - contentItems = this.content; - } - - for (const [index, item] of contentItems.entries()) { - const nonTemplateItem = - Template.resolve(item); - - if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { - seenSiblingIndependentContent = true; - continue; - } - - let itemContent; - try { - itemContent = nonTemplateItem.toString(); - } catch (caughtError) { - const indexPart = colors.yellow(`child #${index + 1}`); - - const error = - new Error( - `Error in ${indexPart} ` + - `of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; - } - - if (!itemContent) { - continue; - } - - if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { - seenSiblingIndependentContent = true; - } + const contentItems = + (this.chunkwrap + ? smush(this).content + : this.content); + + let content = this.#renderContentItems({ + from: '', + items: contentItems, + + getItemContent: item => item.toString(), + + appendItemContent(content, itemContent, item) { + const chunkwrapChunks = + (typeof item === 'string' && chunkwrapSplitter + ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) + : null); + + const itemIncludesChunkwrapSplit = + (chunkwrapChunks + ? chunkwrapChunks.length > 1 + : null); + + if (content) { + if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { + // The first time we see a chunkwrap splitter, backtrack and wrap + // the content *so far* in a chunk. This will be treated just like + // any other open chunkwrap, and closed after the first chunk of + // this item! (That means the existing content is part of the same + // chunk as the first chunk included in this content, which makes + // sense, because that first chink is really just more text that + // precedes the first split.) + content = `<span class="chunkwrap">` + content; + } - const chunkwrapChunks = - (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) - : null); - - const itemIncludesChunkwrapSplit = - (chunkwrapChunks - ? chunkwrapChunks.length > 1 - : null); - - if (content) { - if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { - // The first time we see a chunkwrap splitter, backtrack and wrap - // the content *so far* in a chunk. This will be treated just like - // any other open chunkwrap, and closed after the first chunk of - // this item! (That means the existing content is part of the same - // chunk as the first chunk included in this content, which makes - // sense, because that first chink is really just more text that - // precedes the first split.) - content = `<span class="chunkwrap">` + content; + content += joiner; + } else if (itemIncludesChunkwrapSplit) { + // We've encountered a chunkwrap split before any other content. + // This means there's no content to wrap, no existing chunkwrap + // to close, and no reason to add a joiner, but we *do* need to + // enter a chunkwrap wrapper *now*, so the first chunk of this + // item will be properly wrapped. + content = `<span class="chunkwrap">`; } - content += joiner; - } else if (itemIncludesChunkwrapSplit) { - // We've encountered a chunkwrap split before any other content. - // This means there's no content to wrap, no existing chunkwrap - // to close, and no reason to add a joiner, but we *do* need to - // enter a chunkwrap wrapper *now*, so the first chunk of this - // item will be properly wrapped. - content = `<span class="chunkwrap">`; - } - - if (itemIncludesChunkwrapSplit) { - seenChunkwrapSplitter = true; - } + if (itemIncludesChunkwrapSplit) { + seenChunkwrapSplitter = true; + } - // Blockwraps only apply if they actually contain some content whose - // words should be kept together, so it's okay to put them beneath the - // itemContent check. They also never apply at the very start of content, - // because at that point there aren't any preceding words from which the - // blockwrap would differentiate its content. - if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { - content += `<span class="blockwrap">`; - blockwrapClosers += `</span>`; - } + // Blockwraps only apply if they actually contain some content whose + // words should be kept together, so it's okay to put them beneath the + // itemContent check. They also never apply at the very start of content, + // because at that point there aren't any preceding words from which the + // blockwrap would differentiate its content. + if (item instanceof Tag && item.blockwrap && content) { + content += `<span class="blockwrap">`; + blockwrapClosers += `</span>`; + } - appendItemContent: { if (itemIncludesChunkwrapSplit) { for (const [index, {chunk, following}] of chunkwrapChunks.entries()) { if (index === 0) { @@ -809,17 +840,15 @@ export class Tag { } } - break appendItemContent; + return content; } - content += itemContent; - } - } + return content += itemContent; + }, + }); - // If we've only seen sibling-dependent content (or just no content), - // then the content in total is blank. - if (!seenSiblingIndependentContent) { - return ''; + if (!content.length) { + return content; } if (chunkwrapSplitter) { @@ -839,6 +868,130 @@ export class Tag { return content; } + #plainifyContent() { + // Doesn't play too nice with transformContent, because that function, + // working with the Marked library to process markdown, returns a mix of + // raw HTML strings and actual tags - this function only makes nice line + // breaks out of actual tags. + + if (this.selfClosing) { + return ''; + } + + let joiner = this.#getContentJoiner(); + + if (joiner instanceof Tag && joiner.tagName === 'br') { + joiner = '\n'; + } + + if (joiner === '\n') { + joiner = ' '; + } + + let content = this.#renderContentItems({ + from: '', + items: this.content, + + getItemContent: item => + (item instanceof Tag + ? item.toPlainText() + : item.toString()), + + appendItemContent(content, itemContent, item) { + if (joiner === ' ') { + if (item instanceof Tag && !textLevelSemanticTags.includes(item.tagName)) { + content += '\n\n'; + } else if (!content.endsWith(' ')) { + content += ' '; + } + } else { + content += joiner; + } + + return content += itemContent; + }, + }); + + content = + striptags(content) + .replaceAll(''', `'`) + .replaceAll('"', `"`); + + return content; + } + + #renderContentItems(config) { + let content = structuredClone(config.from); + + let seenSiblingIndependentContent = false; + + for (const [index, item] of config.items.entries()) { + const nonTemplateItem = Template.resolve(item); + + if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { + seenSiblingIndependentContent = true; + continue; + } + + let itemContent; + try { + itemContent = config.getItemContent(nonTemplateItem); + } catch (caughtError) { + throw this.#annotateContentItemError(caughtError, index); + } + + if (!itemContent) { + continue; + } + + const previousLength = content.length; + + content = config.appendItemContent(content, itemContent, nonTemplateItem); + + if (content.length === previousLength) { + continue; + } + + if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { + seenSiblingIndependentContent = true; + } + } + + // If we've only seen sibling-dependent content (or just no content), + // then the content in total is blank. + if (!seenSiblingIndependentContent) { + return config.from; + } + + return content; + } + + #annotateContentItemError(caughtError, index) { + const indexPart = colors.yellow(`child #${index + 1}`); + + const error = + new Error( + `Error in ${indexPart} ` + + `of ${inspect(this, {compact: true})}`, + {cause: caughtError}); + + if (this.#traceError && !disabledTagTracing) { + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; + + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; + + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; + } + + return error; + } + static normalize(content) { // Normalizes contents that are valid from an `isHTML` perspective so // that it's always a pure, single Tag object. @@ -1131,6 +1284,34 @@ export class Attributes { } add(...args) { + // Very common case: add({class: 'foo', id: 'bar'})¡ + // The argument is a plain object (no Template, no Attributes, + // no blessAttributes symbol). We can skip the expensive + // isAttributesAdditionSinglet() validation and flatten/array handling. + if ( + args.length === 1 && + args[0] && + typeof args[0] === 'object' && + !Array.isArray(args[0]) && + !(args[0] instanceof Attributes) && + !(args[0] instanceof Template) && + !Object.hasOwn(args[0], blessAttributes) + ) { + const obj = args[0]; + + // Preserve existing merge semantics by funnelling each key through + // the internal #addOneAttribute helper (handles class/style union, + // unique merging, etc.) but avoid *per-object* validation overhead. + for (const key of Reflect.ownKeys(obj)) { + this.#addOneAttribute(key, obj[key]); + } + + // Match the original return style (list of results) so callers that + // inspect the return continue to work. + return obj; + } + + // Fall back to the original slow-but-thorough implementation switch (args.length) { case 1: isAttributesAdditionSinglet(args[0]); @@ -1142,10 +1323,11 @@ export class Attributes { default: throw new Error( - `Expected array or object, or attribute and value`); + 'Expected array or object, or attribute and value'); } } + with(...args) { const clone = this.clone(); clone.add(...args); @@ -1291,7 +1473,7 @@ export class Attributes { attributeKeyValues .map(([key, value]) => { const keyPart = key; - const escapedValue = this.#escapeAttributeValue(value); + const escapedValue = escape(value.toString(), {attribute: true}); const valuePart = (color ? colors.green(`"${escapedValue}"`) @@ -1367,13 +1549,6 @@ export class Attributes { } } - #escapeAttributeValue(value) { - return value - .toString() - .replaceAll('"', '"') - .replaceAll("'", '''); - } - static parse(string) { const attributes = Object.create(null); @@ -1473,6 +1648,8 @@ export function resolve(tagOrTemplate, { return Tag.normalize(tagOrTemplate); } else if (normalize === 'string') { return Tag.normalize(tagOrTemplate).toString(); + } else if (normalize === 'plain') { + return Tag.normalize(tagOrTemplate).toPlainText(); } else if (normalize) { throw new TypeError(`Expected normalize to be 'tag', 'string', or null`); } else { @@ -1521,6 +1698,61 @@ export function smooth(smoothie) { return tags(helper(smoothie)); } +export function inside(insidee) { + if (insidee instanceof Template) { + return inside(Template.resolve(insidee)); + } + + if (insidee instanceof Tag) { + return Array.from(smooth(tags(insidee.content)).content); + } + + return []; +} + +export function findInside(insidee, query) { + if (typeof query === 'object' && query.slots) { + return findInside(insidee, item => + Template.resolveForSlots(item, query.slots, 'null')); + } + + if (typeof query === 'object' && query.annotation) { + return findInside(insidee, item => + Template.resolveForAnnotation(item, query.annotation, 'null')); + } + + if (typeof query === 'object' && query.tag) { + return findInside(insidee, item => { + const tag = normalize(item); + if (tag.tagName === query) { + return tag; + } else { + return null; + } + }); + } + + if (typeof query === 'string') { + return findInside(insidee, item => + Template.resolveForContentFunction(item, query, 'null')); + } + + if (typeof query !== 'function') { + throw new Error(`Expected {slots}, {annotation}, or query function`); + } + + for (const item of inside(insidee)) { + const result = query(item); + if (result && result === true) { + return item; + } else if (result) { + return result; + } + } + + return null; +} + export function template(description) { return new Template(description); } @@ -1739,6 +1971,10 @@ export class Template { } static validateSlotValueAgainstDescription(value, description) { + if (disabledSlotValidation) { + return true; + } + if (value === undefined) { throw new TypeError(`Specify value as null or don't specify at all`); } @@ -1916,17 +2152,11 @@ export class Template { return this.content.toString(); } - static resolve(tagOrTemplate) { + static resolve(content) { // Flattens contents of a template, recursively "resolving" until a // non-template is ready (or just returns a provided non-template // argument as-is). - if (!(tagOrTemplate instanceof Template)) { - return tagOrTemplate; - } - - let {content} = tagOrTemplate; - while (content instanceof Template) { content = content.content; } @@ -1934,7 +2164,7 @@ export class Template { return content; } - static resolveForSlots(tagOrTemplate, slots) { + static resolveForSlots(content, slots, without = 'throw') { if (!slots || typeof slots !== 'object') { throw new Error( `Expected slots to be an object or array, ` + @@ -1942,24 +2172,87 @@ export class Template { } if (!Array.isArray(slots)) { - return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); + return Template.resolveForSlots(content, Object.keys(slots)).slots(slots); } - while (tagOrTemplate && tagOrTemplate instanceof Template) { + while (content instanceof Template) { try { for (const slot of slots) { - tagOrTemplate.getSlotDescription(slot); + content.getSlotDescription(slot); } - return tagOrTemplate; + return content; } catch { - tagOrTemplate = tagOrTemplate.content; + content = content.content; } } - throw new Error( - `Didn't find slots ${inspect(slots, {compact: true})} ` + - `resolving ${inspect(tagOrTemplate, {compact: true})}`); + if (without === 'throw') { + throw new Error( + `Didn't find slots ${inspect(slots, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForAnnotation(content, annotation, without = 'throw') { + if (!annotation || typeof annotation !== 'string') { + throw new Error( + `Expected annotation to be a string, ` + + `got ${typeAppearance(annotation)}`); + } + + while (content instanceof Template) { + if (content.description.annotation === annotation) { + return content; + } else { + content = content.content; + } + } + + if (without === 'throw') { + throw new Error( + `Didn't find annotation ${inspect(annotation, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForContentFunction(content, dependency, without = 'throw') { + if (!dependency || typeof dependency !== 'string') { + throw new Error( + `Expected dependency to be a string, ` + + `got ${typeAppearance(dependency)}`); + } + + const considerContentFunction = () => + (content instanceof Tag || content instanceof Template) && + Object.hasOwn(content, Symbol.for('hsmusic.contentFunction.via')) && + content[Symbol.for('hsmusic.contentFunction.via')].includes(dependency); + + while (content instanceof Template) { + if (considerContentFunction()) { + return content; + } else if (content.description.annotation === dependency) { + return content; + } else { + content = content.content; + } + } + + if (considerContentFunction()) { + return content; + } + + if (without === 'throw') { + throw new Error( + `Didn't find dependency ${inspect(dependency, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } } [inspect.custom]() { diff --git a/src/listing-spec.js b/src/listing-spec.js index 142c5976..a301845b 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -178,6 +178,7 @@ listingSpec.push({ directory: 'tracks/with-lyrics', stringsKey: 'listTracks.withLyrics', contentFunction: 'listTracksWithLyrics', + seeAlso: ['tracks/needing-lyrics'], }); listingSpec.push({ @@ -195,6 +196,13 @@ listingSpec.push({ }); listingSpec.push({ + directory: 'tracks/needing-lyrics', + stringsKey: 'listTracks.needingLyrics', + contentFunction: 'listTracksNeedingLyrics', + seeAlso: ['tracks/with-lyrics'], +}); + +listingSpec.push({ directory: 'tags/by-name', stringsKey: 'listArtTags.byName', contentFunction: 'listArtTagsByName', @@ -291,7 +299,7 @@ for (const [index, listing] of listingSpec.entries()) { errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`)); } } else { - listing.seeAlso = null; + listing.seeAlso = []; } } diff --git a/src/page/album.js b/src/page/album.js index 696e2854..e585618c 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -8,15 +8,22 @@ export function targets({wikiData}) { export function pathsForTarget(album) { return [ - { - type: 'page', - path: ['album', album.directory], - - contentFunction: { - name: 'generateAlbumInfoPage', - args: [album], - }, - }, + (album.style === 'single' + ? { + type: 'redirect', + fromPath: ['album', album.directory], + toPath: ['track', album.tracks[0].directory], + title: album.name, + } + : { + type: 'page', + path: ['album', album.directory], + + contentFunction: { + name: 'generateAlbumInfoPage', + args: [album], + }, + }), { type: 'page', diff --git a/src/page/artist.js b/src/page/artist.js index 257e060d..7cd50bb3 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -32,6 +32,23 @@ export function pathsForTarget(artist) { args: [artist], }, }, + + { + type: 'page', + path: ['artistRollingWindow', artist.directory], + + condition: () => + artist.musicContributions.some(contrib => contrib.date) || + artist.artworkContributions.some(contrib => + contrib.date && + contrib.thingProperty !== 'wallpaperArtistContribs' && + contrib.thingProperty !== 'bannerArtistContribs'), + + contentFunction: { + name: 'generateArtistRollingWindowPage', + args: [artist], + }, + }, ]; } diff --git a/src/replacer.js b/src/replacer.js index 0698eced..8a929444 100644 --- a/src/replacer.js +++ b/src/replacer.js @@ -8,8 +8,8 @@ import * as marked from 'marked'; import * as html from '#html'; -import {escapeRegex, typeAppearance} from '#sugar'; -import {matchMarkdownLinks} from '#wiki-data'; +import {empty, escapeRegex, typeAppearance} from '#sugar'; +import {matchInlineLinks, matchMarkdownLinks} from '#wiki-data'; export const replacerSpec = { 'album': { @@ -190,6 +190,9 @@ const tagHash = '#'; const tagArgument = '*'; const tagArgumentValue = '='; const tagLabel = '|'; +const tooltipBeginning = '<<'; +const tooltipEnding = '>>'; +const tooltipContent = ':'; const noPrecedingWhitespace = '(?<!\\s)'; @@ -208,6 +211,14 @@ const R_tagArgumentValue = escapeRegex(tagArgumentValue); const R_tagLabel = escapeRegex(tagLabel); +const R_tooltipBeginning = + '(?<=[^<]|^)' + escapeRegex(tooltipBeginning) + '(?!<)'; + +const R_tooltipEnding = + '(?<=[^>]|^)' + escapeRegex(tooltipEnding) + '(?!>)'; + +const R_tooltipContent = escapeRegex(tooltipContent); + const regexpCache = {}; const makeError = (i, message) => ({i, type: 'error', data: {message}}); @@ -247,9 +258,13 @@ function parseNodes(input, i, stopAt, textOnly) { } }; - const literalsToMatch = stopAt - ? stopAt.concat([R_tagBeginning]) - : [R_tagBeginning]; + const beginnings = [tagBeginning, tooltipBeginning]; + const R_beginnings = [R_tagBeginning, R_tooltipBeginning]; + + const literalsToMatch = + (stopAt + ? stopAt.concat(R_beginnings) + : R_beginnings); // The 8ackslash stuff here is to only match an even (or zero) num8er // of sequential 'slashes. Even amounts always cancel out! Odd amounts @@ -300,8 +315,10 @@ function parseNodes(input, i, stopAt, textOnly) { if (textOnly && closestMatch === tagBeginning) throw makeError(i, `Unexpected [[tag]] - expected only text here.`); + if (textOnly && closestMatch === tooltipBeginning) + throw makeError(i, `Unexpected <<tooltip>> - expected only text here.`); - const stopHere = closestMatch !== tagBeginning; + const stopHere = !beginnings.includes(closestMatch); iString = i; i = closestMatchIndex; @@ -453,6 +470,51 @@ function parseNodes(input, i, stopAt, textOnly) { continue; } + + if (closestMatch === tooltipBeginning) { + const iTooltip = closestMatchIndex; + + let N; + + // Label (hoverable text) + + let label; + + N = parseNodes(input, i, [R_tooltipContent, R_tooltipEnding]); + + if (!stopped) + throw endOfInput(i, `reading tooltip label`); + if (input.slice(i).startsWith(tooltipEnding)) + throw makeError(i, `Expected tooltip label and content.`); + if (!N.length) + throw makeError(i, `Expected tooltip label before content.`); + + label = N; + i = stop_iParse; + + // Content (tooltip text) + + let content; + + N = parseNodes(input, i, [R_tooltipEnding]); + + if (!stopped) + throw endOfInput(i, `reading tooltip content`); + if (!N.length) + throw makeError(i, `Expected tooltip content`); + + content = N; + i = stop_iParse; + + nodes.push({ + i: iTooltip, + iEnd: i, + type: 'tooltip', + data: {label, content}, + }); + + continue; + } } return nodes; @@ -464,7 +526,7 @@ export function squashBackslashes(text) { // a set of characters where the backslash carries meaning // into later formatting (i.e. markdown). Note that we do // NOT compress double backslashes into single backslashes. - return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1'); + return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1'); } export function restoreRawHTMLTags(text) { @@ -526,6 +588,7 @@ export function postprocessComments(inputNodes) { function postprocessHTMLTags(inputNodes, tagName, callback) { const outputNodes = []; + const errors = []; const lastNode = inputNodes.at(-1); @@ -593,10 +656,16 @@ function postprocessHTMLTags(inputNodes, tagName, callback) { return false; })(); - outputNodes.push( - callback(attributes, { - inline, - })); + try { + outputNodes.push( + callback(attributes, { + inline, + })); + } catch (caughtError) { + errors.push(new Error( + `Failed to process ${match[0]}`, + {cause: caughtError})); + } // No longer at the start of a line after the tag - there will at // least be text with only '\n' before the next of this tag that's @@ -619,15 +688,33 @@ function postprocessHTMLTags(inputNodes, tagName, callback) { outputNodes.push(node); } + if (!empty(errors)) { + throw new AggregateError( + errors, + `Errors postprocessing <${tagName}> tags`); + } + return outputNodes; } +function complainAboutMediaSrc(src) { + if (!src) { + throw new Error(`Missing "src" attribute`); + } + + if (src.startsWith('/media/')) { + throw new Error(`Start "src" with "media/", not "/media/"`); + } +} + export function postprocessImages(inputNodes) { return postprocessHTMLTags(inputNodes, 'img', (attributes, {inline}) => { const node = {type: 'image'}; node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); + node.inline = attributes.get('inline') ?? inline; if (attributes.get('link')) node.link = attributes.get('link'); @@ -648,10 +735,13 @@ export function postprocessImages(inputNodes) { export function postprocessVideos(inputNodes) { return postprocessHTMLTags(inputNodes, 'video', - attributes => { + (attributes, {inline}) => { const node = {type: 'video'}; node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); + + node.inline = attributes.get('inline') ?? inline; if (attributes.get('width')) node.width = parseInt(attributes.get('width')); if (attributes.get('height')) node.height = parseInt(attributes.get('height')); @@ -668,8 +758,12 @@ export function postprocessAudios(inputNodes) { const node = {type: 'audio'}; node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); + node.inline = attributes.get('inline') ?? inline; + if (attributes.get('align')) node.align = attributes.get('align'); + if (attributes.get('nameless')) node.nameless = true; return node; }); @@ -762,7 +856,7 @@ export function postprocessSummaries(inputNodes) { } export function postprocessExternalLinks(inputNodes) { - const outputNodes = []; + let outputNodes = []; for (const node of inputNodes) { if (node.type !== 'text') { @@ -818,57 +912,169 @@ export function postprocessExternalLinks(inputNodes) { } } + // Repeat everything, but for inline links, which are just a URL on its own, + // not formatted as a Markdown link. These don't have provided labels, and + // get labels automatically filled in by content code. + + inputNodes = outputNodes; + outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + let textNode = { + i: node.i, + iEnd: null, + type: 'text', + data: '', + }; + + let parseFrom = 0; + for (const match of matchInlineLinks(node.data)) { + const {href, index, length} = match; + + textNode.data += node.data.slice(parseFrom, index); + + if (textNode.data) { + textNode.iEnd = textNode.i + textNode.data.length; + outputNodes.push(textNode); + + textNode = { + i: node.i + index + length, + iEnd: null, + type: 'text', + data: '', + }; + } + + outputNodes.push({ + i: node.i + index, + iEnd: node.i + index + length, + type: 'external-link', + data: {label: null, href}, + }); + + parseFrom = index + length; + } + + if (parseFrom !== node.data.length) { + textNode.data += node.data.slice(parseFrom); + textNode.iEnd = node.iEnd; + } + + if (textNode.data) { + outputNodes.push(textNode); + } + } + return outputNodes; } -export function parseContentNodes(input) { +export function parseContentNodes(input, { + errorMode = 'throw', +} = {}) { if (typeof input !== 'string') { throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`); } - try { - let output = parseNodes(input, 0); - output = postprocessComments(output); - output = postprocessImages(output); - output = postprocessVideos(output); - output = postprocessAudios(output); - output = postprocessHeadings(output); - output = postprocessSummaries(output); - output = postprocessExternalLinks(output); - return output; - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; - } - - const { - i, - data: {message}, - } = errorNode; - - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; + let result = null, error = null; + + process: { + try { + result = parseNodes(input, 0); + } catch (caughtError) { + if (caughtError.type === 'error') { + const {i, data: {message}} = caughtError; + + let lineStart = input.slice(0, i).lastIndexOf('\n'); + if (lineStart >= 0) { + lineStart += 1; + } else { + lineStart = 0; + } + + let lineEnd = input.slice(i).indexOf('\n'); + if (lineEnd >= 0) { + lineEnd += i; + } else { + lineEnd = input.length; + } + + const line = input.slice(lineStart, lineEnd); + + const cursor = i - lineStart; + + error = + new SyntaxError( + `Parse error (at pos ${i}): ${message}\n` + + line + `\n` + + '-'.repeat(cursor) + '^'); + } else { + error = caughtError; + } + + // A parse error means there's no output to continue with at all, + // so stop here. + break process; } - let lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; - } else { - lineEnd = input.length; + const postprocessErrors = []; + + for (const postprocess of [ + postprocessComments, + postprocessImages, + postprocessVideos, + postprocessAudios, + postprocessHeadings, + postprocessSummaries, + postprocessExternalLinks, + ]) { + try { + result = postprocess(result); + } catch (caughtError) { + const error = + new Error( + `Error in step ${`"${postprocess.name}"`}`, + {cause: caughtError}); + + error[Symbol.for('hsmusic.aggregate.translucent')] = true; + + postprocessErrors.push(error); + } } - const line = input.slice(lineStart, lineEnd); + if (!empty(postprocessErrors)) { + error = + new AggregateError( + postprocessErrors, + `Errors postprocessing content text`); + + error[Symbol.for('hsmusic.aggregate.translucent')] = 'single'; + } + } - const cursor = i - lineStart; + if (errorMode === 'throw') { + if (error) { + throw error; + } else { + return result; + } + } else if (errorMode === 'return') { + if (!result) { + result = [{ + i: 0, + iEnd: input.length, + type: 'text', + data: input, + }]; + } - throw new SyntaxError([ - `Parse error (at pos ${i}): ${message}`, - line, - '-'.repeat(cursor) + '^', - ].join('\n')); + return {error, result}; + } else { + throw new Error(`Unknown errorMode ${errorMode}`); } } diff --git a/src/search-select.js b/src/search-select.js new file mode 100644 index 00000000..36b9e98a --- /dev/null +++ b/src/search-select.js @@ -0,0 +1,262 @@ +// Complements the specs in search-shape.js with the functions that actually +// process live wiki data into records that are appropriate for storage. +// These files totally go together, so read them side by side, okay? + +import baseSearchSpec from '#search-shape'; +import {unique} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +function prepareArtwork(artwork, thing, { + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + urls, +}) { + if (!artwork) { + return undefined; + } + + const hasWarnings = + artwork.artTags?.some(artTag => artTag.isContentWarning); + + const artworkPath = + artwork.path; + + if (!artworkPath) { + return undefined; + } + + const mediaSrc = + urls + .from('media.root') + .to(...artworkPath); + + if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) { + return undefined; + } + + const selectedSize = + getThumbnailEqualOrSmaller( + (hasWarnings ? 'mini' : 'adorb'), + mediaSrc); + + const mediaSrcJpeg = + mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); + + const displaySrc = + urls + .from('thumb.root') + .to('thumb.path', mediaSrcJpeg); + + const serializeSrc = + displaySrc.replace(thing.directory, '<>'); + + return serializeSrc; +} + +function determineArtistGroups(artist, opts) { + const contributions = [ + artist.musicContributions, + artist.artworkContributions + .filter(contrib => !contrib.annotation?.includes('edits for wiki')), + ].flat(); + + const contributionGroups = + contributions.flatMap(contrib => contrib.groups); + + const scores = + new Map( + unique(contributionGroups).map(group => [group, 0])); + + const artistNamesish = + unique( + [artist.name, ...artist.artistAliases.map(alias => alias.name)] + .map(name => getKebabCase(name))); + + for (const group of scores.keys()) { + if (artistNamesish.includes(getKebabCase(group.name))) { + scores.delete(group); + } + } + + for (const group of contributionGroups) { + scores.set(group, scores.get(group) + 1 / contributions.length); + } + + const dividingGroups = + opts.wikiInfo.divideTrackListsByGroups; + + const dividingGroupThreshold = + (contributions.length < 50 ? 0.08 : 0.16); + + const generalGroupThreshold = + (contributions.length < 50 ? 0.00 : 0.12); + + for (const group of scores.keys()) { + const threshold = + (dividingGroups.includes(group) + ? dividingGroupThreshold + : generalGroupThreshold); + + if (scores.get(group) < threshold) { + scores.delete(group); + } + } + + return Array.from(scores.keys()); +} + +function baselineProcess(thing, _opts) { + const fields = {}; + + fields.primaryName = + thing.name; + + fields.artwork = + null; + + fields.color = + thing.color; + + fields.disambiguator = + null; + + return fields; +} + +function genericSelect(wikiData) { + const groupOrder = + wikiData.wikiInfo.divideTrackListsByGroups; + + const getGroupRank = thing => { + const relevantRanks = + Array.from(groupOrder.entries()) + .filter(({1: group}) => thing.groups.includes(group)) + .map(({0: index}) => index); + + if (relevantRanks.length === 0) { + return Infinity; + } else if (relevantRanks.length === 1) { + return relevantRanks[0]; + } else { + return relevantRanks[0] + 0.5; + } + } + + const sortByGroupRank = things => + things.sort((a, b) => getGroupRank(a) - getGroupRank(b)); + + return [ + sortByGroupRank(wikiData.albumData.slice()), + + wikiData.artTagData, + + wikiData.artistData + .filter(artist => !artist.isAlias), + + wikiData.flashData, + + wikiData.groupData, + + sortByGroupRank( + wikiData.trackData + .filter(track => + track.isMainRelease || + (getKebabCase(track.name) !== + getKebabCase(track.mainReleaseTrack.name)))), + ].flat(); +} + +function genericProcess(thing, opts) { + const fields = baselineProcess(thing, opts); + + const boundPrepareArtwork = artwork => + prepareArtwork(artwork, thing, opts); + + fields.artwork = + (thing.isTrack && thing.hasUniqueCoverArt + ? boundPrepareArtwork(thing.trackArtworks[0]) + : thing.isTrack + ? boundPrepareArtwork(thing.album.coverArtworks[0]) + : thing.isAlbum + ? boundPrepareArtwork(thing.coverArtworks[0]) + : thing.isFlash + ? boundPrepareArtwork(thing.coverArtwork) + : null); + + fields.parentName = + (thing.isTrack ? thing.album.name + : thing.isGroup ? thing.category.name + : thing.isFlash ? thing.act.name + : null); + + fields.disambiguator = + fields.parentName; + + fields.artTags = + (Array.from(new Set( + (thing.isTrack + ? thing.trackArtworks.flatMap(artwork => artwork.artTags) + : thing.isAlbum + ? thing.coverArtworks.flatMap(artwork => artwork.artTags) + : [])))) + + .map(artTag => artTag.nameShort); + + fields.additionalNames = + (thing.constructor.hasPropertyDescriptor('additionalNames') + ? thing.additionalNames.map(entry => entry.name) + : thing.constructor.hasPropertyDescriptor('artistAliases') + ? thing.artistAliases.map(alias => alias.name) + : []); + + const contribKeys = [ + 'artistContribs', + 'contributorContribs', + ]; + + const contributions = + contribKeys + .flatMap(key => thing[key] ?? []); + + fields.contributors = + contributions + .flatMap(({artist}) => [ + artist.name, + ...artist.artistAliases.map(alias => alias.name), + ]); + + const groups = + (thing.isAlbum ? thing.groups + : thing.isTrack ? thing.album.groups + : thing.isArtist ? determineArtistGroups(thing, opts) + : []); + + const mainContributorNames = + contributions + .map(({artist}) => artist.name); + + fields.groups = + groups + .filter(group => !mainContributorNames.includes(group.name)) + .map(group => group.name); + + return fields; +} + +const spiffySearchSpec = { + generic: { + ...baseSearchSpec.generic, + + select: genericSelect, + process: genericProcess, + }, + + verbatim: { + ...baseSearchSpec.verbatim, + + select: genericSelect, + process: genericProcess, + }, +}; + +export default spiffySearchSpec; diff --git a/src/search.js b/src/search.js index a2dae9e1..bef8107f 100644 --- a/src/search.js +++ b/src/search.js @@ -9,11 +9,56 @@ import FlexSearch from 'flexsearch'; import {pack} from 'msgpackr'; import {logWarn} from '#cli'; -import {makeSearchIndex, populateSearchIndex, searchSpec} from '#search-spec'; +import {makeSearchIndex} from '#search-shape'; +import searchSpec from '#search-select'; import {stitchArrays} from '#sugar'; import {checkIfImagePathHasCachedThumbnails, getThumbnailEqualOrSmaller} from '#thumbs'; +// TODO: This function basically mirrors bind-utilities.js, which isn't +// exactly robust, but... binding might need some more thought across the +// codebase in *general.* +function bindSearchUtilities({ + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + thumbsCache, + urls, + wikiData, +}) { + const bound = { + urls, + wikiData, + wikiInfo: wikiData.wikiInfo, + }; + + bound.checkIfImagePathHasCachedThumbnails = + (imagePath) => + checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); + + bound.getThumbnailEqualOrSmaller = + (preferred, imagePath) => + getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); + + return bound; +} + +function populateSearchIndex(index, descriptor, wikiData, utilities) { + for (const thing of descriptor.select(wikiData)) { + const reference = thing.constructor.getReference(thing); + + let processed; + try { + processed = descriptor.process(thing, utilities); + } catch (caughtError) { + throw new Error( + `Failed to process searchable thing ${reference}`, + {cause: caughtError}); + } + + index.add({reference, ...processed}); + } +} + async function serializeIndex(index) { const results = {}; @@ -60,17 +105,20 @@ export async function writeSearchData({ .map(descriptor => makeSearchIndex(descriptor, {FlexSearch})); + const utilities = + bindSearchUtilities({ + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + thumbsCache, + urls, + wikiData, + }); + stitchArrays({ index: indexes, descriptor: descriptors, }).forEach(({index, descriptor}) => - populateSearchIndex(index, descriptor, { - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - thumbsCache, - urls, - wikiData, - })); + populateSearchIndex(index, descriptor, wikiData, utilities)); const serializedIndexes = await Promise.all(indexes.map(serializeIndex)); diff --git a/src/static/css/site.css b/src/static/css/site.css index 5934e206..ef3ffe8e 100644 --- a/src/static/css/site.css +++ b/src/static/css/site.css @@ -61,7 +61,7 @@ body::before, .wallpaper-part { #page-container { max-width: 1100px; - margin: 0 auto 40px; + margin: 0 auto 38px; padding: 15px 0; } @@ -76,10 +76,25 @@ body::before, .wallpaper-part { height: unset; } +@property --banner-shine { + syntax: '<percentage>'; + initial-value: 0%; + inherits: false; +} + #banner { margin: 10px 0; width: 100%; position: relative; + + --banner-shine: 4%; + -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white)); + transition: --banner-shine 0.8s; +} + +#banner:hover { + --banner-shine: 35%; + transition-delay: 0.3s; } #banner::after { @@ -261,7 +276,11 @@ body::before, .wallpaper-part { #page-container { background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); color: #ffffff; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + border-bottom: 2px solid #fff1; + box-shadow: + 0 0 40px #0008, + 0 2px 15px -3px #2221, + 0 2px 6px 2px #1113; } #skippers > * { @@ -899,6 +918,11 @@ summary.underline-white > span:hover a:not(:hover) { display: inline-block; } +.wiki-search-result-disambiguator { + opacity: 0.9; + display: inline-block; +} + .wiki-search-result-image-container { align-self: flex-start; flex-shrink: 0; @@ -1013,10 +1037,13 @@ a .normal-content { } .image-media-link::after { - content: ''; - display: inline-block; - width: 22px; - height: 1em; + /* Thanks to Jay Freestone for being awesome: + * https://www.jayfreestone.com/writing/wrapping-and-inline-pseudo-elements/ + */ + + pointer-events: none; + content: '\200b'; + padding-left: 22px; background-color: var(--primary-color); @@ -1027,7 +1054,6 @@ a .normal-content { mask-repeat: no-repeat; mask-position: calc(100% - 2px); - vertical-align: text-bottom; } .image-media-link:hover::after { @@ -1095,7 +1121,16 @@ a .normal-content { font-weight: 800; } +.dot-switcher > span { + color: #ffffffcc; +} + .dot-switcher > span.current { + font-weight: normal; + color: white; +} + +.dot-switcher > span.current a { font-weight: 800; } @@ -1109,6 +1144,15 @@ a .normal-content { text-decoration: none !important; } +label:hover span { + text-decoration: underline; + text-decoration-style: solid; +} + +label > input[type=checkbox]:not(:checked) + span { + opacity: 0.8; +} + #secondary-nav { text-align: center; @@ -1193,7 +1237,12 @@ a .normal-content { white-space: nowrap; } -.isolate-tooltip-z-indexing > * { +:where(.isolate-tooltip-z-indexing) { + position: relative; + z-index: 1; +} + +:where(.isolate-tooltip-z-indexing > *) { position: relative; z-index: -1; } @@ -1296,6 +1345,16 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), height: 1.4em; } +.contribution-tooltip .chronology-heading { + grid-column-start: handle-start; + grid-column-end: platform-end; + min-width: 30ch; + + font-size: 0.85em; + font-style: oblique; + margin-bottom: 2px; +} + .contribution-tooltip .chronology-link { display: grid; grid-column-start: icon-start; @@ -1429,6 +1488,10 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), font-size: 0.9em; } +.rerelease-tooltip .not-credited-on-first-release { + opacity: 0.9; +} + .content-tooltip-guy .hoverable a { text-decoration-color: transparent; text-decoration-style: dotted; @@ -1438,19 +1501,25 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), display: inline-block; } +.content-tooltip-guy:not(.has-link) .hoverable { + cursor: default; +} + .content-tooltip-guy.has-link .text-with-tooltip-interaction-cue { text-decoration-color: var(--primary-color); } .content-tooltip .tooltip-content { padding: 3px 4.5px; - width: 240px; + width: max-content; + max-width: 240px; } .cover-artwork .content-tooltip { font-size: 0.85rem; padding: 2px 3px; - width: 220px; + width: max-content; + max-width: 220px; } .external-icon { @@ -1483,6 +1552,50 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), color: var(--page-primary-color); } +.wiki-commentary s:not(.spoiler) { + text-decoration-color: #fff9; + text-decoration-thickness: 1.4px; + color: #fffb; +} + +pre.content-code { + position: relative; + white-space: nowrap; + + max-width: calc(100vw - 180px); + + /* Welcome to heck. */ + font-family: inherit; + + border: 1px dashed var(--primary-color); +} + +pre.content-code span { + display: block; + overflow-x: scroll; + padding: 5px 20px 5px 5px; + background: black; + color: white; +} + +pre.content-code::before { + content: ""; + display: block; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + box-shadow: -15px 0 24px -8px black inset; + pointer-events: none; +} + +pre.content-code code { + font-family: "courier new", monospace; + font-weight: 800; + font-size: 0.8em; +} + s.spoiler { display: inline-block; color: transparent; @@ -1503,10 +1616,30 @@ s.spoiler::-moz-selection { background: white; } -span.path { - font-size: 0.9em; +span.path, code.filename { + font-size: 0.95em; font-family: "courier new", monospace; font-weight: 800; + background: #ccc3; + + padding: 0.05em 0.5ch; + border: 1px solid #ccce; + border-radius: 2px; + line-height: 1.4; +} + +blockquote :is(span.path, code.filename) { + font-size: 0.9em; +} + +.image-details code.filename { + margin-left: -0.4ch; + opacity: 0.8; +} + +.image-details code.filename:hover { + opacity: 1; + cursor: text; } span.path i { @@ -1575,9 +1708,11 @@ hr.cute, } #artwork-column .cover-artwork { + --normal-shadow: 0 0 12px 12px #00000080; + box-shadow: 0 2px 14px -6px var(--primary-color), - 0 0 12px 12px #00000080; + var(--normal-shadow); } #artwork-column .cover-artwork:not(:first-child), @@ -1586,6 +1721,10 @@ hr.cute, margin-right: 5px; } +#artwork-column .cover-artwork:not(:first-child) { + --normal-shadow: 0 0 9px 9px #00000068; +} + #artwork-column .cover-artwork:first-child + .cover-artwork-joiner, #artwork-column .cover-artwork.attached-artwork-is-main-artwork, #artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner { @@ -1679,6 +1818,16 @@ p.image-details.origin-details { margin-bottom: 2px; } +p.image-details.origin-details .origin-details-line { + display: block; + margin-top: 0.25em; +} + +p.image-details.origin-details .filename-line { + display: block; + margin-top: 0.25em; +} + .cover-artwork-joiner { z-index: -2; } @@ -1750,6 +1899,16 @@ p.image-details.origin-details { color: var(--primary-color); } +.inherited-commentary-section { + clear: right; + margin-top: 1em; + margin-bottom: 1.5em; + margin-right: min(4vw, 60px); + border: 2px solid var(--deep-color); + border-radius: 4px; + background: #ffffff07; +} + .commentary-art { float: right; width: 30%; @@ -1773,13 +1932,47 @@ p.image-details.origin-details { font-weight: 800; } -.lyrics-entry { - padding-left: 40px; +.lyrics-entry .lyrics-details, +.lyrics-entry .origin-details { + font-size: 0.9em; + font-style: oblique; } .lyrics-entry .lyrics-details { - font-size: 0.9em; - font-style: oblique; + margin-bottom: 0; +} + +.lyrics-entry .origin-details { + margin-top: 0.25em; +} + +.lyrics-entry.long-lyrics { + clip-path: inset(-15px -20px); +} + +.lyrics-entry.long-lyrics::after { + content: ""; + pointer-events: none; + display: block; + + /* Slight stretching past the bottom of the screen seems + * to make resizing the window (and "revealing" that area) + * a bit smoother. + */ + position: fixed; + bottom: -20px; + left: 0; + right: 0; + + height: calc(20px + min(90px, 13.5vh)); + background: linear-gradient(to bottom, transparent, black 70%, black); + opacity: 0.6; +} + +.lyrics-entry sup { + vertical-align: text-top; + opacity: 0.8; + cursor: default; } .js-hide, @@ -1801,12 +1994,20 @@ p.image-details.origin-details { margin-bottom: 1.5em; } -a.align-center, img.align-center, audio.align-center { +.content-image-container.align-full { + width: 100%; +} + +a.align-center, img.align-center, audio.align-center, video.align-center { display: block; margin-left: auto; margin-right: auto; } +a.align-full, a.align-full img, img.align-full, video.align-full { + width: 100%; +} + center { margin-top: 1em; margin-bottom: 1em; @@ -1857,6 +2058,11 @@ h1 { white-space: nowrap; } +#content details { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + #content.top-index h1, #content.flash-index h1 { text-align: center; @@ -1921,48 +2127,34 @@ ul.quick-info li:not(:last-child)::after { text-align: center; } -.gallery-view-switcher { +.gallery-view-switcher, +.gallery-style-selector { margin-left: auto; margin-right: auto; text-align: center; line-height: 1.4; } -#content.top-index section { - margin-bottom: 1.5em; +.gallery-style-selector .styles { + display: inline-flex; + justify-content: center; } -.expandable-gallery-section .section-expando { - margin-top: 1em; - margin-bottom: 2em; - - display: flex; - flex-direction: row; - justify-content: space-around; +.gallery-style-selector .styles label:not(:last-child) { + margin-right: 1.25ch; } -.expandable-gallery-section .section-expando-content { - text-align: center; - line-height: 1.5; -} +.gallery-style-selector .count { + font-size: 0.85em; -.expandable-gallery-section .section-expando-toggle { - text-decoration: underline; - text-decoration-style: dotted; -} + position: relative; + bottom: -0.25em; -.expandable-gallery-section.expanded .section-content-below-cut { - animation: expand-gallery-section 0.8s forwards; + opacity: 0.9; } -@keyframes expand-gallery-section { - from { - opacity: 0; - } - - to { - opacity: 1; - } +#content.top-index section { + margin-bottom: 1.5em; } .quick-description:not(.has-external-links-only) { @@ -2134,6 +2326,13 @@ ul > li.has-details { margin-left: -17px; } +li .origin-details { + display: block; + margin-left: 2ch; + font-size: 0.9em; + font-style: oblique; +} + .album-group-list dt, .group-series-list dt { font-style: oblique; @@ -2154,17 +2353,23 @@ ul > li.has-details { text-indent: 0; } -.album-group-list blockquote { +blockquote:is( + .album-group-list *, .group-series-list * +) { max-width: 540px; margin-bottom: 9px; margin-top: 3px; } -.album-group-list blockquote p:first-child { +blockquote p:first-child:is( + .album-group-list *, .group-series-list * +) { margin-top: 0; } -.album-group-list blockquote p:last-child { +blockquote p:last-child:is( + .album-group-list *, .group-series-list * +) { margin-bottom: 0; } @@ -2184,31 +2389,54 @@ ul > li.has-details { #content hr { border: 1px inset #808080; - width: 100%; +} + +#content hr.split { + color: #808080; } #content hr.split::before { content: "(split)"; - color: #808080; } -#content hr.split { +#content hr.main-separator { + color: var(--dim-color); + clear: none; + margin-top: -0.25em; + margin-bottom: 1.75em; +} + +#content hr.main-separator::before { + content: "♦"; + font-size: 1.2em; +} + +#content hr.split, +#content hr.main-separator { position: relative; overflow: hidden; border: none; } -#content hr.split::after { +#content hr.split::after, +#content hr.main-separator::after { display: inline-block; content: ""; - border: 1px inset #808080; - width: 100%; + width: calc(100% - min(calc(8vw - 35px), 45px)); position: absolute; top: 50%; - margin-top: -2px; margin-left: 10px; } +#content hr.split::after { + border: 1px inset currentColor; + margin-top: -2px; +} + +#content hr.main-separator::after { + border-bottom: 1px solid currentColor; +} + li > ul { margin-top: 5px; } @@ -2288,6 +2516,65 @@ html[data-url-key="localized.albumCommentary"] p.track-info { margin-left: 20px; } +html[data-url-key="localized.artistRollingWindow"] #content p { + text-align: center; +} + +html[data-url-key="localized.artistRollingWindow"] #content input[type=number] { + width: 3em; + margin: 0 0.25em; + background: black; + color: white; + border: 1px dotted var(--primary-color); + padding: 4px; + border-radius: 3px; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a { + display: inline-block; + padding: 5px; + text-decoration: underline; + text-decoration-style: dotted; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a:not([href]) { + text-decoration: none; + opacity: 0.7; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area { + border: 1px dashed #ffffff42; + border-top-style: solid; + border-bottom-style: solid; + + display: flex; + flex-direction: column; + justify-content: center; + min-height: calc(100vh - 260px); +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area .grid-listing { + width: 100%; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item.peeking { + opacity: 0.8; + background: #ffffff24; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) > *:not([style*="display: none"]) ~ *::before { + content: '\00b7'; + margin-left: 0.5ch; + margin-right: 0.5ch; +} + html[data-url-key="localized.groupInfo"] .by a { color: var(--page-primary-color); } @@ -2326,7 +2613,9 @@ h1 a[href="#additional-names-box"]:hover { max-width: min(60vw, 600px); padding: 15px 20px 10px 20px; +} +#additional-names-box:not(.always-visible) { display: none; } @@ -2581,6 +2870,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las .content-video-container, .content-audio-container { width: fit-content; + max-width: 100%; background-color: var(--dark-color); border: 2px solid var(--primary-color); border-radius: 2.5px 2.5px 3px 3px; @@ -2590,6 +2880,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las .content-video-container video, .content-audio-container audio { display: block; + max-width: 100%; } .content-video-container.align-center, @@ -2598,6 +2889,11 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las margin-right: auto; } +.content-video-container.align-full, +.content-audio-container.align-full { + width: 100%; +} + .content-audio-container .filename { color: white; font-family: monospace; @@ -2664,6 +2960,23 @@ img { object-fit: cover; } +.image { + --reveal-filter: ; + --shadow-filter: ; + + backdrop-filter: blur(0); + filter: + var(--reveal-filter) + var(--shadow-filter); +} + +p > img, li > img { + max-width: 100%; + object-fit: contain; + height: auto; + vertical-align: text-bottom; +} + .image-inner-area::after { content: ""; display: block; @@ -2723,9 +3036,9 @@ video.pixelate, .pixelate video { text-decoration-style: dotted; } -.reveal .image { +.reveal:not(.revealed) .image { opacity: 0.7; - filter: blur(20px) brightness(0.7); + --reveal-filter: blur(20px) brightness(0.7); } .reveal .image.reveal-thumbnail { @@ -2749,7 +3062,6 @@ video.pixelate, .pixelate video { } .reveal.revealed .image { - filter: none; opacity: 1; } @@ -2760,7 +3072,6 @@ video.pixelate, .pixelate video { .reveal:not(.revealed) .image-outer-area > * { --reveal-border-radius: 6px; position: relative; - overflow: hidden; border-radius: var(--reveal-border-radius); } @@ -2796,7 +3107,7 @@ video.pixelate, .pixelate video { } .reveal:not(.revealed) .image-outer-area > *:hover .image { - filter: blur(20px) brightness(0.6); + --reveal-filter: blur(20px) brightness(0.6); opacity: 0.6; } @@ -2822,20 +3133,88 @@ video.pixelate, .pixelate video { justify-content: center; align-items: flex-start; padding: 5px 15px; + box-sizing: border-box; +} + +.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) { + padding-bottom: 140px; + background: #cccccc07; + border-radius: 10px; + border: 1px dashed #fff3; +} + +.grid-listing .reveal-all-container { + flex-basis: 100%; +} + +.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) .reveal-all-container { + display: none; +} + +.grid-listing .reveal-all-container.has-nearby-tab { + margin-bottom: 0.6em; +} + +.grid-listing .reveal-all { + max-width: 400px; + margin: 0.20em auto 0; + text-align: center; +} + +.grid-listing .reveal-all .warnings:not(.reveal-all:hover *) { + opacity: 0.4; +} + +.grid-listing .reveal-all a { + display: inline-block; + margin-bottom: 0.15em; + + text-decoration: underline; + text-decoration-style: dotted; +} + +.grid-listing .reveal-all b { + white-space: nowrap; } .grid-item { + line-height: 1.2; font-size: 0.9em; } .grid-item { + --tab-pull: 0px; + --tabnt-offset: 0px; + display: inline-block; text-align: center; background-color: #111111; border: 1px dotted var(--primary-color); border-radius: 2px; padding: 5px; + margin: 10px; + margin-top: + calc( + 10px + - var(--tab-pull) + + var(--tabnt-offset)); +} + +.grid-item.has-tab { + border-radius: 8px 8px 3px 3px; +} + +.grid-item.has-tab:hover { + --tab-pull: 3px; +} + +.grid-item:not(.has-tab) { + --tabnt-offset: calc(1.2em - 4px); +} + +.grid-item[class*="hidden-by-"] { + display: none; } .grid-item .image-container { @@ -2852,10 +3231,16 @@ video.pixelate, .pixelate video { } .grid-item .image { + --shadow-filter: + drop-shadow(0 3px 2px #0004) + drop-shadow(0 1px 5px #0001) + drop-shadow(0 3px 4px #0001); + width: 100%; height: 100% !important; margin-top: auto; margin-bottom: auto; + object-fit: contain; } .grid-item:hover { @@ -2872,20 +3257,27 @@ video.pixelate, .pixelate video { hyphens: auto; } -.grid-item > span:not(:first-child) { - margin-top: 2px; -} +/* tab */ +.grid-item > span:first-child { + margin-bottom: calc(3px + var(--tab-pull)); -.grid-item > span:first-of-type { - margin-top: 6px; + font-style: oblique; } -.grid-item > span:not(:first-of-type) { +/* info */ +.grid-item > .image-container + span ~ span { + margin-top: 2px; + font-size: 0.9em; opacity: 0.8; } -.grid-item:hover > span:first-of-type { +/* title */ +.grid-item > .image-container + span { + margin-top: 6px; +} + +.grid-item:hover > .image-container + span { text-decoration: underline; } @@ -2915,6 +3307,47 @@ video.pixelate, .pixelate video { --dim-color: inherit !important; } +.grid-caption { + flex-basis: 100%; + text-align: center; + line-height: 1.5; +} + +.grid-expando { + margin-top: 1em; + margin-bottom: 2em; + flex-basis: 100%; + + display: flex; + flex-direction: row; + justify-content: space-around; +} + +.grid-expando-content { + margin: 0; + text-align: center; + line-height: 1.5; +} + +.grid-expando-toggle { + text-decoration: underline; + text-decoration-style: dotted; +} + +.grid-item.shown-by-expandable-cut { + animation: expand-cover-grid 0.8s forwards; +} + +@keyframes expand-cover-grid { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + /* Carousel */ .carousel-container { @@ -2935,7 +3368,6 @@ video.pixelate, .pixelate video { left: 0; right: 0; bottom: 0; - z-index: -20; background-color: var(--dim-color); filter: brightness(0.6); } @@ -3220,6 +3652,48 @@ h3.content-heading { clear: both; } +summary.content-heading { + list-style-type: none; +} + +summary.content-heading .cue { + display: inline-flex; + color: var(--primary-color); +} + +summary.content-heading .cue::after { + content: ""; + padding-left: 0.5ch; + display: list-item; + list-style-type: disclosure-closed; + list-style-position: inside; +} + +details[open] > summary.content-heading .cue::after { + list-style-type: disclosure-open; +} + +summary.content-heading > span:hover { + text-decoration: none !important; +} + +summary.content-heading > span:hover .cue { + text-decoration: underline; + text-decoration-style: wavy; +} + +summary.content-heading .when-open { + display: none; +} + +details[open] > summary.content-heading .when-open { + display: unset; +} + +details[open] > summary.content-heading .when-collapsed { + display: none; +} + /* This animation's name is referenced in JavaScript */ @keyframes highlight-hash-link { 0% { @@ -3325,15 +3799,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r grid-template-columns: 1fr min(40%, 90px); } -.content-sticky-heading-root.has-cover { - padding-right: min(40%, 400px); -} - .content-sticky-heading-row h1 { position: relative; margin: 0; padding-right: 20px; - line-height: 1.4; + overflow-x: hidden; } .content-sticky-heading-row h1 .reference-collapsed-heading { @@ -3473,7 +3943,9 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r } #content, .sidebar { - contain: paint; + /* In the year of our pizza 2025, we try commenting this out. + */ + /*contain: paint;*/ } /* Sticky sidebar */ @@ -3860,6 +4332,10 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r max-width: unset; } + #artwork-column .cover-artwork { + --normal-shadow: 0 0 transparent; + } + #artwork-column .cover-artwork:not(:first-child), #artwork-column .cover-artwork-joiner { margin-left: 30px; diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js index 71112313..74d63ad6 100644 --- a/src/static/js/client-util.js +++ b/src/static/js/client-util.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export function rebase(href, rebaseKey = 'rebaseLocalized') { let result = document.documentElement.dataset[rebaseKey] || './'; @@ -37,7 +35,7 @@ export function cssProp(el, ...args) { } } -export function templateContent(el) { +export function templateContent(el, slots = {}) { if (el === null) { return null; } @@ -46,7 +44,25 @@ export function templateContent(el) { throw new Error(`Expected a <template> element`); } - return el.content.cloneNode(true); + const content = el.content.cloneNode(true); + + for (const [key, value] of Object.entries(slots)) { + const slot = content.querySelector(`slot[name="${key}"]`); + + if (!slot) { + console.warn(`Slot ${key} missing in template:`, el); + continue; + } + + if (value === null || value === undefined) { + console.warn(`Valueless slot ${key} in template:`, el); + continue; + } + + slot.replaceWith(value); + } + + return content; } // Curry-style, so multiple points can more conveniently be tested at once. @@ -81,12 +97,12 @@ export function getVisuallyContainingElement(child) { const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); */ -export const openAlbum = d => rebase(`album/${d}`); -export const openArtTag = d => rebase(`tag/${d}`); -export const openArtist = d => rebase(`artist/${d}`); -export const openFlash = d => rebase(`flash/${d}`); -export const openGroup = d => rebase(`group/${d}`); -export const openTrack = d => rebase(`track/${d}`); +export const openAlbum = d => rebase(`album/${d}/`); +export const openArtTag = d => rebase(`tag/${d}/`); +export const openArtist = d => rebase(`artist/${d}/`); +export const openFlash = d => rebase(`flash/${d}/`); +export const openGroup = d => rebase(`group/${d}/`); +export const openTrack = d => rebase(`track/${d}/`); // TODO: This should also use urlSpec. @@ -127,3 +143,10 @@ export function dispatchInternalEvent(event, eventName, ...args) { return results; } + +const languageCode = document.documentElement.getAttribute('lang'); + +export function formatDate(inputDate) { + const date = new Date(inputDate); + return date.toLocaleDateString(languageCode); +} diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js index 195ba25d..a6d9b098 100644 --- a/src/static/js/client/additional-names-box.js +++ b/src/static/js/client/additional-names-box.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {cssProp} from '../client-util.js'; import {info as hashLinkInfo} from './hash-link.js'; diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js index c5eaf81b..d7c4a591 100644 --- a/src/static/js/client/album-commentary-sidebar.js +++ b/src/static/js/client/album-commentary-sidebar.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {empty} from '../../shared-util/sugar.js'; import {info as hashLinkInfo} from './hash-link.js'; diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js index fd40d1a2..f74f640e 100644 --- a/src/static/js/client/art-tag-gallery-filter.js +++ b/src/static/js/client/art-tag-gallery-filter.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: 'artTagGalleryFilterInfo', diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js index 44e10c11..d0576152 100644 --- a/src/static/js/client/art-tag-network.js +++ b/src/static/js/client/art-tag-network.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {cssProp} from '../client-util.js'; import {atOffset, stitchArrays} from '../../shared-util/sugar.js'; diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js index 21ddfb91..2eadf916 100644 --- a/src/static/js/client/artist-external-link-tooltip.js +++ b/src/static/js/client/artist-external-link-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {accumulateSum, empty} from '../../shared-util/sugar.js'; import {info as hoverableTooltipInfo, repositionCurrentTooltip} diff --git a/src/static/js/client/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js new file mode 100644 index 00000000..b8ff7354 --- /dev/null +++ b/src/static/js/client/artist-rolling-window.js @@ -0,0 +1,571 @@ +import {cssProp, formatDate} from '../client-util.js'; + +import {sortByDate} from '../../shared-util/sort.js'; +import {chunkByConditions, chunkByProperties, empty, stitchArrays} + from '../../shared-util/sugar.js'; + +export const info = { + id: 'artistRollingWindowInfo', + + timeframeMonthsBefore: null, + timeframeMonthsAfter: null, + timeframeMonthsPeek: null, + + contributionKind: null, + contributionGroup: null, + + timeframeSelectionSomeLine: null, + timeframeSelectionNoneLine: null, + + timeframeSelectionContributionCount: null, + timeframeSelectionTimeframeCount: null, + timeframeSelectionFirstDate: null, + timeframeSelectionLastDate: null, + + timeframeSelectionControl: null, + timeframeSelectionMenu: null, + timeframeSelectionPrevious: null, + timeframeSelectionNext: null, + + timeframeEmptyLine: null, + + sourceArea: null, + sourceGrid: null, + sources: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') { + return; + } + + info.timeframeMonthsBefore = + document.getElementById('timeframe-months-before'); + + info.timeframeMonthsAfter = + document.getElementById('timeframe-months-after'); + + info.timeframeMonthsPeek = + document.getElementById('timeframe-months-peek'); + + info.contributionKind = + document.getElementById('contribution-kind'); + + info.contributionGroup = + document.getElementById('contribution-group'); + + info.timeframeSelectionSomeLine = + document.getElementById('timeframe-selection-some'); + + info.timeframeSelectionNoneLine = + document.getElementById('timeframe-selection-none'); + + info.timeframeSelectionContributionCount = + document.getElementById('timeframe-selection-contribution-count'); + + info.timeframeSelectionTimeframeCount = + document.getElementById('timeframe-selection-timeframe-count'); + + info.timeframeSelectionFirstDate = + document.getElementById('timeframe-selection-first-date'); + + info.timeframeSelectionLastDate = + document.getElementById('timeframe-selection-last-date'); + + info.timeframeSelectionControl = + document.getElementById('timeframe-selection-control'); + + info.timeframeSelectionMenu = + document.getElementById('timeframe-selection-menu'); + + info.timeframeSelectionPrevious = + document.getElementById('timeframe-selection-previous'); + + info.timeframeSelectionNext = + document.getElementById('timeframe-selection-next'); + + info.timeframeEmptyLine = + document.getElementById('timeframe-empty'); + + info.sourceArea = + document.getElementById('timeframe-source-area'); + + info.sourceGrid = + info.sourceArea.querySelector('.grid-listing'); + + info.sources = + info.sourceGrid.getElementsByClassName('grid-item'); +} + +export function addPageListeners() { + if (!info.sourceArea) { + return; + } + + for (const input of [ + info.timeframeMonthsBefore, + info.timeframeMonthsAfter, + info.timeframeMonthsPeek, + info.contributionKind, + info.contributionGroup, + ]) { + input.addEventListener('change', () => { + updateArtistRollingWindow() + }); + } + + info.timeframeSelectionMenu.addEventListener('change', () => { + updateRollingWindowTimeframeSelection(); + }); + + const eatClicks = (element, callback) => { + element.addEventListener('click', domEvent => { + domEvent.preventDefault(); + callback(); + }); + + element.addEventListener('mousedown', domEvent => { + if (domEvent.detail > 1) { + domEvent.preventDefault(); + } + }); + }; + + eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection); + eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection); +} + +export function mutatePageContent() { + if (!info.sourceArea) { + return; + } + + updateArtistRollingWindow(); +} + +function previousRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex > 0) { + menu.selectedIndex--; + } + + updateRollingWindowTimeframeSelection(); +} + +function nextRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex < menu.length - 1) { + menu.selectedIndex++; + } + + updateRollingWindowTimeframeSelection(); +} + +function getArtistRollingWindowSourceInfo() { + const sourceElements = + Array.from(info.sources); + + const sourceTimeElements = + sourceElements + .map(el => Array.from(el.getElementsByTagName('time'))); + + const sourceTimeClasses = + sourceTimeElements + .map(times => times + .map(time => Array.from(time.classList))); + + const sourceKinds = + sourceTimeClasses + .map(times => times + .map(classes => classes + .find(cl => cl.endsWith('-contribution-date')) + .slice(0, -'-contribution-date'.length))); + + const sourceGroups = + sourceElements + .map(el => + Array.from(el.querySelectorAll('.contribution-group')) + .map(data => data.value)); + + const sourceDates = + sourceTimeElements + .map(times => times + .map(time => new Date(time.getAttribute('datetime')))); + + return stitchArrays({ + element: sourceElements, + kinds: sourceKinds, + groups: sourceGroups, + dates: sourceDates, + }); +} + +function getArtistRollingWindowTimeframeInfo() { + const contributionKind = + info.contributionKind.value; + + const contributionGroup = + info.contributionGroup.value; + + const sourceInfo = + getArtistRollingWindowSourceInfo(); + + const principalSources = + sourceInfo.filter(source => { + if (!source.kinds.includes(contributionKind)) { + return false; + } + + if (contributionGroup !== '-') { + if (!source.groups.includes(contributionGroup)) { + return false; + } + } + + return true; + }); + + const principalSourceDates = + principalSources.map(source => + stitchArrays({ + kind: source.kinds, + date: source.dates, + }).find(({kind}) => kind === contributionKind) + .date); + + const getPeekDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value) + - parseInt(info.timeframeMonthsPeek.value))); + + return date; + }; + + const getEntranceDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value))); + + return date; + }; + + const getExitDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + + parseInt(info.timeframeMonthsAfter.value))); + + return date; + }; + + const principalSourceIndices = + Array.from({length: principalSources.length}, (_, i) => i); + + const timeframeSourceChunks = + chunkByConditions(principalSourceIndices, [ + (previous, next) => + +principalSourceDates[previous] !== + +principalSourceDates[next], + ]); + + const timeframeSourceChunkDates = + timeframeSourceChunks + .map(indices => indices[0]) + .map(index => principalSourceDates[index]); + + const timeframeSourceChunkPeekDates = + timeframeSourceChunkDates + .map(getPeekDate); + + const timeframeSourceChunkEntranceDates = + timeframeSourceChunkDates + .map(getEntranceDate); + + const timeframeSourceChunkExitDates = + timeframeSourceChunkDates + .map(getExitDate); + + const peekDateInfo = + stitchArrays({ + peek: timeframeSourceChunkPeekDates, + indices: timeframeSourceChunks, + }).map(({peek, indices}) => ({ + date: peek, + peek: indices, + })); + + const entranceDateInfo = + stitchArrays({ + entrance: timeframeSourceChunkEntranceDates, + indices: timeframeSourceChunks, + }).map(({entrance, indices}) => ({ + date: entrance, + entrance: indices, + })); + + const exitDateInfo = + stitchArrays({ + exit: timeframeSourceChunkExitDates, + indices: timeframeSourceChunks, + }).map(({exit, indices}) => ({ + date: exit, + exit: indices, + })); + + const dateInfoChunks = + chunkByProperties( + sortByDate([ + ...peekDateInfo, + ...entranceDateInfo, + ...exitDateInfo, + ]), + ['date']); + + const dateInfo = + dateInfoChunks + .map(({chunk}) => + Object.assign({ + peek: null, + entrance: null, + exit: null, + }, ...chunk)); + + const timeframeInfo = + dateInfo.reduce( + (accumulator, {date, peek, entrance, exit}) => { + const previous = accumulator.at(-1); + + // These mustn't be mutated! + let peeking = (previous ? previous.peeking : []); + let tracking = (previous ? previous.tracking : []); + + if (peek) { + peeking = + peeking.concat(peek); + } + + if (entrance) { + peeking = + peeking.filter(index => !entrance.includes(index)); + + tracking = + tracking.concat(entrance); + } + + if (exit) { + tracking = + tracking.filter(index => !exit.includes(index)); + } + + return [...accumulator, { + date, + peeking, + tracking, + peek, + entrance, + exit, + }]; + }, + []); + + const indicesToSources = indices => + (indices + ? indices.map(index => principalSources[index]) + : null); + + const finalizedTimeframeInfo = + timeframeInfo.map(({ + date, + peeking, + tracking, + peek, + entrance, + exit, + }) => ({ + date, + peeking: indicesToSources(peeking), + tracking: indicesToSources(tracking), + peek: indicesToSources(peek), + entrance: indicesToSources(entrance), + exit: indicesToSources(exit), + })); + + return finalizedTimeframeInfo; +} + +function updateArtistRollingWindow() { + const timeframeInfo = + getArtistRollingWindowTimeframeInfo(); + + if (empty(timeframeInfo)) { + cssProp(info.timeframeSelectionControl, 'display', 'none'); + cssProp(info.timeframeSelectionSomeLine, 'display', 'none'); + cssProp(info.timeframeSelectionNoneLine, 'display', null); + + updateRollingWindowTimeframeSelection(timeframeInfo); + + return; + } + + cssProp(info.timeframeSelectionControl, 'display', null); + cssProp(info.timeframeSelectionSomeLine, 'display', null); + cssProp(info.timeframeSelectionNoneLine, 'display', 'none'); + + // The last timeframe is just the exit of the final tracked sources, + // so we aren't going to display a menu option for it, and will just use + // it as the end of the final option's date range. + + const usedTimeframes = timeframeInfo.slice(0, -1); + const firstTimeframe = timeframeInfo.at(0); + const lastTimeframe = timeframeInfo.at(-1); + + const sourceCount = + timeframeInfo + .flatMap(({entrance}) => entrance ?? []) + .length; + + const timeframeCount = + usedTimeframes.length; + + info.timeframeSelectionContributionCount.innerText = sourceCount; + info.timeframeSelectionTimeframeCount.innerText = timeframeCount; + + const firstDate = firstTimeframe.date; + const lastDate = lastTimeframe.date; + + info.timeframeSelectionFirstDate.innerText = formatDate(firstDate); + info.timeframeSelectionLastDate.innerText = formatDate(lastDate); + + while (info.timeframeSelectionMenu.firstChild) { + info.timeframeSelectionMenu.firstChild.remove(); + } + + for (const [index, timeframe] of usedTimeframes.entries()) { + const nextTimeframe = timeframeInfo[index + 1]; + + const option = document.createElement('option'); + + option.appendChild(document.createTextNode( + `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`)); + + info.timeframeSelectionMenu.appendChild(option); + } + + updateRollingWindowTimeframeSelection(timeframeInfo); +} + +function updateRollingWindowTimeframeSelection(timeframeInfo) { + timeframeInfo ??= getArtistRollingWindowTimeframeInfo(); + + updateRollingWindowTimeframeSelectionControls(timeframeInfo); + updateRollingWindowTimeframeSelectionSources(timeframeInfo); +} + +function updateRollingWindowTimeframeSelectionControls(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const atFirstTimeframe = + currentIndex === 0; + + // The last actual timeframe is empty and not displayed as a menu option. + const atLastTimeframe = + currentIndex === timeframeInfo.length - 2; + + if (atFirstTimeframe) { + info.timeframeSelectionPrevious.removeAttribute('href'); + } else { + info.timeframeSelectionPrevious.setAttribute('href', '#'); + } + + if (atLastTimeframe) { + info.timeframeSelectionNext.removeAttribute('href'); + } else { + info.timeframeSelectionNext.setAttribute('href', '#'); + } +} + +function updateRollingWindowTimeframeSelectionSources(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const contributionGroup = + info.contributionGroup.value; + + cssProp(info.sourceGrid, 'display', null); + + const {peeking: peekingSources, tracking: trackingSources} = + (empty(timeframeInfo) + ? {peeking: [], tracking: []} + : timeframeInfo[currentIndex]); + + const peekingElements = + peekingSources.map(source => source.element); + + const trackingElements = + trackingSources.map(source => source.element); + + const showingElements = + [...trackingElements, ...peekingElements]; + + const hidingElements = + Array.from(info.sources) + .filter(element => + !peekingElements.includes(element) && + !trackingElements.includes(element)); + + for (const element of peekingElements) { + element.classList.add('peeking'); + element.classList.remove('tracking'); + } + + for (const element of trackingElements) { + element.classList.remove('peeking'); + element.classList.add('tracking'); + } + + for (const element of hidingElements) { + element.classList.remove('peeking'); + element.classList.remove('tracking'); + cssProp(element, 'display', 'none'); + } + + for (const element of showingElements) { + cssProp(element, 'display', null); + + for (const time of element.getElementsByTagName('time')) { + for (const className of time.classList) { + if (!className.endsWith('-contribution-date')) continue; + + const kind = className.slice(0, -'-contribution-date'.length); + if (kind === info.contributionKind.value) { + cssProp(time, 'display', null); + } else { + cssProp(time, 'display', 'none'); + } + } + } + + for (const data of element.getElementsByClassName('contribution-group')) { + if (contributionGroup === '-' || data.value !== contributionGroup) { + cssProp(data, 'display', null); + } else { + cssProp(data, 'display', 'none'); + } + } + } + + if (empty(peekingElements) && empty(trackingElements)) { + cssProp(info.timeframeEmptyLine, 'display', null); + } else { + cssProp(info.timeframeEmptyLine, 'display', 'none'); + } +} diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js index aa637cc4..37b0645a 100644 --- a/src/static/js/client/css-compatibility-assistant.js +++ b/src/static/js/client/css-compatibility-assistant.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; export const info = { diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js index 46d1cd5b..00530484 100644 --- a/src/static/js/client/datetimestamp-tooltip.js +++ b/src/static/js/client/datetimestamp-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - // TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip? import {stitchArrays} from '../../shared-util/sugar.js'; diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js index 56021e7f..3a4ee314 100644 --- a/src/static/js/client/dragged-link.js +++ b/src/static/js/client/dragged-link.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: `draggedLinkInfo`, diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js deleted file mode 100644 index dc83e8b7..00000000 --- a/src/static/js/client/expandable-gallery-section.js +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-env browser */ - -// TODO: Combine this and quick-description.js - -import {cssProp} from '../client-util.js'; - -import {stitchArrays} from '../../shared-util/sugar.js'; - -export const info = { - id: 'expandableGallerySectionInfo', - - sections: null, - - sectionContentBelowCut: null, - - sectionExpandoToggles: null, - - sectionExpandCues: null, - sectionCollapseCues: null, -}; - -export function getPageReferences() { - info.sections = - Array.from(document.querySelectorAll('.expandable-gallery-section')) - .filter(section => section.querySelector('.section-expando-toggle')); - - info.sectionContentBelowCut = - info.sections - .map(section => section.querySelector('.section-content-below-cut')); - - info.sectionExpandoToggles = - info.sections - .map(section => section.querySelector('.section-expando-toggle')); - - info.sectionExpandCues = - info.sections - .map(section => section.querySelector('.section-expand-cue')); - - info.sectionCollapseCues = - info.sections - .map(section => section.querySelector('.section-collapse-cue')); -} - -export function addPageListeners() { - for (const { - section, - contentBelowCut, - expandoToggle, - expandCue, - collapseCue, - } of stitchArrays({ - section: info.sections, - contentBelowCut: info.sectionContentBelowCut, - expandoToggle: info.sectionExpandoToggles, - expandCue: info.sectionExpandCues, - collapseCue: info.sectionCollapseCues, - })) { - expandoToggle.addEventListener('click', domEvent => { - domEvent.preventDefault(); - - const collapsed = - cssProp(contentBelowCut, 'display') === 'none'; - - if (collapsed) { - section.classList.add('expanded'); - cssProp(contentBelowCut, 'display', null); - cssProp(expandCue, 'display', 'none'); - cssProp(collapseCue, 'display', null); - } else { - section.classList.remove('expanded'); - cssProp(contentBelowCut, 'display', 'none'); - cssProp(expandCue, 'display', null); - cssProp(collapseCue, 'display', 'none'); - } - }); - } -} diff --git a/src/static/js/client/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js new file mode 100644 index 00000000..4d6e0058 --- /dev/null +++ b/src/static/js/client/expandable-grid-section.js @@ -0,0 +1,83 @@ +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'expandableGallerySectionInfo', + + items: null, + toggles: null, + expandCues: null, + collapseCues: null, +}; + +export function getPageReferences() { + const expandos = + Array.from(document.querySelectorAll('.grid-expando')); + + const grids = + expandos + .map(expando => expando.closest('.grid-listing')); + + info.items = + grids + .map(grid => grid.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.toggles = + expandos + .map(expando => expando.querySelector('.grid-expando-toggle')); + + info.expandCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-expand-cue')); + + info.collapseCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-collapse-cue')); +} + +export function addPageListeners() { + stitchArrays({ + items: info.items, + toggle: info.toggles, + expandCue: info.expandCues, + collapseCue: info.collapseCues, + }).forEach(({ + items, + toggle, + expandCue, + collapseCue, + }) => { + toggle.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + const collapsed = + items.some(item => + item.classList.contains('hidden-by-expandable-cut')); + + for (const item of items) { + if ( + !item.classList.contains('hidden-by-expandable-cut') && + !item.classList.contains('shown-by-expandable-cut') + ) continue; + + if (collapsed) { + item.classList.remove('hidden-by-expandable-cut'); + item.classList.add('shown-by-expandable-cut'); + } else { + item.classList.add('hidden-by-expandable-cut'); + item.classList.remove('shown-by-expandable-cut'); + } + } + + if (collapsed) { + cssProp(expandCue, 'display', 'none'); + cssProp(collapseCue, 'display', null); + } else { + cssProp(expandCue, 'display', null); + cssProp(collapseCue, 'display', 'none'); + } + }); + }); +} diff --git a/src/static/js/client/gallery-style-selector.js b/src/static/js/client/gallery-style-selector.js new file mode 100644 index 00000000..44f98ac3 --- /dev/null +++ b/src/static/js/client/gallery-style-selector.js @@ -0,0 +1,121 @@ +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'galleryStyleSelectorInfo', + + selectors: null, + sections: null, + + selectorStyleInputs: null, + selectorStyleInputStyles: null, + + selectorReleaseItems: null, + selectorReleaseItemStyles: null, + + selectorCountAll: null, + selectorCountFiltered: null, + selectorCountFilteredCount: null, + selectorCountNone: null, +}; + +export function getPageReferences() { + info.selectors = + Array.from(document.querySelectorAll('.gallery-style-selector')); + + info.sections = + info.selectors + .map(selector => selector.closest('section')); + + info.selectorStyleInputs = + info.selectors + .map(selector => selector.querySelectorAll('.styles input')) + .map(inputs => Array.from(inputs)); + + info.selectorStyleInputStyles = + info.selectorStyleInputs + .map(inputs => inputs + .map(input => input.closest('label').dataset.style)); + + info.selectorReleaseItems = + info.sections + .map(section => section.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.selectorReleaseItemStyles = + info.selectorReleaseItems + .map(items => items + .map(item => item.dataset.style)); + + info.selectorCountAll = + info.selectors + .map(selector => selector.querySelector('.count.all')); + + info.selectorCountFiltered = + info.selectors + .map(selector => selector.querySelector('.count.filtered')); + + info.selectorCountFilteredCount = + info.selectorCountFiltered + .map(selector => selector.querySelector('span')); + + info.selectorCountNone = + info.selectors + .map(selector => selector.querySelector('.count.none')); +} + +export function addPageListeners() { + for (const index of info.selectors.keys()) { + for (const input of info.selectorStyleInputs[index]) { + input.addEventListener('input', () => updateVisibleReleases(index)); + } + } +} + +function updateVisibleReleases(index) { + const inputs = info.selectorStyleInputs[index]; + const inputStyles = info.selectorStyleInputStyles[index]; + + const selectedStyles = + stitchArrays({input: inputs, style: inputStyles}) + .filter(({input}) => input.checked) + .map(({style}) => style); + + const releases = info.selectorReleaseItems[index]; + const releaseStyles = info.selectorReleaseItemStyles[index]; + + let visible = 0; + + stitchArrays({ + release: releases, + style: releaseStyles, + }).forEach(({release, style}) => { + if (selectedStyles.includes(style)) { + release.classList.remove('hidden-by-style-mismatch'); + visible++; + } else { + release.classList.add('hidden-by-style-mismatch'); + } + }); + + const countAll = info.selectorCountAll[index]; + const countFiltered = info.selectorCountFiltered[index]; + const countFilteredCount = info.selectorCountFilteredCount[index]; + const countNone = info.selectorCountNone[index]; + + if (visible === releases.length) { + cssProp(countAll, 'display', null); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', 'none'); + } else if (visible === 0) { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', null); + } else { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', null); + cssProp(countNone, 'display', 'none'); + countFilteredCount.innerHTML = visible; + } +} diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js index 27035e29..02ffdc23 100644 --- a/src/static/js/client/hash-link.js +++ b/src/static/js/client/hash-link.js @@ -1,6 +1,5 @@ -/* eslint-env browser */ - -import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {filterMultipleArrays, stitchArrays, unique} + from '../../shared-util/sugar.js'; import {dispatchInternalEvent} from '../client-util.js'; @@ -11,6 +10,9 @@ export const info = { hrefs: null, targets: null, + details: null, + detailsIDs: null, + state: { highlightedTarget: null, scrollingAfterClick: false, @@ -40,6 +42,19 @@ export function getPageReferences() { info.hrefs, info.targets, (_link, _href, target) => target); + + info.details = + unique([ + ...document.querySelectorAll('details[id]'), + ... + Array.from(document.querySelectorAll('summary[id]')) + .map(summary => summary.closest('details')), + ]); + + info.detailsIDs = + info.details.map(details => + details.id || + details.querySelector('summary').id); } function processScrollingAfterHashLinkClicked() { @@ -60,6 +75,15 @@ function processScrollingAfterHashLinkClicked() { }, 200); } +export function mutatePageContent() { + if (location.hash.length > 1) { + const target = document.getElementById(location.hash.slice(1)); + if (target) { + expandDetails(target); + } + } +} + export function addPageListeners() { // Instead of defining a scroll offset (to account for the sticky heading) // in JavaScript, we interface with the CSS property 'scroll-margin-top'. @@ -94,6 +118,8 @@ export function addPageListeners() { return; } + expandDetails(target); + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -143,4 +169,32 @@ export function addPageListeners() { state.highlightedTarget = null; }); } + + stitchArrays({ + details: info.details, + id: info.detailsIDs, + }).forEach(({details, id}) => { + details.addEventListener('toggle', () => { + if (!details.open) { + detractHash(id); + } + }); + }); +} + +function expandDetails(target) { + if (target.nodeName === 'SUMMARY') { + const details = target.closest('details'); + if (details) { + details.open = true; + } + } else if (target.nodeName === 'DETAILS') { + target.open = true; + } +} + +function detractHash(id) { + if (location.hash === '#' + id) { + history.pushState({}, undefined, location.href.replace(/#.*$/, '')); + } } diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js index 9569de3e..22b9471c 100644 --- a/src/static/js/client/hoverable-tooltip.js +++ b/src/static/js/client/hoverable-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {empty, filterMultipleArrays} from '../../shared-util/sugar.js'; import {WikiRect} from '../rectangles.js'; @@ -118,17 +116,17 @@ export function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', event => { - handleTooltipReceivedFocus(tooltip, event.relatedTarget); + tooltip.addEventListener('focusin', domEvent => { + handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget); }); - tooltip.addEventListener('focusout', event => { + tooltip.addEventListener('focusout', domEvent => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return; - handleTooltipLostFocus(tooltip, event.relatedTarget); + handleTooltipLostFocus(tooltip, domEvent.relatedTarget); }); } @@ -158,20 +156,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event); + hoverable.addEventListener('focusin', domEvent => { + handleTooltipHoverableReceivedFocus(hoverable, domEvent); }); - hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event); + hoverable.addEventListener('focusout', domEvent => { + handleTooltipHoverableLostFocus(hoverable, domEvent); }); - hoverable.addEventListener('touchend', event => { - handleTooltipHoverableTouchEnded(hoverable, event); + hoverable.addEventListener('touchend', domEvent => { + handleTooltipHoverableTouchEnded(hoverable, domEvent); }); - hoverable.addEventListener('click', event => { - handleTooltipHoverableClicked(hoverable, event); + hoverable.addEventListener('click', domEvent => { + handleTooltipHoverableClicked(hoverable, domEvent); }); } @@ -416,7 +414,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { }, 1200); } -function handleTooltipHoverableClicked(hoverable) { +function handleTooltipHoverableClicked(hoverable, domEvent) { const {state} = info; // Don't navigate away from the page if the this hoverable was recently @@ -426,7 +424,7 @@ function handleTooltipHoverableClicked(hoverable) { state.currentlyActiveHoverable === hoverable && state.hoverableWasRecentlyTouched ) { - event.preventDefault(); + domEvent.preventDefault(); } } diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js index e9e2708d..0595bff7 100644 --- a/src/static/js/client/image-overlay.js +++ b/src/static/js/client/image-overlay.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {getColors} from '../../shared-util/colors.js'; import {cssProp} from '../client-util.js'; @@ -149,7 +147,8 @@ function getImageLinkDetails(imageLink) { a.href, embeddedSrc: - img?.src ?? + img?.src || + img?.currentSrc || a.dataset.embedSrc, originalFileSize: diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index aeb9264a..3357837e 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import '../group-contributions-table.js'; import * as additionalNamesBoxModule from './additional-names-box.js'; @@ -7,16 +5,20 @@ import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js'; import * as artTagNetworkModule from './art-tag-network.js'; import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js'; +import * as artistRollingWindowModule from './artist-rolling-window.js'; import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js'; import * as datetimestampTooltipModule from './datetimestamp-tooltip.js'; import * as draggedLinkModule from './dragged-link.js'; -import * as expandableGallerySectionModule from './expandable-gallery-section.js'; +import * as expandableGridSectionModule from './expandable-grid-section.js'; +import * as galleryStyleSelectorModule from './gallery-style-selector.js'; import * as hashLinkModule from './hash-link.js'; import * as hoverableTooltipModule from './hoverable-tooltip.js'; import * as imageOverlayModule from './image-overlay.js'; import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js'; import * as liveMousePositionModule from './live-mouse-position.js'; +import * as memorableDetailsModule from './memorable-details.js'; import * as quickDescriptionModule from './quick-description.js'; +import * as revealAllGridControlModule from './reveal-all-grid-control.js'; import * as scriptedLinkModule from './scripted-link.js'; import * as sidebarSearchModule from './sidebar-search.js'; import * as stickyHeadingModule from './sticky-heading.js'; @@ -30,16 +32,20 @@ export const modules = [ artTagGalleryFilterModule, artTagNetworkModule, artistExternalLinkTooltipModule, + artistRollingWindowModule, cssCompatibilityAssistantModule, datetimestampTooltipModule, draggedLinkModule, - expandableGallerySectionModule, + expandableGridSectionModule, + galleryStyleSelectorModule, hashLinkModule, hoverableTooltipModule, imageOverlayModule, intrapageDotSwitcherModule, liveMousePositionModule, + memorableDetailsModule, quickDescriptionModule, + revealAllGridControlModule, scriptedLinkModule, sidebarSearchModule, stickyHeadingModule, diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js index d06bc5a6..b9a27a9b 100644 --- a/src/static/js/client/intrapage-dot-switcher.js +++ b/src/static/js/client/intrapage-dot-switcher.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; import {cssProp} from '../client-util.js'; diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js index 36a28429..32fc5bf4 100644 --- a/src/static/js/client/live-mouse-position.js +++ b/src/static/js/client/live-mouse-position.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: 'liveMousePositionInfo', diff --git a/src/static/js/client/memorable-details.js b/src/static/js/client/memorable-details.js new file mode 100644 index 00000000..57d9fde8 --- /dev/null +++ b/src/static/js/client/memorable-details.js @@ -0,0 +1,62 @@ +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'memorableDetailsInfo', + + details: null, + ids: null, + + session: { + openDetails: { + type: 'json', + maxLength: settings => settings.maxOpenDetailsStorage, + }, + }, + + settings: { + maxOpenDetailsStorage: 1000, + }, +}; + +export function getPageReferences() { + info.details = + Array.from(document.querySelectorAll('details.memorable')); + + info.ids = + info.details.map(details => details.getAttribute('data-memorable-id')); +} + +export function mutatePageContent() { + stitchArrays({ + details: info.details, + id: info.ids, + }).forEach(({details, id}) => { + if (info.session.openDetails?.includes(id)) { + details.open = true; + } + }); +} + +export function addPageListeners() { + for (const [index, details] of info.details.entries()) { + details.addEventListener('toggle', () => { + handleDetailsToggled(index); + }); + } +} + +function handleDetailsToggled(index) { + const details = info.details[index]; + const id = info.ids[index]; + + if (details.open) { + if (info.session.openDetails) { + info.session.openDetails = [...info.session.openDetails, id]; + } else { + info.session.openDetails = [id]; + } + } else if (info.session.openDetails?.includes(id)) { + info.session.openDetails = + info.session.openDetails.filter(item => item !== id); + } +} diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js index 6a7a6023..9117d48c 100644 --- a/src/static/js/client/quick-description.js +++ b/src/static/js/client/quick-description.js @@ -1,7 +1,3 @@ -/* eslint-env browser */ - -// TODO: Combine this and expandable-gallery-section.js - import {stitchArrays} from '../../shared-util/sugar.js'; export const info = { diff --git a/src/static/js/client/reveal-all-grid-control.js b/src/static/js/client/reveal-all-grid-control.js new file mode 100644 index 00000000..0572a190 --- /dev/null +++ b/src/static/js/client/reveal-all-grid-control.js @@ -0,0 +1,70 @@ +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'revealAllGridControlInfo', + + revealAllLinks: null, + revealables: null, + + revealLabels: null, + concealLabels: null, +}; + +export function getPageReferences() { + info.revealAllLinks = + Array.from(document.querySelectorAll('.reveal-all a')); + + info.revealables = + info.revealAllLinks + .map(link => link.closest('.grid-listing')) + .map(listing => listing.querySelectorAll('.reveal')); + + info.revealLabels = + info.revealAllLinks + .map(link => link.querySelector('.reveal-label')); + + info.concealLabels = + info.revealAllLinks + .map(link => link.querySelector('.conceal-label')); +} + +export function addPageListeners() { + for (const [index, link] of info.revealAllLinks.entries()) { + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + handleRevealAllLinkClicked(index); + }); + } +} + +export function addInternalListeners() { + // Don't even think about it. "Reveal all artworks" is a stable control, + // meaning it only changes because the user interacted with it directly. +} + +function handleRevealAllLinkClicked(index) { + const revealables = info.revealables[index]; + const revealLabel = info.revealLabels[index]; + const concealLabel = info.concealLabels[index]; + + const shouldReveal = + (cssProp(revealLabel, 'display') === 'none' + ? false + : true); + + for (const revealable of revealables) { + if (shouldReveal) { + revealable.classList.add('revealed'); + } else { + revealable.classList.remove('revealed'); + } + } + + if (shouldReveal) { + cssProp(revealLabel, 'display', 'none'); + cssProp(concealLabel, 'display', null); + } else { + cssProp(revealLabel, 'display', null); + cssProp(concealLabel, 'display', 'none'); + } +} diff --git a/src/static/js/client/scripted-link.js b/src/static/js/client/scripted-link.js index 8b8d8a13..badc6ccb 100644 --- a/src/static/js/client/scripted-link.js +++ b/src/static/js/client/scripted-link.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {pick, stitchArrays} from '../../shared-util/sugar.js'; import { diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js index c8f42e91..b4356a0f 100644 --- a/src/static/js/client/sidebar-search.js +++ b/src/static/js/client/sidebar-search.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {getColors} from '../../shared-util/colors.js'; import {accumulateSum, empty, unique} from '../../shared-util/sugar.js'; @@ -73,6 +71,10 @@ export const info = { groupResultKindString: null, tagResultKindString: null, + groupResultDisambiguatorString: null, + flashResultDisambiguatorString: null, + trackResultDisambiguatorString: null, + albumResultFilterString: null, artistResultFilterString: null, flashResultFilterString: null, @@ -196,6 +198,15 @@ export function getPageReferences() { info.tagResultKindString = findString('tag-result-kind'); + info.groupResultDisambiguatorString = + findString('group-result-disambiguator'); + + info.flashResultDisambiguatorString = + findString('flash-result-disambiguator'); + + info.trackResultDisambiguatorString = + findString('track-result-disambiguator'); + info.albumResultFilterString = findString('album-result-filter'); @@ -841,7 +852,7 @@ function fillResultElements(results, { } for (const result of filteredResults) { - const el = generateSidebarSearchResult(result); + const el = generateSidebarSearchResult(result, filteredResults); if (!el) continue; info.results.appendChild(el); @@ -890,13 +901,13 @@ function showFilterElements(results) { } } -function generateSidebarSearchResult(result) { +function generateSidebarSearchResult(result, results) { const preparedSlots = { color: result.data.color ?? null, name: - result.data.name ?? result.data.primaryName ?? null, + getSearchResultName(result), imageSource: getSearchResultImageSource(result), @@ -961,9 +972,37 @@ function generateSidebarSearchResult(result) { return null; } + const compareReferenceType = otherResult => + otherResult.referenceType === result.referenceType; + + const compareName = otherResult => + getSearchResultName(otherResult) === getSearchResultName(result); + + const ambiguous = + results.some(otherResult => + otherResult !== result && + compareReferenceType(otherResult) && + compareName(otherResult)); + + if (ambiguous) { + preparedSlots.disambiguate = + result.data.disambiguator; + + preparedSlots.disambiguatorString = + info[result.referenceType + 'ResultDisambiguatorString']; + } + return generateSidebarSearchResultTemplate(preparedSlots); } +function getSearchResultName(result) { + return ( + result.data.name ?? + result.data.primaryName ?? + null + ); +} + function getSearchResultImageSource(result) { const {artwork} = result.data; if (!artwork) return null; @@ -1039,6 +1078,15 @@ function generateSidebarSearchResultTemplate(slots) { } } + if (!accentSpan && slots.disambiguate) { + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-result-disambiguator'); + accentSpan.appendChild( + templateContent(slots.disambiguatorString, { + disambiguator: slots.disambiguate, + })); + } + if (!accentSpan && slots.kindString) { accentSpan = document.createElement('span'); accentSpan.classList.add('wiki-search-result-kind'); @@ -1305,7 +1353,7 @@ async function handleDroppedIntoSearchInput(domEvent) { let droppedURL; try { droppedURL = new URL(droppedText); - } catch (error) { + } catch { droppedURL = null; } diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js index b65574d0..c69e137f 100644 --- a/src/static/js/client/sticky-heading.js +++ b/src/static/js/client/sticky-heading.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; import {cssProp, dispatchInternalEvent, templateContent} from '../client-util.js'; @@ -255,7 +253,11 @@ function getContentHeadingClosestToStickySubheading(index) { // Iterate from bottom to top of the content area. const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { + for (const heading of contentHeadings.toReversed()) { + if (heading.nodeName === 'SUMMARY' && !heading.closest('details').open) { + continue; + } + const headingRect = heading.getBoundingClientRect(); if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { return heading; diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js index 23857fa5..1c4e7e4b 100644 --- a/src/static/js/client/summary-nested-link.js +++ b/src/static/js/client/summary-nested-link.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import { empty, filterMultipleArrays, diff --git a/src/static/js/client/text-with-tooltip.js b/src/static/js/client/text-with-tooltip.js index dd207e04..2b855756 100644 --- a/src/static/js/client/text-with-tooltip.js +++ b/src/static/js/client/text-with-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; import {registerTooltipElement, registerTooltipHoverableElement} diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js index 2446c172..9a6e29c1 100644 --- a/src/static/js/client/wiki-search.js +++ b/src/static/js/client/wiki-search.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {promiseWithResolvers} from '../../shared-util/sugar.js'; import {dispatchInternalEvent} from '../client-util.js'; diff --git a/src/static/js/group-contributions-table.js b/src/static/js/group-contributions-table.js index 72ad2327..bef85fad 100644 --- a/src/static/js/group-contributions-table.js +++ b/src/static/js/group-contributions-table.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - // TODO: Update to clientSteps style. const groupContributionsTableInfo = diff --git a/src/static/js/info-card.js b/src/static/js/info-card.js index 1d9f7c86..05d5d801 100644 --- a/src/static/js/info-card.js +++ b/src/static/js/info-card.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - // Note: This is a super ancient chunk of code which isn't actually in use, // so it's just commented out here. diff --git a/src/static/js/lazy-loading.js b/src/static/js/lazy-loading.js index 1df56f08..0c8aef31 100644 --- a/src/static/js/lazy-loading.js +++ b/src/static/js/lazy-loading.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - // Lazy loading! Roll your own. Woot. // This file includes a 8unch of fall8acks and stuff like that, and is written // with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js index b00ed98e..24382ef8 100644 --- a/src/static/js/rectangles.js +++ b/src/static/js/rectangles.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {info as liveMousePositionInfo} from './client/live-mouse-position.js'; export class WikiRect extends DOMRect { diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js index c3002b18..92ba1f0d 100644 --- a/src/static/js/search-worker.js +++ b/src/static/js/search-worker.js @@ -1,8 +1,7 @@ -/* eslint-env worker */ - import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js'; -import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js'; +import {default as searchSpec, makeSearchIndex} + from '../shared-util/search-shape.js'; import { empty, @@ -130,7 +129,7 @@ async function loadDatabase() { try { idb = await promisifyIDBRequest(request); - } catch (error) { + } catch { console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`); console.warn(request.error); idb = null; @@ -391,11 +390,15 @@ function performSearchAction({query, options}) { } const interestingFieldCombinations = [ + ['primaryName'], + ['additionalNames'], + ['primaryName', 'parentName', 'groups'], ['primaryName', 'parentName'], ['primaryName', 'groups', 'contributors'], ['primaryName', 'groups', 'artTags'], ['primaryName', 'groups'], + ['additionalNames', 'groups'], ['primaryName', 'contributors'], ['primaryName', 'artTags'], ['parentName', 'groups', 'artTags'], @@ -412,7 +415,6 @@ const interestingFieldCombinations = [ ['contributors', 'parentName'], ['contributors', 'groups'], ['primaryName', 'contributors'], - ['primaryName'], ]; function queryGenericIndex(query, options) { diff --git a/src/static/js/xhr-util.js b/src/static/js/xhr-util.js index 8a43072c..bc0698da 100644 --- a/src/static/js/xhr-util.js +++ b/src/static/js/xhr-util.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - /** * This fetch function is adapted from a `loadImage` function * credited to Parziphal, Feb 13, 2017. diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 74b05b74..233a7ed3 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -97,16 +97,6 @@ count: many: "" other: "{CONTRIBUTIONS} contributions" - coverArts: - _: "{COVER_ARTS}" - withUnit: - zero: "" - one: "{COVER_ARTS} cover art" - two: "" - few: "" - many: "" - other: "{COVER_ARTS} cover arts" - flashes: _: "{FLASHES}" withUnit: @@ -269,9 +259,14 @@ releaseInfo: # Descriptions - by: - _: "By {ARTISTS}." - featuring: "By {ARTISTS}, featuring {FEATURING}." + by: >- + By {ARTISTS}. + by.featuring: >- + By {ARTISTS}, featuring {FEATURING}. + by.withAlbum: >- + From {ALBUM}, by {ARTISTS}. + by.featuring.withAlbum: >- + From {ALBUM}, by {ARTISTS} feat. {FEATURING}. from: "From {ALBUM}." @@ -294,7 +289,22 @@ releaseInfo: note: "Context notes:" - alsoReleasedOn: "Also released on {ALBUMS}." + alsoReleased: + onAlbums: >- + Also released on {ALBUMS}. + + asSingle: >- + Also released {SINGLE}. + + onAlbums.asSingle: >- + Also released on {ALBUMS}, and {SINGLE}. + + single: "as a single" + + previousProduction: + _: "This track is a previous version or production of {TRACKS}." + + trackOnAlbum: "{TRACK} (on {ALBUM})" tracksReferenced: _: "Tracks that {TRACK} references:" @@ -368,12 +378,20 @@ releaseInfo: readCommentary: _: "Read {LINK}." - link: "artist commentary" - readCreditSources: + link: + _: "artist commentary" + withWikiCommentary: "artist and wiki commentary" + onlyWikiCommentary: "wiki commentary" + + readCreditingSources: _: "Read {LINK}." link: "crediting sources" + readReferencingSources: + _: "Read {LINK}." + link: "referencing sources" + additionalFiles: heading: "View or download additional files:" @@ -449,6 +467,9 @@ trackList: rerelease: >- {TRACK} (rerelease) + previousProduction: >- + {TRACK} (previous version or production) + # # misc: # @@ -488,7 +509,16 @@ misc: # artistCommentary: artistCommentary: - _: "Artist commentary:" + _: "Artist commentary for {THING}:" + sticky: "Artist commentary:" + + withWikiCommentary: + _: "Artist and wiki commentary for {THING}:" + sticky: "Artist and wiki commentary:" + + onlyWikiCommentary: + _: "Wiki commentary for {THING}:" + sticky: "Wiki commentary:" entry: title: @@ -515,13 +545,10 @@ misc: info: fromMainRelease: >- - This commentary is properly placed on this track's main release, {ALBUM}. + The following commentary is properly placed on this track's main release, {ALBUM}. fromMainRelease.namedDifferently: >- - This commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}. - - releaseSpecific: >- - This commentary is specific to this release, {ALBUM}. + The following commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}. seeSpecificReleases: >- For release-specific commentary, check out: {ALBUMS}. @@ -562,6 +589,10 @@ misc: noExternalLinkPlatformName: "Other" chronology: + heading: + artistReleases: "Releases by {ARTIST}:" + artistTracks: "Tracks by {ARTIST}:" + previous: symbol: "←" info: @@ -579,47 +610,17 @@ misc: bannerArt: "banner art" coverArt: "cover art" flash: "flash" + release: "release" track: "track" trackArt: "track art" trackContribution: "track contribution" wallpaperArt: "wallpaper art" - # chronology: - # - # "Chronology links" are a section that appear in the nav bar for - # most things with individual contributors across the wiki! These - # allow for quick navigation between older and newer releases of - # a given artist, or seeing at a glance how many contributions an - # artist made before the one you're currently viewing. - # - # Chronology information is described for each artist and shows - # the kind of thing which is being contributed to, since all of - # the entries are displayed together in one list. - # - - chronology: - - # seeArtistPages: - # If the thing you're viewing has a lot of contributors, their - # chronology info will be exempt from the nav bar, which'll - # show this message instead. - - seeArtistPages: "(See artist pages for chronology info!)" - - # withNavigation: - # Navigation refers to previous/next links. - - withNavigation: "{HEADING} ({NAVIGATION})" - - heading: - coverArt: "{INDEX} cover art by {ARTIST}" - flash: "{INDEX} flash/game by {ARTIST}" - track: "{INDEX} track by {ARTIST}" - trackArt: "{INDEX} track art by {ARTIST}" - onlyIndex: "Only" - - creditSources: - _: "Crediting sources:" + creditingSources: + _: "{CUE} for {THING}:" + collapsed: "{CUE} for {THING}…" + cue: "Crediting sources" + sticky: "Crediting sources:" # external: # Links which will generally bring you somewhere off of the wiki. @@ -647,7 +648,12 @@ misc: amazonMusic: "Amazon Music" appleMusic: "Apple Music" artstation: "ArtStation" - bandcamp: "Bandcamp" + + bandcamp: + _: "Bandcamp" + + composerRelease: "Bandcamp (composer's release)" + officialRelease: "Bandcamp (official release)" bgreco: _: "bgreco.net" @@ -694,6 +700,11 @@ misc: nintendoMusic: "Nintendo Music" patreon: "Patreon" poetryFoundation: "Poetry Foundation" + + reddit: + _: "Reddit" + subreddit: "Reddit ({SUBREDDIT})" + soundcloud: "SoundCloud" spotify: "Spotify" steam: "Steam" @@ -781,8 +792,10 @@ misc: nav: previous: "Previous" next: "Next" + info: "Info" gallery: "Gallery" + rollingWindow: "Rolling Window" # pageTitle: # Title set under the page's <title> HTML element, which is @@ -830,6 +843,11 @@ misc: artist: "(artist)" group: "(group)" + resultDisambiguator: + group: "({DISAMBIGUATOR})" + flash: "(in {DISAMBIGUATOR})" + track: "(from {DISAMBIGUATOR})" + resultFilter: album: "Albums" artTag: "Art Tags" @@ -838,6 +856,12 @@ misc: group: "Groups" track: "Tracks" + referencingSources: + _: "{CUE} for {THING}:" + collapsed: "{CUE} for {THING}…" + cue: "Referencing sources" + sticky: "Referencing sources:" + # skippers: # # These are navigational links that only show up when you're @@ -865,7 +889,7 @@ misc: # Displayed on various info pages. artistCommentary: "Artist commentary" - creditSources: "Crediting sources" + creditingSources: "Crediting sources" # Displayed on artist info page. @@ -886,6 +910,7 @@ misc: sampledBy: "Sampled by..." features: "Features..." featuredIn: "Featured in..." + referencingSources: "Referencing sources" lyrics: "Lyrics" @@ -970,8 +995,22 @@ misc: # that thing. coverGrid: + revealAll: + reveal: "Reveal all artworks" + conceal: "Conceal all artworks" + warnings: "In this gallery: {WARNINGS}" + + expandCollapseCue: "({CUE})" + expand: "Show the rest!" + collapse: "Collapse these" + noCoverArt: "{ALBUM}" + tab: + groups: "{GROUPS}" + artists: "{ARTISTS}" + artists.featuring: "{ARTISTS} feat. {FEATURING}" + details: notFromThisGroup: "{NAME}{MARKER}" notFromThisGroup.marker: "*" @@ -979,14 +1018,13 @@ misc: accent: "({DETAILS})" albumLength: "{TRACKS}, {TIME}" + albumLength.single: "single, {TIME}" coverArtists: "Artwork by {ARTISTS}" coverArtists.customLabel: "{LABEL} by {ARTISTS}" otherCoverArtists: "With {ARTISTS}" - expandCollapseCue: "({CUE})" - albumGalleryGrid: noCoverArt: "{NAME}" @@ -1146,6 +1184,9 @@ albumGalleryPage: statsLine.withDate: >- {TRACKS} totaling {DURATION}. Released {DATE}. + statsLine.withDate.noDuration: >- + Released {DATE}. + # coverArtistsLine: # This is displayed if every track (which has artwork at all) # has the same illustration credits. @@ -1291,6 +1332,9 @@ artistPage: firstRelease: >- First released on {ALBUM} + notCreditedOnFirstRelease: >- + Note that {ARTIST} is not credited on this track's first release. + firstRelease: _: "{ENTRY} ({FIRST_RELEASE})" term: "first release" @@ -1322,6 +1366,16 @@ artistPage: flash: _: "{FLASH}" + artwork.accent: + withLabel: >- + {LABEL} + + withAnnotation: >- + {ANNOTATION} + + withLabel.withAnnotation: >- + {LABEL}: {ANNOTATION} + # contributedDurationLine: # This is shown at the top of the artist's track list, provided # any of their tracks have durations at all. @@ -1394,6 +1448,41 @@ artistGalleryPage: infoLine: >- Contributed to {COVER_ARTS}. +artistRollingWindowPage: + title: "{ARTIST} - Rolling Window" + + windowConfigurationLine: >- + With a rolling window of {TIME_BEFORE} before a given date, and {TIME_AFTER} after, peeking ahead {PEEK}... + + contributionConfigurationLine: >- + Selecting {KIND} contributions from group {GROUP}... + + timeframeSelectionLine: + _: >- + There are {CONTRIBUTIONS} contributions, making {TIMEFRAMES} timeframes between {FIRST_DATE} and {LAST_DATE}. + none: >- + There aren't any matching contributions, or those which do aren't dated, so there are no timeframes. + + emptyTimeframeLine: >- + This timeframe is empty, since no contributions are in range. + + timeframeSelectionControl: + _: "{PREVIOUS} {TIMEFRAMES} {NEXT}" + previous: "← Previous" + next: "Next →" + + contributionKind: + artwork: "Artwork" + music: "Music" + flash: "Flash" + + contributionGroup: + all: "All groups" + group: "{GROUP}" + + timeframe: + months: "{INPUT} months" + # # artTagPage: # Stuff that's common between art tag pages. @@ -1676,6 +1765,17 @@ groupGalleryPage: bySeries: "By series" byDate: "By date" + albumStyleSwitcher: + _: "Showing these releases:" + + album: "Albums" + single: "Singles" + + count: + all: "all {TOTAL}" + filtered: "{COUNT} of {TOTAL}" + none: "none at all" + albumsByDate: title: "All albums" @@ -1695,12 +1795,6 @@ groupGalleryPage: caption.seriesAlbumsNotFromGroup: >- Albums marked {MARKER} are part of {SERIES}, but not from {GROUP}. - expand: >- - Show the rest! - - collapse: >- - Collapse these - # # listingIndex: # The listing index page shows all available listings on the wiki, @@ -1792,6 +1886,7 @@ listingPage: title: "Albums - by Tracks" title.short: "...by Tracks" item: "{ALBUM} ({TRACKS})" + item.single: "{ALBUM} ({TRACKS}—single)" # listAlbums.byDuration: # Lists albums by total duration of all tracks, longest to @@ -2228,6 +2323,23 @@ listingPage: title.withDate: "{ALBUM} ({DATE})" item: "{TRACK}" + # listTracks.needingLyrics: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which are marked as needing lyrics. + # The chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are totally + # ignored. + + needingLyrics: + title: "Tracks - which need Lyrics" + title.short: "...which need Lyrics" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + other: # other.allSheetMusic: @@ -2439,15 +2551,14 @@ trackPage: backToTrack: "Return to track page" + singleAccent: "single" + track: _: "{TRACK}" withNumber: "{NUMBER}. {TRACK}" - chronology: - scope: - title: "Chronology links {SCOPE}" - wiki: "across this wiki" - album: "within this album" + needsLyrics: >- + This track has vocals, but there aren't lyrics available for it on this wiki, yet! socialEmbed: heading: "{ALBUM}" diff --git a/src/upd8.js b/src/upd8.js index 86ecab69..0bceea8d 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -42,8 +42,9 @@ import wrap from 'word-wrap'; import {mapAggregate, openAggregate, showAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; -import {stringifyCache} from '#cli'; +import {formatDuration, stringifyCache} from '#cli'; import {displayCompositeCacheAnalysis} from '#composite'; +import * as html from '#html'; import find, {bindFind, getAllFindSpecs} from '#find'; import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} from '#language'; @@ -117,7 +118,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); let COMMIT; try { COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim(); -} catch (error) { +} catch { COMMIT = '(failed to detect)'; } @@ -518,6 +519,11 @@ async function main() { type: 'flag', }, + 'skip-self-diagnosis': { + help: `Disable some runtime validation for the wiki's own code, which speeds up long builds, but may allow unpredicted corner cases to fail strangely and silently`, + type: 'flag', + }, + 'queue-size': { help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`, type: 'value', @@ -656,7 +662,8 @@ async function main() { const thumbsOnly = cliOptions['thumbs-only'] ?? false; const noInput = cliOptions['no-input'] ?? false; - const showAggregateTraces = cliOptions['show-traces'] ?? false; + const skipSelfDiagnosis = cliOptions['skip-self-diagnosis'] ?? false; + const showTraces = cliOptions['show-traces'] ?? false; const precacheMode = cliOptions['precache-mode'] ?? 'common'; @@ -1156,6 +1163,18 @@ async function main() { return false; } + if (skipSelfDiagnosis) { + logWarn`${'Skipping code self-diagnosis.'} (--skip-self-diagnosis provided)`; + logWarn`This build should run substantially faster, but corner cases`; + logWarn`not previously predicted may fail strangely and silently.`; + + html.disableSlotValidation(); + } + + if (!showTraces) { + html.disableTagTracing(); + } + Object.assign(stepStatusSummary.determineMediaCachePath, { status: STATUS_STARTED_NOT_DONE, timeStart: Date.now(), @@ -1334,7 +1353,7 @@ async function main() { const niceShowAggregate = (error, ...opts) => { showAggregate(error, { - showTraces: showAggregateTraces, + showTraces, pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), ...opts, }); @@ -1779,10 +1798,17 @@ async function main() { if (!paragraph) console.log(''); niceShowAggregate(aggregate); - logWarn`The above duplicate directories were detected while reviewing data files.`; - logWarn`Since it's impossible to automatically determine which one's directory is`; - logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`; - logWarn`some or all of these data entries to resolve the errors.`; + if (aggregate.errors.find(err => err.message.toLowerCase().includes('duplicate'))) { + logWarn`The above duplicate directories were detected while reviewing data files.`; + logWarn`Since it's impossible to automatically determine which one's directory is`; + logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`; + logWarn`some or all of these data entries to resolve the errors.`; + } else { + logWarn`The above directory errors were detected while reviewing data files.`; + logWarn`Since it's impossible to automatically fill in working directories,`; + logWarn`the build can't continue. Manually specify 'Directory' fields in`; + logWarn`some or all of these data entries to resolve the errors.`; + } console.log(''); paragraph = true; @@ -2419,7 +2445,7 @@ async function main() { }); internalDefaultLanguage = internalDefaultLanguageWatcher.language; - } catch (_error) { + } catch { // No need to display the error here - it's already printed by // watchLanguageFile. errorLoadingInternalDefaultLanguage = true; @@ -3207,6 +3233,7 @@ async function main() { developersComment, languages, missingImagePaths, + niceShowAggregate, thumbsCache, urlSpec, urls, @@ -3269,7 +3296,7 @@ async function main() { } // TODO: isMain detection isn't consistent across platforms here -/* eslint-disable-next-line no-constant-condition */ +// eslint-disable-next-line no-constant-binary-expression if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') { (async () => { let result; @@ -3363,23 +3390,6 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus })(); } -function formatDuration(timeDelta) { - const seconds = timeDelta / 1000; - - if (seconds > 90) { - const modSeconds = Math.floor(seconds % 60); - const minutes = Math.floor(seconds - seconds % 60) / 60; - return `${minutes}m${modSeconds}s`; - } - - if (seconds < 0.1) { - return 'instant'; - } - - const precision = (seconds > 1 ? 3 : 2); - return `${seconds.toPrecision(precision)}s`; -} - function showStepStatusSummary() { const longestNameLength = Math.max(... diff --git a/src/url-spec.js b/src/url-spec.js index 75cd8006..2e8b9fc1 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,6 +1,7 @@ // Exports defined here are re-exported through urls.js, // so they're generally imported from '#urls'. +import {readFileSync} from 'node:fs'; import {readFile} from 'node:fs/promises'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -195,6 +196,24 @@ export async function processURLSpecFromFile(file) { error => annotateErrorWithFile(error, file)); } + return processURLSpecFromFileContents(file, contents); +} + +export function processURLSpecFromFileSync(file) { + let contents; + + try { + contents = readFileSync(file, 'utf-8'); + } catch (caughtError) { + throw annotateError( + new Error(`Failed to read URL spec file`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); + } + + return processURLSpecFromFileContents(file, contents); +} + +function processURLSpecFromFileContents(file, contents) { let sourceSpec; let parseLanguage; diff --git a/src/urls-default.yaml b/src/urls-default.yaml index 74225efd..667f7d8b 100644 --- a/src/urls-default.yaml +++ b/src/urls-default.yaml @@ -11,7 +11,7 @@ yamlAliases: # part of a build. This is so that multiple builds of a wiki can coexist # served from the same server / file system root: older builds' HTML files # refer to earlier values of STATIC_VERSION, avoiding name collisions. - - &staticVersion 5p1 + - &staticVersion 5p2 data: prefix: 'data/' @@ -41,6 +41,7 @@ localized: artist: 'artist/<>/' artistGallery: 'artist/<>/gallery/' + artistRollingWindow: 'artist/<>/rolling-window/' commentaryIndex: 'commentary/' diff --git a/src/urls.js b/src/urls.js index 9cc4a554..b51ea459 100644 --- a/src/urls.js +++ b/src/urls.js @@ -129,6 +129,9 @@ export function generateURLs(urlSpec) { if (template === null) { // Self-diagnose, brutally. + // TODO: This variable isn't used, and has never been used, + // but it might have been *meant* to be used? + // eslint-disable-next-line no-unused-vars const otherTemplateKey = (device ? 'posix' : 'device'); const {value: {[templateKey]: otherTemplate}} = diff --git a/src/validators.js b/src/validators.js index 59df80d4..63268ded 100644 --- a/src/validators.js +++ b/src/validators.js @@ -842,9 +842,11 @@ export function validateReference(type) { type.map(type => `"${type}:"`).join(', ') + `, got "${typePart}:"`); } - } else if (typePart !== type) { - throw new TypeError( - `Expected ref to begin with "${type}:", got "${typePart}:"`); + } else if (type) { + if (typePart !== type) { + throw new TypeError( + `Expected ref to begin with "${type}:", got "${typePart}:"`); + } } isDirectory(directoryPart); diff --git a/src/web-routes.js b/src/web-routes.js index b68dccbf..a7115bbd 100644 --- a/src/web-routes.js +++ b/src/web-routes.js @@ -4,8 +4,10 @@ import {fileURLToPath} from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/* eslint-disable no-unused-vars */ const codeSrcPath = __dirname; const codeRootPath = path.resolve(codeSrcPath, '..'); +/* eslint-enable no-unused-vars */ function getNodeDependencyRootPath(dependencyName) { return ( diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js index d55ab215..afbf8b2f 100644 --- a/src/write/bind-utilities.js +++ b/src/write/bind-utilities.js @@ -24,6 +24,7 @@ export function bindUtilities({ language, languages, missingImagePaths, + niceShowAggregate, pagePath, pagePathStringFromRoot, thumbsCache, @@ -42,6 +43,7 @@ export function bindUtilities({ language, languages, missingImagePaths, + niceShowAggregate, pagePath, pagePathStringFromRoot, thumb, diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index ecb9df21..5dece8d0 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -253,7 +253,7 @@ export async function go({ let url; try { url = new URL(request.url, `http://${request.headers.host}`); - } catch (error) { + } catch { response.writeHead(500, contentTypePlain); response.end('Failed to parse request URL\n'); return; @@ -300,7 +300,7 @@ export async function go({ let filePath; try { filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep))); - } catch (error) { + } catch { response.writeHead(404, contentTypePlain); response.end(`File not found for: ${safePath}`); console.log(`${requestHead} [404] ${pathname}`); diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index d363811d..89450fc2 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -1,6 +1,8 @@ -import {cp, mkdir, stat, symlink, writeFile, unlink} from 'node:fs/promises'; import * as path from 'node:path'; +import {cp, mkdir, readFile, stat, symlink, writeFile, unlink} + from 'node:fs/promises'; + import {rimraf} from 'rimraf'; import {quickLoadContentDependencies} from '#content-dependencies'; @@ -86,6 +88,11 @@ export function getCLIOptions() { type: 'value', }, + 'paths': { + help: `Skip rest and build only pages matching paths in this text file`, + type: 'value', + }, + // NOT for neatly ena8ling or disa8ling specific features of the site! // This is only in charge of what general groups of files to write. // They're here to make development quicker when you're only working @@ -106,8 +113,6 @@ export async function go({ universalUtilities, - mediaPath, - defaultLanguage, languages, urls, @@ -119,6 +124,7 @@ export async function go({ const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT; const appendIndexHTML = cliOptions['append-index-html'] ?? false; const writeOneLanguage = cliOptions['lang'] ?? null; + const pathsFromFile = cliOptions['paths'] ?? null; if (!outputPath) { logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`; @@ -138,6 +144,36 @@ export async function go({ logInfo`Writing all languages.`; } + let filterPaths = null; + if (pathsFromFile) { + let pathsText; + try { + pathsText = await readFile(pathsFromFile, 'utf8'); + } catch (error) { + logError`Failed to read file specified in ${'--paths'}:`; + logError`${error.code}: ${pathsFromFile}`; + return false; + } + + filterPaths = pathsText.split('\n').filter(Boolean); + + if (empty(filterPaths)) { + logWarn`Specified to build only paths in file ${'--paths'}:`; + logWarn`${pathsFromFile}`; + logWarn`But this file is totally empty...`; + } + + if (filterPaths.some(path => !path.startsWith('/'))) { + logError`All lines in ${'--paths'} file should start with slash ('${'/'}')`; + logError`These lines don't:`; + console.error(filterPaths.filter(path => !path.startsWith('/')).join('\n')); + logError`Please check file contents, or specified path, and try again.`; + return false; + } + + logInfo`Writing ${filterPaths.length} paths specified in: ${pathsFromFile} (${'--paths'})`; + } + const selectedPageFlags = Object.keys(cliOptions) .filter(key => pageFlags.includes(key)); @@ -211,6 +247,27 @@ export async function go({ // TODO: Validate each pathsForTargets entry } + if (!empty(filterPaths)) { + paths = + paths.filter(path => + filterPaths.includes( + (path.type === 'page' + ? '/' + + getPagePathname({ + baseDirectory: '', + pagePath: path.path, + urls, + }) + : path.type === 'redirect' + ? '/' + + getPagePathname({ + baseDirectory: '', + pagePath: path.fromPath, + urls, + }) + : null))); + } + paths = paths.filter(path => path.condition?.() ?? true); |