diff options
Diffstat (limited to 'src')
138 files changed, 11946 insertions, 4460 deletions
diff --git a/src/content-function.js b/src/content-function.js new file mode 100644 index 00000000..9d6f6af9 --- /dev/null +++ b/src/content-function.js @@ -0,0 +1,596 @@ +import { + annotateFunction, + empty, + setIntersection, +} from './util/sugar.js'; + +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(', ')}`); + } + + if (!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 (slots && !expectedExtraDependencyKeys.has('html')) { + throw new ContentFunctionSpecError(`Content functions with slots must specify html in extraDependencies`); + } + + // 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: {}, + }); +} + +contentFunction.identifyingSymbol = Symbol(`Is a content function?`); + +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; + + if (isInvalidated) { + wrappedGenerate = function() { + throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`); + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'}); + wrappedGenerate.fulfilled = false; + } else if (isMissingContentDependencies || isMissingExtraDependencies) { + wrappedGenerate = function() { + throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`); + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); + wrappedGenerate.fulfilled = false; + } else { + const callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => { + if (hasDataFunction && !arg1) { + throw new Error(`Expected data`); + } + + if (hasDataFunction && hasRelationsFunction && !arg2) { + throw new Error(`Expected relations`); + } + + if (hasRelationsFunction && !arg1) { + throw new Error(`Expected relations`); + } + + if (hasDataFunction && hasRelationsFunction) { + return generate(arg1, arg2, ...extraArgs, fulfilledDependencies); + } else if (hasDataFunction || hasRelationsFunction) { + return generate(arg1, ...extraArgs, fulfilledDependencies); + } else { + return generate(...extraArgs, fulfilledDependencies); + } + }; + + if (hasSlotsDescription) { + const stationery = fulfilledDependencies.html.stationery({ + annotation: generate.name, + + // These extra slots are for the data and relations (positional) args. + // No hacks to store them temporarily or otherwise "invisibly" alter + // the behavior of the template description's `content`, since that + // would be expressly against the purpose of templates! + slots: { + _cfArg1: {validate: v => v.isObject}, + _cfArg2: {validate: v => v.isObject}, + ...slots, + }, + + content(slots) { + const args = [slots._cfArg1, slots._cfArg2]; + return callUnderlyingGenerate(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 = sprawl; + } + + if (hasQueryFunction) { + wrappedGenerate.query = query; + } + + if (hasRelationsFunction) { + wrappedGenerate.relations = relations; + } + + if (hasDataFunction) { + wrappedGenerate.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, + }); + } 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`); + } +} + +export function getArgsForRelationsAndData(contentFunction, wikiData, ...args) { + const insertArgs = []; + + if (contentFunction.sprawl) { + insertArgs.push(contentFunction.sprawl(wikiData, ...args)); + } + + if (contentFunction.query) { + insertArgs.unshift(contentFunction.query(...insertArgs, ...args)); + } + + // Note: Query is generally intended to "filter" the provided args/sprawl, + // so in most cases it shouldn't be necessary to access the original args + // or sprawl afterwards. These are left available for now (as the second + // and later arguments in relations/data), but if they don't find any use, + // we can refactor this step to remove them. + + return [...insertArgs, ...args]; +} + +export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) { + const relationIdentifier = Symbol('Relation'); + + function recursive(contentFunctionName, ...args) { + const contentFunction = dependencies[contentFunctionName]; + if (!contentFunction) { + throw new Error(`Couldn't find dependency ${contentFunctionName}`); + } + + // TODO: It's a bit awkward to pair this list of arguments with the output of + // getRelationsTree, but we do need to evaluate it right away (for the upcoming + // call to relations), and we're going to be reusing the same results for a + // later call to data (outside of getRelationsTree). There might be a nicer way + // of handling this. + const argsForRelationsAndData = + getArgsForRelationsAndData( + contentFunction, + wikiData, + ...args); + + const result = { + name: contentFunctionName, + args: argsForRelationsAndData, + }; + + if (contentFunction.relations) { + const listedDependencies = new Set(contentFunction.contentDependencies); + + const relationSlots = {}; + + const relationSymbolMessage = (() => { + let num = 1; + return name => `#${num++} ${name}`; + })(); + + 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)); + relationSlots[relationSymbol] = {name, args}; + return {[relationIdentifier]: relationSymbol}; + }; + + const relationsLayout = + contentFunction.relations(relationFunction, ...argsForRelationsAndData); + + const relationsTree = Object.fromEntries( + Object.getOwnPropertySymbols(relationSlots) + .map(symbol => [symbol, relationSlots[symbol]]) + .map(([symbol, {name, args}]) => [ + symbol, + recursive(name, ...args), + ])); + + result.relations = { + layout: relationsLayout, + slots: relationSlots, + tree: relationsTree, + }; + } + + return result; + } + + const root = recursive(contentFunctionName, ...args); + + return {root, relationIdentifier}; +} + +export function flattenRelationsTree({root, relationIdentifier}) { + const flatRelationSlots = {}; + + function recursive(node) { + if (node.relations) { + const {tree, slots} = node.relations; + for (const slot of Object.getOwnPropertySymbols(slots)) { + flatRelationSlots[slot] = recursive(tree[slot]); + } + } + + return { + name: node.name, + args: node.args, + relations: node.relations?.layout ?? null, + }; + } + + return { + root: recursive(root), + relationIdentifier, + flatRelationSlots, + }; +} + +export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, layout) { + function recursive(object) { + if (typeof object !== 'object' || object === null) { + return object; + } + + if (Array.isArray(object)) { + return object.map(recursive); + } + + if (relationIdentifier in object) { + return results[object[relationIdentifier]]; + } + + if (object.constructor !== Object) { + throw new Error(`Expected primitive, array, relation, or normal {key: value} style Object, got constructor ${object.constructor?.name}`); + } + + return Object.fromEntries( + Object.entries(object) + .map(([key, value]) => [key, recursive(value)])); + } + + 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 function quickEvaluate({ + contentDependencies: allContentDependencies, + extraDependencies: allExtraDependencies, + + name, + args = [], + slots = null, + multiple = null, + postprocess = null, +}) { + if (multiple !== null) { + return multiple.map(opts => + quickEvaluate({ + contentDependencies: allContentDependencies, + extraDependencies: allExtraDependencies, + + ...opts, + name: opts.name ?? name, + args: opts.args ?? args, + slots: opts.slots ?? slots, + postprocess: opts.postprocess ?? postprocess, + })); + } + + const treeInfo = getRelationsTree(allContentDependencies, name, allExtraDependencies.wikiData ?? {}, ...args); + 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(', ')})`); + } + + const slotResults = {}; + + function runContentFunction({name, args, relations: layout}) { + const contentFunction = fulfilledContentDependencies[name]; + + if (!contentFunction) { + throw new Error(`Content function ${name} unfulfilled or not listed`); + } + + const generateArgs = []; + + if (contentFunction.data) { + generateArgs.push(contentFunction.data(...args)); + } + + if (layout) { + generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout)); + } + + return contentFunction(...generateArgs); + } + + for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) { + slotResults[slot] = runContentFunction(flatRelationSlots[slot]); + } + + let topLevelResult = runContentFunction(root); + + if (slots) { + topLevelResult.setSlots(slots); + } + + if (postprocess) { + topLevelResult = postprocess(topLevelResult); + } + + return topLevelResult; +} diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js new file mode 100644 index 00000000..d280a633 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,97 @@ +import {empty} from '../../util/sugar.js'; + +function validateFileMapping(v, validateValue) { + return value => { + v.isObject(value); + + const valueErrors = []; + for (const [fileKey, fileValue] of Object.entries(value)) { + if (fileValue === null) { + continue; + } + + try { + validateValue(fileValue); + } catch (error) { + error.message = `(${fileKey}) ` + error.message; + valueErrors.push(error); + } + } + + if (!empty(valueErrors)) { + throw new AggregateError(valueErrors, `Errors validating values`); + } + }; +} + +export default { + extraDependencies: ['html', 'language'], + + data(additionalFiles) { + return { + // Additional files are already a serializable format. + additionalFiles, + }; + }, + + slots: { + fileLinks: { + validate: v => validateFileMapping(v, v.isHTML), + }, + + fileSizes: { + validate: v => validateFileMapping(v, v.isWholeNumber), + }, + }, + + generate(data, slots, {html, language}) { + if (!slots.fileLinks) { + return html.blank(); + } + + const filesWithLinks = new Set( + Object.entries(slots.fileLinks) + .filter(([key, value]) => value) + .map(([key]) => key)); + + if (empty(filesWithLinks)) { + return html.blank(); + } + + const filteredFileGroups = data.additionalFiles + .map(({title, description, files}) => ({ + title, + description, + files: files.filter(f => filesWithLinks.has(f)), + })) + .filter(({files}) => !empty(files)); + + if (empty(filteredFileGroups)) { + return html.blank(); + } + + return html.tag('dl', + filteredFileGroups.flatMap(({title, description, files}) => [ + html.tag('dt', + (description + ? language.$('releaseInfo.additionalFiles.entry.withDescription', { + title, + description, + }) + : language.$('releaseInfo.additionalFiles.entry', {title}))), + + html.tag('dd', + html.tag('ul', + files.map(file => + html.tag('li', + (slots.fileSizes?.[file] + ? language.$('releaseInfo.additionalFiles.file.withSize', { + file: slots.fileLinks[file], + size: language.formatFileSize(slots.fileSizes[file]), + }) + : language.$('releaseInfo.additionalFiles.file', { + file: slots.fileLinks[file], + })))))), + ])); + }, +}; diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js new file mode 100644 index 00000000..17280da5 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesShortcut.js @@ -0,0 +1,27 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + data(additionalFiles) { + return { + titles: additionalFiles.map(fileGroup => fileGroup.title), + }; + }, + + generate(data, {html, language}) { + if (empty(data.titles)) { + return html.blank(); + } + + return language.$('releaseInfo.additionalFiles.shortcut', { + anchorLink: + html.tag('a', + {href: '#additional-files'}, + language.$('releaseInfo.additionalFiles.shortcut.anchorLink')), + + titles: + language.formatUnitList(data.titles), + }); + }, +} diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js new file mode 100644 index 00000000..23f32bf5 --- /dev/null +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -0,0 +1,59 @@ +export default { + contentDependencies: [ + 'generateAdditionalFilesList', + 'linkAlbumAdditionalFile', + ], + + extraDependencies: [ + 'getSizeOfAdditionalFile', + 'html', + 'urls', + ], + + data(album, additionalFiles) { + return { + albumDirectory: album.directory, + fileLocations: additionalFiles.flatMap(({files}) => files), + }; + }, + + relations(relation, album, additionalFiles) { + return { + additionalFilesList: + relation('generateAdditionalFilesList', additionalFiles), + + additionalFileLinks: + Object.fromEntries( + additionalFiles + .flatMap(({files}) => files) + .map(file => [ + file, + relation('linkAlbumAdditionalFile', album, file), + ])), + }; + }, + + slots: { + showFileSizes: {type: 'boolean', default: true}, + }, + + generate(data, relations, slots, { + getSizeOfAdditionalFile, + urls, + }) { + return relations.additionalFilesList + .slots({ + fileLinks: relations.additionalFileLinks, + fileSizes: + Object.fromEntries(data.fileLocations.map(file => [ + file, + (slots.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, file)) + : 0), + ])), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js new file mode 100644 index 00000000..3cc141bc --- /dev/null +++ b/src/content/dependencies/generateAlbumBanner.js @@ -0,0 +1,37 @@ +export default { + contentDependencies: ['generateBanner'], + extraDependencies: ['html', 'language'], + + relations(relation, album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + banner: relation('generateBanner'), + }; + }, + + data(album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + path: ['media.albumBanner', album.directory, album.bannerFileExtension], + dimensions: album.bannerDimensions, + }; + }, + + generate(data, relations, {html, language}) { + if (!relations.banner) { + return html.blank(); + } + + return relations.banner.slots({ + path: data.path, + dimensions: data.dimensions, + alt: language.$('misc.alt.albumBanner'), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js new file mode 100644 index 00000000..ea31292c --- /dev/null +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -0,0 +1,166 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumNavAccent', + 'generateAlbumStyleRules', + 'generateColorStyleRules', + 'generateColorStyleVariables', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + if (album.commentary) { + relations.albumCommentaryContent = + relation('transformContent', album.commentary); + } + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + relations.trackCommentaryHeadings = + tracksWithCommentary + .map(() => relation('generateContentHeading')); + + relations.trackCommentaryLinks = + tracksWithCommentary + .map(track => relation('linkTrack', track)); + + relations.trackCommentaryContent = + tracksWithCommentary + .map(track => relation('transformContent', track.commentary)); + + relations.trackCommentaryColorVariables = + tracksWithCommentary + .map(track => + (track.color === album.color + ? null + : relation('generateColorStyleVariables', track.color))); + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + const thingsWithCommentary = + (album.commentary + ? [album, ...tracksWithCommentary] + : tracksWithCommentary); + + data.entryCount = thingsWithCommentary.length; + + data.wordCount = + thingsWithCommentary + .map(({commentary}) => commentary) + .join(' ') + .split(' ') + .length; + + data.trackCommentaryDirectories = + tracksWithCommentary + .map(track => track.directory); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('albumCommentaryPage.title', { + album: data.name, + }), + + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', + language.$('albumCommentaryPage.infoLine', { + words: + html.tag('b', + language.formatWordCount(data.wordCount, {unit: true})), + + entries: + html.tag('b', + language.countCommentaryEntries(data.entryCount, {unit: true})), + })), + + relations.albumCommentaryContent && [ + html.tag('h3', + {class: ['content-heading']}, + language.$('albumCommentaryPage.entry.title.albumCommentary')), + + html.tag('blockquote', + relations.albumCommentaryContent), + ], + + stitchArrays({ + heading: relations.trackCommentaryHeadings, + link: relations.trackCommentaryLinks, + directory: data.trackCommentaryDirectories, + content: relations.trackCommentaryContent, + colorVariables: relations.trackCommentaryColorVariables, + }).map(({heading, link, directory, content, colorVariables}) => [ + heading.slots({ + tag: 'h3', + id: directory, + title: link, + }), + html.tag('blockquote', {style: colorVariables}, content), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: false, + showExtraLinks: true, + currentExtra: 'commentary', + }), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js new file mode 100644 index 00000000..f7e86303 --- /dev/null +++ b/src/content/dependencies/generateAlbumCoverArtwork.js @@ -0,0 +1,23 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations(relation, album) { + return { + coverArtwork: + relation('generateCoverArtwork', album.artTags), + }; + }, + + data(album) { + return { + path: ['media.albumCover', album.directory, album.coverArtFileExtension], + }; + }, + + generate(data, relations) { + return relations.coverArtwork + .slots({ + path: data.path, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryInfoLine.js b/src/content/dependencies/generateAlbumGalleryInfoLine.js new file mode 100644 index 00000000..d4bd4d75 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryInfoLine.js @@ -0,0 +1,38 @@ +import {getTotalDuration} from '../../util/wiki-data.js'; + +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.infoLine']; + 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.join('.'), options))); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js new file mode 100644 index 00000000..b39b4c80 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -0,0 +1,137 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryInfoLine', + 'generateAlbumNavAccent', + 'generateAlbumStyleRules', + 'generateColorStyleRules', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.infoLine = + relation('generateAlbumGalleryInfoLine', album); + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = + album.tracks.map(track => + relation('linkTrack', track)); + + relations.images = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? relation('image', track.artTags) + : relation('image'))); + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + data.names = + album.tracks.map(track => track.name); + + data.coverArtists = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? track.coverArtistContribs.map(({who: artist}) => artist.name) + : null)); + + data.paths = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : null)); + + return data; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: + language.$('albumGalleryPage.title', { + album: data.name, + }), + + headingMode: 'static', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.infoLine, + + relations.coverGrid + .slots({ + links: relations.links, + names: data.names, + images: + stitchArrays({ + image: relations.images, + path: data.paths, + name: data.names, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', {name}), + })), + info: + data.coverArtists.map(names => + (names === null + ? null + : language.$('misc.albumGrid.details.coverArtists', { + artists: language.formatUnitList(names), + }))), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: false, + showExtraLinks: true, + currentExtra: 'gallery', + }), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 00000000..8fbb81f9 --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,286 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumBanner', + 'generateAlbumCoverArtwork', + 'generateAlbumNavAccent', + 'generateAlbumReleaseInfo', + 'generateAlbumSecondaryNav', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateAlbumTrackList', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkAlbumCommentary', + 'linkAlbumGallery', + 'linkArtist', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.socialEmbed = + relation('generateAlbumSocialEmbed', album); + + relations.coverArtistChronologyContributions = + getChronologyRelations(album, { + contributions: album.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ]), + }); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.secondaryNav = + relation('generateAlbumSecondaryNav', album); + + relations.sidebar = + relation('generateAlbumSidebar', album, null); + + if (album.hasCoverArt) { + relations.cover = + relation('generateAlbumCoverArtwork', album); + } + + if (album.hasBannerArt) { + relations.banner = + relation('generateAlbumBanner', album); + } + + // Section: Release info + + relations.releaseInfo = + relation('generateAlbumReleaseInfo', album); + + // Section: Extra links + + const extra = sections.extra = {}; + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + extra.galleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + extra.commentaryLink = + relation('linkAlbumCommentary', album); + } + + if (!empty(album.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', album.additionalFiles); + } + + // Section: Track list + + relations.trackList = + relation('generateAlbumTrackList', album); + + // Section: Additional files + + if (!empty(album.additionalFiles)) { + const additionalFiles = sections.additionalFiles = {}; + + additionalFiles.heading = + relation('generateContentHeading'); + + additionalFiles.additionalFilesList = + relation('generateAlbumAdditionalFilesList', album, album.additionalFiles); + } + + // Section: Artist commentary + + if (album.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', album.commentary); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + if (!empty(album.additionalFiles)) { + data.numAdditionalFiles = album.additionalFiles.length; + } + + data.dateAddedToWiki = album.dateAddedToWiki; + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('albumPage.title', {album: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: + relations.cover + ?.slots({ + alt: language.$('misc.alt.albumCover'), + }) + ?? null, + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + sec.extra.additionalFilesShortcut, + + sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewGalleryOrCommentary', { + gallery: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')), + commentary: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')), + }), + + sec.extra.galleryLink && !sec.extra.commentaryLink && + language.$('releaseInfo.viewGallery', { + link: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGallery.link')), + }), + + !sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewCommentary', { + link: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewCommentary.link')), + }), + ]), + + relations.trackList, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + data.dateAddedToWiki && + language.$('releaseInfo.addedToWiki', { + date: language.formatDate(data.dateAddedToWiki), + }), + ]), + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.additionalFilesList, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + auto: 'current', + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: true, + }), + }, + ], + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + banner: relations.banner ?? null, + bannerPosition: 'top', + + secondaryNav: relations.secondaryNav, + + ...relations.sidebar, + + // socialEmbed: relations.socialEmbed, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js new file mode 100644 index 00000000..0237fdec --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,114 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkTrack', + 'linkAlbumCommentary', + 'linkAlbumGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, track) { + const relations = {}; + + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.previousTrackLink = null; + relations.nextTrackLink = null; + + if (track) { + const index = album.tracks.indexOf(track); + + if (index > 0) { + relations.previousTrackLink = + relation('linkTrack', album.tracks[index - 1]); + } + + if (index < album.tracks.length - 1) { + relations.nextTrackLink = + relation('linkTrack', album.tracks[index + 1]); + } + } + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + relations.albumGalleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + relations.albumCommentaryLink = + relation('linkAlbumCommentary', album); + } + + return relations; + }, + + data(album, track) { + return { + hasMultipleTracks: album.tracks.length > 1, + isTrackPage: !!track, + }; + }, + + slots: { + showTrackNavigation: {type: 'boolean', default: false}, + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery', 'commentary'), + }, + }, + + generate(data, relations, slots, {html, language}) { + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.albumGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('albumPage.nav.gallery'), + }), + + relations.albumCommentaryLink?.slots({ + attributes: {class: slots.currentExtra === 'commentary' && 'current'}, + content: language.$('albumPage.nav.commentary'), + }), + ]}; + + const {content: previousNextLinks = []} = + slots.showTrackNavigation && + data.isTrackPage && + data.hasMultipleTracks && + relations.previousNextLinks.slots({ + previousLink: relations.previousTrackLink, + nextLink: relations.nextTrackLink, + }); + + const randomLink = + slots.showTrackNavigation && + data.hasMultipleTracks && + html.tag('a', + { + href: '#', + 'data-random': 'track-in-album', + id: 'random-button', + }, + (data.isTrackPage + ? language.$('trackPage.nav.random') + : language.$('albumPage.nav.randomTrack'))); + + const allLinks = [ + ...previousNextLinks, + ...extraLinks, + randomLink, + ].filter(Boolean); + + if (empty(allLinks)) { + return html.blank(); + } + + return `(${language.formatUnitList(allLinks)})`; + }, +}; diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js new file mode 100644 index 00000000..86e6dfe9 --- /dev/null +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -0,0 +1,101 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.artistContribs); + + relations.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.coverArtistContribs); + + relations.wallpaperArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); + + relations.bannerArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); + + if (!empty(album.urls)) { + relations.externalLinks = + album.urls.map(url => + relation('linkExternal', url)); + } + + return relations; + }, + + data(album) { + const data = {}; + + if (album.date) { + data.date = album.date; + } + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + + data.duration = accumulateSum(album.tracks, track => track.duration); + data.durationApproximate = album.tracks.length > 1; + + return data; + }, + + generate(data, relations, {html, language}) { + return html.tags([ + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + relations.artistContributionsLine + .slots({stringKey: 'releaseInfo.by'}), + + relations.coverArtistContributionsLine + .slots({stringKey: 'releaseInfo.coverArtBy'}), + + relations.wallpaperArtistContributionsLine + .slots({stringKey: 'releaseInfo.wallpaperArtBy'}), + + relations.bannerArtistContributionsLine + .slots({stringKey: 'releaseInfo.bannerArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: + language.formatDuration(data.duration, { + approximate: data.durationApproximate, + }), + }), + ]), + + relations.externalLinks && + html.tag('p', + language.$('releaseInfo.listenOn', { + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('mode', 'album'))), + })), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js new file mode 100644 index 00000000..6616f20e --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -0,0 +1,98 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleVariables', + 'generateSecondaryNav', + 'linkAlbum', + 'linkGroup', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.secondaryNav = + relation('generateSecondaryNav'); + + relations.groupParts = + album.groups.map(group => { + const relations = {}; + + relations.groupLink = + relation('linkGroup', group); + + relations.colorVariables = + relation('generateColorStyleVariables', group.color); + + if (album.date) { + const albums = group.albums.filter(album => album.date); + const index = albums.indexOf(album); + const previousAlbum = (index > 0) && albums[index - 1]; + const nextAlbum = (index < albums.length - 1) && albums[index + 1]; + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + } + + return relations; + }); + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + }, + + generate(relations, slots, {html, language}) { + return relations.secondaryNav.slots({ + class: 'nav-links-groups', + content: + relations.groupParts.map(({ + colorVariables, + groupLink, + previousAlbumLink, + nextAlbumLink, + }) => { + const links = [ + previousAlbumLink + ?.slots({ + color: false, + content: language.$('misc.nav.previous'), + }), + + nextAlbumLink + ?.slots({ + color: false, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean); + + return ( + (slots.mode === 'album' && !empty(links) + ? html.tag('span', {style: colorVariables}, [ + language.$('albumSidebar.groupBox.title', { + group: groupLink, + }), + `(${language.formatUnitList(links)})`, + ]) + : language.$('albumSidebar.groupBox.title', { + group: groupLink, + }))); + }), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 00000000..a84f4357 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,75 @@ +export default { + contentDependencies: [ + 'generateAlbumSidebarGroupBox', + 'generateAlbumSidebarTrackSection', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + relations(relation, album, track) { + const relations = {}; + + relations.albumLink = + relation('linkAlbum', album); + + relations.groupBoxes = + album.groups.map(group => + relation('generateAlbumSidebarGroupBox', album, group)); + + relations.trackSections = + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, track, trackSection)); + + return relations; + }, + + data(album, track) { + return {isAlbumPage: !track}; + }, + + generate(data, relations, {html}) { + const trackListBox = { + content: + html.tags([ + html.tag('h1', relations.albumLink), + relations.trackSections, + ]), + }; + + if (data.isAlbumPage) { + const groupBoxes = + relations.groupBoxes + .map(content => content.slot('mode', 'album')) + .map(content => ({content})); + + return { + leftSidebarMultiple: [ + ...groupBoxes, + trackListBox, + ], + }; + } + + const conjoinedGroupBox = { + content: + relations.groupBoxes + .flatMap((content, i, {length}) => [ + content.slot('mode', 'track'), + i < length - 1 && + html.tag('hr', { + style: `border-color: var(--primary-color); border-style: none none dotted none` + }), + ]) + .filter(Boolean), + }; + + return { + // leftSidebarStickyMode: 'column', + leftSidebarMultiple: [ + trackListBox, + conjoinedGroupBox, + ], + }; + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js new file mode 100644 index 00000000..874dcc20 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,87 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkAlbum', + 'linkExternal', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, group) { + const relations = {}; + + relations.groupLink = + relation('linkGroup', group); + + relations.externalLinks = + group.urls.map(url => + relation('linkExternal', url)); + + if (group.descriptionShort) { + relations.description = + relation('transformContent', group.descriptionShort); + } + + if (album.date) { + const albums = group.albums.filter(album => album.date); + const index = albums.indexOf(album); + const previousAlbum = (index > 0) && albums[index - 1]; + const nextAlbum = (index < albums.length - 1) && albums[index + 1]; + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + } + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'track', + }, + }, + + generate(relations, slots, {html, language}) { + return html.tags([ + html.tag('h1', + language.$('albumSidebar.groupBox.title', { + group: relations.groupLink, + })), + + slots.mode === 'album' && + relations.description + ?.slot('mode', 'multiline'), + + !empty(relations.externalLinks) && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(relations.externalLinks), + })), + + slots.mode === 'album' && + relations.nextAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.next', { + album: relations.nextAlbumLink, + })), + + slots.mode === 'album' && + relations.previousAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.previous', { + album: relations.previousAlbumLink, + })), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js new file mode 100644 index 00000000..2aca6da1 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -0,0 +1,98 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['getColors', 'html', 'language'], + + relations(relation, album, track, trackSection) { + const relations = {}; + + relations.trackLinks = + trackSection.tracks.map(track => + relation('linkTrack', track)); + + return relations; + }, + + data(album, track, trackSection) { + const data = {}; + + data.hasTrackNumbers = album.hasTrackNumbers; + data.isTrackPage = !!track; + + data.name = trackSection.name; + data.color = trackSection.color; + data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + + data.firstTrackNumber = trackSection.startIndex + 1; + data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length; + + if (track) { + const index = trackSection.tracks.indexOf(track); + if (index !== -1) { + data.includesCurrentTrack = true; + data.currentTrackIndex = index; + } + } + + return data; + }, + + generate(data, relations, {getColors, html, language}) { + const sectionName = + html.tag('span', {class: 'group-name'}, + (data.isDefaultTrackSection + ? language.$('albumSidebar.trackList.fallbackSectionName') + : data.name)); + + let style; + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + const trackListItems = + relations.trackLinks.map((trackLink, index) => + html.tag('li', + { + class: + data.includesCurrentTrack && + index === data.currentTrackIndex && + 'current', + }, + language.$('albumSidebar.trackList.item', { + track: trackLink, + }))); + + return html.tag('details', + { + class: data.includesCurrentTrack && 'current', + + open: ( + // Leave sidebar track sections collapsed on album info page, + // since there's already a view of the full track listing + // in the main content area. + data.isTrackPage && + + // Only expand the track section which includes the track + // currently being viewed by default. + data.includesCurrentTrack), + }, + [ + html.tag('summary', {style}, + html.tag('span', + (data.hasTrackNumbers + ? language.$('albumSidebar.trackList.group.withRange', { + group: sectionName, + range: `${data.firstTrackNumber}–${data.lastTrackNumber}` + }) + : language.$('albumSidebar.trackList.group', { + group: sectionName, + })))), + + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js new file mode 100644 index 00000000..079899d3 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language', 'urls'], + + relations(relation, album) { + const relations = {}; + + relations.description = + relation('generateAlbumSocialEmbedDescription', album); + + return relations; + }, + + data(album) { + const data = {}; + + data.hasHeading = !empty(album.groups); + + if (data.hasHeading) { + const firstGroup = album.groups[0]; + data.headingGroupName = firstGroup.directory; + data.headingGroupDirectory = firstGroup.directory; + } + + data.hasImage = album.hasCoverArt; + + if (data.hasImage) { + data.coverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + } + + data.albumName = album.name; + data.albumColor = album.color; + + return data; + }, + + generate(data, relations, {absoluteTo, language, urls}) { + const socialEmbed = {}; + + if (data.hasHeading) { + socialEmbed.heading = + language.$('albumPage.socialEmbed.heading', { + group: data.headingGroupName, + }); + + socialEmbed.headingLink = + absoluteTo('localized.album', data.headingGroupDirectory); + } else { + socialEmbed.heading = ''; + socialEmbed.headingLink = null; + } + + socialEmbed.title = + language.$('albumPage.socialEmbed.title', { + album: data.albumName, + }); + + socialEmbed.description = relations.description; + + if (data.hasImage) { + const imagePath = urls + .from('shared.root') + .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension); + socialEmbed.image = '/' + imagePath; + } + + socialEmbed.color = data.albumColor; + + return socialEmbed; + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js new file mode 100644 index 00000000..40f696f8 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,48 @@ +import {accumulateSum} from '../../util/sugar.js'; + +export default { + extraDependencies: ['language'], + + data(album) { + const data = {}; + + const duration = accumulateSum(album.tracks, track => track.duration); + + data.hasDuration = duration > 0; + data.hasTracks = album.tracks.length > 0; + data.hasDate = !!album.date; + data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration); + + if (!data.hasAny) + return data; + + if (data.hasDuration) + data.duration = duration; + + if (data.hasTracks) + data.tracks = album.tracks.length; + + if (data.hasDate) + data.date = album.date; + + return data; + }, + + generate(data, {language}) { + return language.formatString( + 'albumPage.socialEmbed.body' + [ + data.hasDuration && '.withDuration', + data.hasTracks && '.withTracks', + data.hasDate && '.withReleaseDate', + ].filter(Boolean).join(''), + + Object.fromEntries([ + data.hasDuration && + ['duration', language.formatDuration(data.duration)], + data.hasTracks && + ['tracks', language.countTracks(data.tracks, {unit: true})], + data.hasDate && + ['date', language.formatDate(data.date)], + ].filter(Boolean))); + }, +}; diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js new file mode 100644 index 00000000..6a894d71 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,59 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['to'], + + data(album) { + const data = {}; + + data.hasWallpaper = !empty(album.wallpaperArtistContribs); + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasWallpaper) { + data.hasWallpaperStyle = !!album.wallpaperStyle; + data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; + data.wallpaperStyle = album.wallpaperStyle; + } + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + return data; + }, + + generate(data, {to}) { + const wallpaperPart = + (data.hasWallpaper + ? [ + `body::before {`, + ` background-image: url("${to(...data.wallpaperPath)}");`, + ...(data.hasWallpaperStyle + ? data.wallpaperStyle + .split('\n') + .map(line => ` ${line}`) + : []), + `}`, + ] + : []); + + const bannerPart = + (data.hasBannerStyle + ? [ + `#banner img {`, + ...data.bannerStyle + .split('\n') + .map(line => ` ${line}`), + `}`, + ] + : []); + + return [ + ...wallpaperPart, + ...bannerPart, + ] + .filter(Boolean) + .join('\n'); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 00000000..b222799b --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,137 @@ +import {accumulateSum, empty, stitchArrays} from '../../util/sugar.js'; + +function displayTrackSections(album) { + if (empty(album.trackSections)) { + return false; + } + + if (album.trackSections.length > 1) { + return true; + } + + if (!album.trackSections[0].isDefaultTrackSection) { + return true; + } + + return false; +} + +function displayTracks(album) { + if (empty(album.tracks)) { + return false; + } + + return true; +} + +function getDisplayMode(album) { + if (displayTrackSections(album)) { + return 'trackSections'; + } else if (displayTracks(album)) { + return 'tracks'; + } else { + return 'none'; + } +} + +export default { + contentDependencies: ['generateAlbumTrackListItem', 'generateContentHeading'], + extraDependencies: ['html', 'language'], + + query(album) { + return { + displayMode: getDisplayMode(album), + }; + }, + + relations(relation, query, album) { + const relations = {}; + + switch (query.displayMode) { + case 'trackSections': + relations.trackSectionHeadings = + album.trackSections.map(() => + relation('generateContentHeading')); + + relations.itemsByTrackSection = + album.trackSections.map(section => + section.tracks.map(track => + relation('generateAlbumTrackListItem', track, album))); + + break; + + case 'tracks': + relations.itemsByTrack = + album.tracks.map(track => + relation('generateAlbumTrackListItem', track, album)); + break; + } + + return relations; + }, + + data(query, album) { + const data = {}; + + data.displayMode = query.displayMode; + data.hasTrackNumbers = album.hasTrackNumbers; + + switch (query.displayMode) { + case 'trackSections': + data.trackSectionInfo = + album.trackSections.map(section => { + const info = {}; + + info.name = section.name; + info.duration = accumulateSum(section.tracks, track => track.duration); + info.durationApproximate = section.tracks.length > 1; + + if (album.hasTrackNumbers) { + info.startIndex = section.startIndex; + } + + return info; + }); + break; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const listTag = (data.hasTrackNumbers ? 'ol' : 'ul'); + + switch (data.displayMode) { + case 'trackSections': + return html.tag('dl', {class: 'album-group-list'}, + stitchArrays({ + heading: relations.trackSectionHeadings, + items: relations.itemsByTrackSection, + info: data.trackSectionInfo, + }).map(({heading, items, info}) => [ + heading.slots({ + tag: 'dt', + title: + language.$('trackList.section.withDuration', { + section: info.name, + duration: + language.formatDuration(info.duration, { + approximate: info.durationApproximate, + }), + }), + }), + + html.tag('dd', + html.tag(listTag, + data.hasTrackNumbers ? {start: info.startIndex + 1} : {}, + items)), + ])); + + case 'tracks': + return html.tag(listTag, relations.itemsByTrack); + + default: + return html.blank(); + } + } +}; diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js new file mode 100644 index 00000000..15aecba0 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,72 @@ +import {compareArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkContribution', + 'linkTrack', + ], + + extraDependencies: ['getColors', 'html', 'language'], + + relations(relation, track) { + const relations = {}; + + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + + relations.trackLink = + relation('linkTrack', track); + + return relations; + }, + + data(track, album) { + const data = {}; + + data.duration = track.duration ?? 0; + + if (track.color !== album.color) { + data.color = track.color; + } + + data.showArtists = + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false}); + + return data; + }, + + generate(data, relations, {getColors, html, language}) { + let style; + + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + const parts = ['trackList.item.withDuration']; + const options = {}; + + options.duration = + language.formatDuration(data.duration); + + options.track = + relations.trackLink + .slot('color', false); + + if (data.showArtists) { + parts.push('withArtists'); + options.by = + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: language.formatConjunctionList(relations.contributionLinks), + })); + } + + return html.tag('li', {style}, + language.formatString(parts.join('.'), options)); + }, +}; diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js new file mode 100644 index 00000000..d1ec3efe --- /dev/null +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -0,0 +1,114 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +// TODO: Very awkward we have to duplicate this functionality in relations and data. +function getGalleryThings(artist) { + const galleryThings = [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist]; + sortAlbumsTracksChronologically(galleryThings, {latestFirst: true}); + return galleryThings; +} + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, artist) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + relations.coverGrid = + relation('generateCoverGrid'); + + const galleryThings = getGalleryThings(artist); + + relations.links = + galleryThings.map(thing => + (thing.album + ? relation('linkTrack', thing) + : relation('linkAlbum', thing))); + + relations.images = + galleryThings.map(thing => + relation('image', thing.artTags)); + + return relations; + }, + + data(artist) { + const data = {}; + + data.name = artist.name; + + const galleryThings = getGalleryThings(artist); + + data.numArtworks = galleryThings.length; + + data.names = + galleryThings.map(thing => thing.name); + + data.paths = + galleryThings.map(thing => + (thing.album + ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] + : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('artistGalleryPage.title', { + artist: data.name, + }), + + headingMode: 'static', + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', + {class: 'quick-info'}, + language.$('artistGalleryPage.infoLine', { + coverArts: language.countCoverArts(data.numArtworks, { + unit: true, + }), + })), + + relations.coverGrid + .slots({ + links: relations.links, + names: data.names, + images: + stitchArrays({ + image: relations.images, + path: data.paths, + }).map(({image, path}) => + image.slot('path', path)), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + currentExtra: 'gallery', + }) + .content, + }) + }, +} diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js new file mode 100644 index 00000000..1e7086ed --- /dev/null +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -0,0 +1,213 @@ +import { + empty, + filterProperties, + stitchArrays, + unique, +} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkGroup'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData}) { + return { + groupOrder: groupCategoryData.flatMap(category => category.groups), + } + }, + + query(sprawl, tracksAndAlbums) { + const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album); + const filteredTracks = tracksAndAlbums.filter(thing => thing.album); + + const allAlbums = unique([ + ...filteredAlbums, + ...filteredTracks.map(track => track.album), + ]); + + const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups)); + const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); + + 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 track of filteredTracks) { + for (const group of track.album.groups) { + groupToCountMap.set(group, groupToCountMap.get(group) + 1); + if (track.duration) { + groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration); + groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1); + } + } + } + + const groupsSortedByCount = + allGroupsOrdered + .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.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)); + + const groupCountsSortedByCount = + groupsSortedByCount + .map(group => groupToCountMap.get(group)); + + const groupDurationsSortedByCount = + groupsSortedByCount + .map(group => groupToDurationMap.get(group)); + + const groupDurationsApproximateSortedByCount = + groupsSortedByCount + .map(group => groupToDurationCountMap.get(group) > 1); + + const groupCountsSortedByDuration = + groupsSortedByDuration + .map(group => groupToCountMap.get(group)); + + const groupDurationsSortedByDuration = + groupsSortedByDuration + .map(group => groupToDurationMap.get(group)); + + const groupDurationsApproximateSortedByDuration = + groupsSortedByDuration + .map(group => groupToDurationCountMap.get(group) > 1); + + return { + groupsSortedByCount, + groupsSortedByDuration, + + groupCountsSortedByCount, + groupDurationsSortedByCount, + groupDurationsApproximateSortedByCount, + + groupCountsSortedByDuration, + groupDurationsSortedByDuration, + groupDurationsApproximateSortedByDuration, + }; + }, + + relations(relation, query) { + return { + groupLinksSortedByCount: + query.groupsSortedByCount + .map(group => relation('linkGroup', group)), + + groupLinksSortedByDuration: + query.groupsSortedByDuration + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return filterProperties(query, [ + 'groupCountsSortedByCount', + 'groupDurationsSortedByCount', + 'groupDurationsApproximateSortedByCount', + + 'groupCountsSortedByDuration', + 'groupDurationsSortedByDuration', + 'groupDurationsApproximateSortedByDuration', + ]); + }, + + slots: { + title: {type: 'html'}, + showBothColumns: {type: 'boolean'}, + showSortButton: {type: 'boolean'}, + visible: {type: 'boolean', default: true}, + + sort: {validate: v => v.is('count', 'duration')}, + countUnit: {validate: v => v.is('tracks', 'artworks')}, + }, + + generate(data, relations, slots, {html, language}) { + if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) { + return html.blank(); + } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) { + return html.blank(); + } + + const getCounts = counts => + counts.map(count => { + switch (slots.countUnit) { + case 'tracks': return language.countTracks(count, {unit: true}); + case 'artworks': return language.countArtworks(count, {unit: true}); + } + }); + + // We aren't displaying the "~" approximate symbol here for now. + // The general notion that these sums aren't going to be 100% accurate + // is made clear by the "XYZ has contributed ~1:23:45 hours of music..." + // line that's always displayed above this table. + const getDurations = (durations, approximate) => + stitchArrays({ + duration: durations, + approximate: approximate, + }).map(({duration}) => language.formatDuration(duration)); + + const topLevelClasses = [ + 'group-contributions-sorted-by-' + slots.sort, + slots.visible && 'visible', + ]; + + return html.tags([ + html.tag('dt', {class: topLevelClasses}, + (slots.showSortButton + ? language.$('artistPage.groupContributions.title.withSortButton', { + title: slots.title, + sort: + html.tag('a', {href: '#', class: 'group-contributions-sort-button'}, + (slots.sort === 'count' + ? language.$('artistPage.groupContributions.title.sorting.count') + : language.$('artistPage.groupContributions.title.sorting.duration'))), + }) + : slots.title)), + + html.tag('dd', {class: topLevelClasses}, + html.tag('ul', {class: 'group-contributions-table', role: 'list'}, + (slots.sort === 'count' + ? stitchArrays({ + group: relations.groupLinksSortedByCount, + count: getCounts(data.groupCountsSortedByCount), + duration: getDurations(data.groupDurationsSortedByCount, data.groupDurationsApproximateSortedByCount), + }).map(({group, count, duration}) => + html.tag('li', + html.tag('div', {class: 'group-contributions-row'}, [ + group, + html.tag('span', {class: 'group-contributions-metrics'}, + // When sorting by count, duration details aren't necessarily + // available for all items. + (slots.showBothColumns && duration + ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration}) + : language.$('artistPage.groupContributions.item.countAccent', {count}))), + ]))) + : stitchArrays({ + group: relations.groupLinksSortedByDuration, + count: getCounts(data.groupCountsSortedByDuration), + duration: getDurations(data.groupDurationsSortedByDuration, data.groupDurationsApproximateSortedByCount), + }).map(({group, count, duration}) => + html.tag('li', + html.tag('div', {class: 'group-contributions-row'}, [ + group, + html.tag('span', {class: 'group-contributions-metrics'}, + // Count details are always available, since they're just the + // number of contributions directly. And duration details are + // guaranteed for every item when sorting by duration. + (slots.showBothColumns + ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count}) + : language.$('artistPage.groupContributions.item.durationAccent', {duration}))), + ])))))), + ]); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js new file mode 100644 index 00000000..7f79a609 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,308 @@ +import {empty, unique} from '../../util/sugar.js'; +import {getTotalDuration} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistGroupContributionsInfo', + 'generateArtistInfoPageArtworksChunkedList', + 'generateArtistInfoPageCommentaryChunkedList', + 'generateArtistInfoPageFlashesChunkedList', + 'generateArtistInfoPageTracksChunkedList', + 'generateArtistNavLinks', + 'generateContentHeading', + 'generateCoverArtwork', + 'generatePageLayout', + 'linkAlbum', + 'linkArtistGallery', + 'linkExternal', + 'linkGroup', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, artist) { + return { + // 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. + allTracks: + unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]), + + // 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. + allArtworks: [ + ...artist.albumsAsCoverArtist, + ...artist.albumsAsWallpaperArtist, + ...artist.albumsAsBannerArtist, + ...artist.tracksAsCoverArtist, + ], + + // Banners and wallpapers don't show up in the artist gallery page, only + // cover art. + hasGallery: + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist), + }; + }, + + relations(relation, query, sprawl, artist) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + if (artist.hasAvatar) { + relations.cover = + relation('generateCoverArtwork', []); + } + + if (artist.contextNotes) { + const contextNotes = sections.contextNotes = {}; + contextNotes.content = relation('transformContent', artist.contextNotes); + } + + if (!empty(artist.urls)) { + const visit = sections.visit = {}; + visit.externalLinks = + artist.urls.map(url => + relation('linkExternal', url)); + } + + if (!empty(query.allTracks)) { + const tracks = sections.tracks = {}; + tracks.heading = relation('generateContentHeading'); + tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist); + tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks); + } + + if (!empty(query.allArtworks)) { + const artworks = sections.artworks = {}; + artworks.heading = relation('generateContentHeading'); + artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist); + artworks.groupInfo = + relation('generateArtistGroupContributionsInfo', query.allArtworks); + + if (query.hasGallery) { + artworks.artistGalleryLink = + relation('linkArtistGallery', artist); + } + } + + if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) { + const flashes = sections.flashes = {}; + flashes.heading = relation('generateContentHeading'); + flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist); + } + + if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) { + const commentary = sections.commentary = {}; + commentary.heading = relation('generateContentHeading'); + commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist); + } + + return relations; + }, + + data(query, sprawl, artist) { + const data = {}; + + data.name = artist.name; + data.directory = artist.directory; + + if (artist.hasAvatar) { + data.avatarFileExtension = artist.avatarFileExtension; + } + + data.totalTrackCount = query.allTracks.length; + data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true}); + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + cover: + (relations.cover + ? relations.cover.slots({ + path: [ + 'media.artistAvatar', + data.directory, + data.avatarFileExtension, + ], + }) + : null), + + mainContent: [ + sec.contextNotes && [ + html.tag('p', language.$('releaseInfo.note')), + html.tag('blockquote', + sec.contextNotes.content), + ], + + sec.visit && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(sec.visit.externalLinks), + })), + + sec.artworks?.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) && + html.tag('p', + language.$('misc.jumpTo.withLinks', { + links: language.formatUnitList( + [ + sec.tracks && + html.tag('a', + {href: '#tracks'}, + language.$('artistPage.trackList.title')), + + sec.artworks && + html.tag('a', + {href: '#art'}, + language.$('artistPage.artList.title')), + + sec.flashes && + html.tag('a', + {href: '#flashes'}, + language.$('artistPage.flashList.title')), + + sec.commentary && + html.tag('a', + {href: '#commentary'}, + language.$('artistPage.commentaryList.title')), + ].filter(Boolean)), + })), + + sec.tracks && [ + sec.tracks.heading + .slots({ + tag: 'h2', + id: 'tracks', + title: language.$('artistPage.trackList.title'), + }), + + data.totalDuration > 0 && + html.tag('p', + language.$('artistPage.contributedDurationLine', { + artist: data.name, + duration: + language.formatDuration(data.totalDuration, { + approximate: data.totalTrackCount > 1, + unit: true, + }), + })), + + sec.tracks.list + .slots({ + groupInfo: [ + sec.tracks.groupInfo + .clone() + .slots({ + title: language.$('artistPage.groupContributions.title.music'), + showSortButton: true, + sort: 'count', + countUnit: 'tracks', + visible: true, + }), + + sec.tracks.groupInfo + .clone() + .slots({ + title: language.$('artistPage.groupContributions.title.music'), + showSortButton: true, + sort: 'duration', + countUnit: 'tracks', + visible: false, + }), + ], + }), + ], + + sec.artworks && [ + sec.artworks.heading + .slots({ + tag: 'h2', + id: 'art', + title: language.$('artistPage.artList.title'), + }), + + sec.artworks.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery.orBrowseList', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + sec.artworks.list + .slots({ + groupInfo: + sec.artworks.groupInfo + .slots({ + title: language.$('artistPage.groupContributions.title.artworks'), + showBothColumns: false, + sort: 'count', + countUnit: 'artworks', + }), + }), + ], + + sec.flashes && [ + sec.flashes.heading + .slots({ + tag: 'h2', + id: 'flashes', + title: language.$('artistPage.flashList.title'), + }), + + sec.flashes.list, + ], + + sec.commentary && [ + sec.commentary.heading + .slots({ + tag: 'h2', + id: 'commentary', + title: language.$('artistPage.commentaryList.title'), + }), + + sec.commentary.list, + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + }) + .content, + }); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js new file mode 100644 index 00000000..656121c6 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -0,0 +1,188 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + // TODO: Add and integrate wallpaper and banner date fields (#90) + // This will probably only happen once all artworks follow a standard + // shape (#70) and get their own sorting function. Read for more info: + // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961 + + const entries = [ + ...artist.albumsAsCoverArtist.map(album => ({ + thing: album, + entry: { + type: 'albumCover', + album: album, + date: album.coverArtDate, + contribs: album.coverArtistContribs, + }, + })), + + ...artist.albumsAsWallpaperArtist.map(album => ({ + thing: album, + entry: { + type: 'albumWallpaper', + album: album, + date: album.coverArtDate, + contribs: album.wallpaperArtistContribs, + }, + })), + + ...artist.albumsAsBannerArtist.map(album => ({ + thing: album, + entry: { + type: 'albumBanner', + album: album, + date: album.coverArtDate, + contribs: album.bannerArtistContribs, + }, + })), + + ...artist.tracksAsCoverArtist.map(track => ({ + thing: track, + entry: { + type: 'trackCover', + album: track.album, + date: track.coverArtDate, + track: track, + contribs: track.coverArtistContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, + things => sortAlbumsTracksChronologically(things, { + getDate: thing => thing.coverArtDate, + })); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album', 'date']); + + return {chunks}; + }, + + relations(relation, query, artist) { + return { + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemTrackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track ? relation('linkTrack', track) : null)), + + itemOtherArtistLinks: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))), + }; + }, + + data(query, artist) { + return { + chunkDates: + query.chunks.map(({date}) => date), + + itemTypes: + query.chunks.map(({chunk}) => + chunk.map(({type}) => type)), + + itemContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + }; + }, + + generate(data, relations, {html, language}) { + return relations.chunkedList.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + date: data.chunkDates, + + items: relations.items, + itemTrackLinks: relations.itemTrackLinks, + itemOtherArtistLinks: relations.itemOtherArtistLinks, + itemTypes: data.itemTypes, + itemContributions: data.itemContributions, + }).map(({ + chunk, + albumLink, + date, + + items, + itemTrackLinks, + itemOtherArtistLinks, + itemTypes, + itemContributions, + }) => + chunk.slots({ + mode: 'album', + albumLink, + date, + + items: + stitchArrays({ + item: items, + trackLink: itemTrackLinks, + otherArtistLinks: itemOtherArtistLinks, + type: itemTypes, + contribution: itemContributions, + }).map(({ + item, + trackLink, + otherArtistLinks, + type, + contribution, + }) => + item.slots({ + otherArtistLinks, + contribution, + + content: + (type === 'trackCover' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.' + { + albumWallpaper: 'wallpaperArt', + albumBanner: 'bannerArt', + albumCover: 'coverArt', + }[type]))), + })), + })), + }); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js new file mode 100644 index 00000000..eb9056cb --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -0,0 +1,81 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + mode: { + validate: v => v.is('flash', 'album'), + }, + + albumLink: {type: 'html'}, + flashActLink: {type: 'html'}, + + date: {validate: v => v.isDate}, + dateRangeStart: {validate: v => v.isDate}, + dateRangeEnd: {validate: v => v.isDate}, + + duration: {validate: v => v.isDuration}, + durationApproximate: {type: 'boolean'}, + + items: {type: 'html'}, + }, + + generate(slots, {html, language}) { + let accentedLink; + + accent: { + switch (slots.mode) { + case 'album': { + accentedLink = slots.albumLink; + + const options = {album: accentedLink}; + const parts = ['artistPage.creditList.album']; + + if (slots.date) { + parts.push('withDate'); + options.date = language.formatDate(slots.date); + } + + if (slots.duration) { + parts.push('withDuration'); + options.duration = + language.formatDuration(slots.duration, { + approximate: slots.durationApproximate, + }); + } + + accentedLink = language.formatString(parts.join('.'), options); + break; + } + + case 'flash': { + accentedLink = slots.flashActLink; + + const options = {act: accentedLink}; + const parts = ['artistPage.creditList.flashAct']; + + if ( + slots.dateRangeStart && + slots.dateRangeEnd && + slots.dateRangeStart !== slots.dateRangeEnd + ) { + parts.push('withDateRange'); + options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd); + } else if (slots.dateRangeStart || slots.date) { + parts.push('withDate'); + options.date = language.formatDate(slots.dateFirst); + } + + accentedLink = language.formatString(parts.join('.'), options); + break; + } + } + } + + return html.tags([ + html.tag('dt', accentedLink), + html.tag('dd', + html.tag('ul', + slots.items)), + ]); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js new file mode 100644 index 00000000..9004f18a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -0,0 +1,50 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + content: {type: 'html'}, + + otherArtistLinks: {validate: v => v.arrayOf(v.isHTML)}, + contribution: {type: 'string'}, + rerelease: {type: 'boolean'}, + }, + + generate(slots, {html, language}) { + let accentedContent = slots.content; + + accent: { + if (slots.rerelease) { + accentedContent = + language.$('artistPage.creditList.entry.rerelease', { + entry: accentedContent, + }); + + break accent; + } + + const parts = ['artistPage.creditList.entry']; + const options = {entry: accentedContent}; + + if (slots.otherArtistLinks) { + parts.push('withArtists'); + options.artists = language.formatConjunctionList(slots.otherArtistLinks); + } + + if (slots.contribution) { + parts.push('withContribution'); + options.contribution = slots.contribution; + } + + if (parts.length === 1) { + break accent; + } + + accentedContent = language.formatString(parts.join('.'), options); + } + + return ( + html.tag('li', + {class: slots.rerelease && 'rerelease'}, + accentedContent)); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js new file mode 100644 index 00000000..a0334cbc --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -0,0 +1,16 @@ +export default { + extraDependencies: ['html'], + + slots: { + groupInfo: {type: 'html'}, + chunks: {type: 'html'}, + }, + + generate(slots, {html}) { + return ( + html.tag('dl', [ + slots.groupInfo, + slots.chunks, + ])); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js new file mode 100644 index 00000000..b96d6813 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -0,0 +1,111 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + // TODO: Add and integrate wallpaper and banner date fields (#90) + // This will probably only happen once all artworks follow a standard + // shape (#70) and get their own sorting function. Read for more info: + // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961 + + const entries = [ + ...artist.albumsAsCommentator.map(album => ({ + thing: album, + entry: { + type: 'album', + album, + }, + })), + + ...artist.tracksAsCommentator.map(track => ({ + thing: track, + entry: { + type: 'track', + album: track.album, + track, + }, + })), + ]; + + sortEntryThingPairs(entries, sortAlbumsTracksChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album']); + + return {chunks}; + }, + + relations(relation, query) { + return { + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemTrackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track ? relation('linkTrack', track) : null)), + }; + }, + + data(query) { + return { + itemTypes: + query.chunks.map(({chunk}) => + chunk.map(({type}) => type)), + }; + }, + + generate(data, relations, {html, language}) { + return html.tag('dl', + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + + items: relations.items, + itemTrackLinks: relations.itemTrackLinks, + itemTypes: data.itemTypes, + }).map(({chunk, albumLink, items, itemTrackLinks, itemTypes}) => + chunk.slots({ + mode: 'album', + albumLink, + items: + stitchArrays({ + item: items, + trackLink: itemTrackLinks, + type: itemTypes, + }).map(({item, trackLink, type}) => + item.slots({ + content: + (type === 'album' + ? html.tag('i', + language.$('artistPage.creditList.entry.album.commentary')) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js new file mode 100644 index 00000000..2f64483a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -0,0 +1,134 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortEntryThingPairs, + sortFlashesChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'linkFlash', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const entries = [ + ...artist.flashesAsContributor.map(flash => ({ + thing: flash, + entry: { + flash, + act: flash.act, + contribs: flash.contributorContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, sortFlashesChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['act']); + + return {chunks}; + }, + + relations(relation, query) { + // Flashes and games can list multiple contributors as collaborative + // credits, but we don't display these on the artist page, since they + // usually involve many artists crediting a larger team where collaboration + // isn't as relevant (without more particular details that aren't tracked + // on the wiki). + + return { + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + actLinks: + query.chunks.map(({chunk}) => + relation('linkFlash', chunk[0].flash)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemFlashLinks: + query.chunks.map(({chunk}) => + chunk.map(({flash}) => relation('linkFlash', flash))), + }; + }, + + data(query, artist) { + return { + actNames: + query.chunks.map(({act}) => act.name), + + firstDates: + query.chunks.map(({chunk}) => chunk[0].flash.date ?? null), + + lastDates: + query.chunks.map(({chunk}) => chunk[chunk.length - 1].flash.date ?? null), + + itemContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + }; + }, + + generate(data, relations, {html, language}) { + return html.tag('dl', + stitchArrays({ + chunk: relations.chunks, + actLink: relations.actLinks, + actName: data.actNames, + firstDate: data.firstDates, + lastDate: data.lastDates, + + items: relations.items, + itemFlashLinks: relations.itemFlashLinks, + itemContributions: data.itemContributions, + }).map(({ + chunk, + actLink, + actName, + firstDate, + lastDate, + + items, + itemFlashLinks, + itemContributions, + }) => + chunk.slots({ + mode: 'flash', + flashActLink: actLink.slot('content', actName), + dateRangeStart: firstDate, + dateRangeEnd: lastDate, + + items: + stitchArrays({ + item: items, + flashLink: itemFlashLinks, + contribution: itemContributions, + }).map(({ + item, + flashLink, + contribution, + }) => + item.slots({ + contribution, + + content: + language.$('artistPage.creditList.entry.flash', { + flash: flashLink, + }), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js new file mode 100644 index 00000000..7667dea7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -0,0 +1,23 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkArtist'], + + relations(relation, contribs, artist) { + const otherArtistContribs = contribs.filter(({who}) => who !== artist); + + if (empty(otherArtistContribs)) { + return {}; + } + + const otherArtistLinks = + otherArtistContribs + .map(({who}) => relation('linkArtist', who)); + + return {otherArtistLinks}; + }, + + generate(relations) { + return relations.otherArtistLinks ?? null; + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js new file mode 100644 index 00000000..d6ae9ae8 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -0,0 +1,185 @@ +import {accumulateSum, stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['language'], + + query(artist) { + const entries = [ + ...artist.tracksAsArtist.map(track => ({ + thing: track, + entry: { + track, + album: track.album, + date: track.date, + contribs: track.artistContribs, + }, + })), + + ...artist.tracksAsContributor.map(track => ({ + thing: track, + entry: { + track, + date: track.date, + album: track.album, + contribs: track.contributorContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, sortAlbumsTracksChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album', 'date']); + + return {chunks}; + }, + + relations(relation, query, artist) { + return { + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + trackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => relation('linkTrack', track))), + + trackOtherArtistLinks: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))), + }; + }, + + data(query, artist) { + return { + chunkDates: + query.chunks.map(({date}) => date), + + chunkDurations: + query.chunks.map(({chunk}) => + accumulateSum( + chunk + .filter(({track}) => track.duration && track.originalReleaseTrack === null) + .map(({track}) => track.duration))), + + chunkDurationsApproximate: + query.chunks.map(({chunk}) => + chunk + .filter(({track}) => track.duration && track.originalReleaseTrack === null) + .length > 1), + + trackDurations: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track.duration)), + + trackContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + + trackRereleases: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track.originalReleaseTrack !== null)), + }; + }, + + generate(data, relations, {language}) { + return relations.chunkedList.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + date: data.chunkDates, + duration: data.chunkDurations, + durationApproximate: data.chunkDurationsApproximate, + + items: relations.items, + trackLinks: relations.trackLinks, + trackOtherArtistLinks: relations.trackOtherArtistLinks, + trackDurations: data.trackDurations, + trackContributions: data.trackContributions, + trackRereleases: data.trackRereleases, + }).map(({ + chunk, + albumLink, + date, + duration, + durationApproximate, + + items, + trackLinks, + trackOtherArtistLinks, + trackDurations, + trackContributions, + trackRereleases, + }) => + chunk.slots({ + mode: 'album', + albumLink, + date, + duration, + durationApproximate, + + items: + stitchArrays({ + item: items, + trackLink: trackLinks, + otherArtistLinks: trackOtherArtistLinks, + duration: trackDurations, + contribution: trackContributions, + rerelease: trackRereleases, + }).map(({ + item, + trackLink, + otherArtistLinks, + duration, + contribution, + rerelease, + }) => + item.slots({ + otherArtistLinks, + contribution, + rerelease, + + content: + (duration + ? language.$('artistPage.creditList.entry.track.withDuration', { + track: trackLink, + duration: language.formatDuration(duration), + }) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + })), + })), + }); + }, +}; diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js new file mode 100644 index 00000000..f78b45a1 --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,100 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkArtistGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableListings: wikiInfo.enableListings, + }; + }, + + relations(relation, sprawl, artist) { + const relations = {}; + + relations.artistMainLink = + relation('linkArtist', artist); + + relations.artistInfoLink = + relation('linkArtist', artist); + + if ( + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist) + ) { + relations.artistGalleryLink = + relation('linkArtistGallery', artist); + } + + return relations; + }, + + data(sprawl) { + return { + enableListings: sprawl.enableListings, + }; + }, + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {html, language}) { + const infoLink = + relations.artistInfoLink?.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.artistGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }), + ]}; + + const mostAccentLinks = [ + ...extraLinks, + ].filter(Boolean); + + // Don't show the info accent link all on its own. + const allAccentLinks = + (empty(mostAccentLinks) + ? [] + : [infoLink, ...mostAccentLinks]); + + const accent = + (empty(allAccentLinks) + ? html.blank() + : `(${language.formatUnitList(allAccentLinks)})`); + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + accent, + html: + language.$('artistPage.nav.artist', { + artist: relations.artistMainLink, + }), + }, + ]; + }, +}; diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js new file mode 100644 index 00000000..835140a8 --- /dev/null +++ b/src/content/dependencies/generateBanner.js @@ -0,0 +1,28 @@ +export default { + extraDependencies: ['html', 'to'], + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + dimensions: { + validate: v => v.isDimensions, + }, + + alt: { + type: 'string', + }, + }, + + generate(slots, {html, to}) { + return ( + html.tag('div', {id: 'banner'}, + html.tag('img', { + src: to(...slots.path), + alt: slots.alt, + width: slots.dimensions?.[0] ?? 1100, + height: slots.dimensions?.[1] ?? 200, + }))); + }, +}; diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js new file mode 100644 index 00000000..15c0898c --- /dev/null +++ b/src/content/dependencies/generateChronologyLinks.js @@ -0,0 +1,82 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + slots: { + chronologyInfoSets: { + validate: v => + v.arrayOf( + v.validateProperties({ + headingString: v.isString, + contributions: v.arrayOf(v.validateProperties({ + index: v.isCountingNumber, + artistLink: v.isHTML, + previousLink: v.isHTML, + nextLink: v.isHTML, + })), + })), + } + }, + + generate(slots, {html, language}) { + if (empty(slots.chronologyInfoSets)) { + return html.blank(); + } + + const totalContributionCount = + accumulateSum( + slots.chronologyInfoSets, + ({contributions}) => contributions.length); + + if (totalContributionCount === 0) { + return html.blank(); + } + + if (totalContributionCount > 8) { + return html.tag('div', {class: 'chronology'}, + language.$('misc.chronology.seeArtistPages')); + } + + return html.tags( + slots.chronologyInfoSets.map(({ + headingString, + contributions, + }) => + contributions.map(({ + index, + artistLink, + previousLink, + nextLink, + }) => { + const heading = + html.tag('span', {class: 'heading'}, + language.$(headingString, { + index: language.formatIndex(index), + artist: artistLink, + })); + + const navigation = + (previousLink || nextLink) && + html.tag('span', {class: 'buttons'}, + language.formatUnitList([ + previousLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.previous'), + }), + + nextLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean))); + + return html.tag('div', {class: 'chronology'}, + (navigation + ? language.$('misc.chronology.withNavigation', {heading, navigation}) + : heading)); + }))); + }, +}; diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js new file mode 100644 index 00000000..fbc32599 --- /dev/null +++ b/src/content/dependencies/generateColorStyleRules.js @@ -0,0 +1,27 @@ +export default { + contentDependencies: [ + 'generateColorStyleVariables', + ], + + relations(relation, color) { + const relations = {}; + + if (color) { + relations.variables = + relation('generateColorStyleVariables', color); + } + + return relations; + }, + + generate(relations) { + if (!relations.variables) return ''; + + return [ + `:root {`, + // This is pretty hilariously hacky. + ...relations.variables.split(';').map(line => line + ';'), + `}`, + ].join('\n'); + }, +}; diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js new file mode 100644 index 00000000..90346d8d --- /dev/null +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -0,0 +1,33 @@ +export default { + extraDependencies: [ + 'getColors', + ], + + data(color) { + return {color}; + }, + + generate(data, {getColors}) { + if (!data.color) return []; + + const { + primary, + dark, + dim, + dimGhost, + bg, + bgBlack, + shadow, + } = getColors(data.color); + + return [ + `--primary-color: ${primary}`, + `--dark-color: ${dark}`, + `--dim-color: ${dim}`, + `--dim-ghost-color: ${dimGhost}`, + `--bg-color: ${bg}`, + `--bg-black-color: ${bgBlack}`, + `--shadow-color: ${shadow}`, + ].join('; '); + }, +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 00000000..ccaf1076 --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,19 @@ +export default { + extraDependencies: ['html'], + + slots: { + title: {type: 'html'}, + id: {type: 'string'}, + tag: {type: 'string', default: 'p'}, + }, + + generate(slots, {html}) { + return html.tag(slots.tag, + { + class: 'content-heading', + id: slots.id, + tabindex: '0', + }, + slots.title); + } +} diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js new file mode 100644 index 00000000..503bd120 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['image', 'linkArtTag'], + extraDependencies: ['html', 'language'], + + relations(relation, artTags) { + const relations = {}; + + relations.image = + relation('image', artTags); + + if (artTags) { + relations.tagLinks = + artTags + .filter(tag => !tag.isContentWarning) + .map(tag => relation('linkArtTag', tag)); + } else { + relations.tagLinks = null; + } + + return relations; + }, + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + + mode: { + validate: v => v.is('primary', 'thumbnail'), + default: 'primary', + }, + }, + + generate(relations, slots, {html, language}) { + switch (slots.mode) { + case 'primary': + return html.tag('div', {id: 'cover-art-container'}, [ + relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'medium', + id: 'cover-art', + reveal: true, + link: true, + square: true, + }), + + !empty(relations.tagLinks) && + html.tag('p', + language.$('releaseInfo.artTags.inline', { + tags: language.formatUnitList(relations.tagLinks), + })), + ]); + + case 'thumbnail': + return relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'small', + reveal: false, + link: false, + square: true, + }); + + default: + return html.blank(); + } + }, +}; diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js new file mode 100644 index 00000000..2a2503ac --- /dev/null +++ b/src/content/dependencies/generateCoverCarousel.js @@ -0,0 +1,54 @@ +import {empty, repeat, stitchArrays} from '../../util/sugar.js'; +import {getCarouselLayoutForNumberOfItems} from '../../util/wiki-data.js'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.arrayOf(v.isHTML)}, + links: {validate: v => v.arrayOf(v.isHTML)}, + + lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + const stitched = + stitchArrays({ + image: slots.images, + link: slots.links, + }); + + if (empty(stitched)) { + return; + } + + const layout = getCarouselLayoutForNumberOfItems(stitched.length); + + return html.tag('div', + { + class: 'carousel-container', + 'data-carousel-rows': layout.rows, + 'data-carousel-columns': layout.columns, + }, + repeat(3, [ + html.tag('div', + {class: 'carousel-grid', 'aria-hidden': 'true'}, + stitched.map(({image, link}, index) => + html.tag('div', {class: 'carousel-item'}, + link.slots({ + attributes: {tabindex: '-1'}, + content: + image.slots({ + thumb: 'small', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + })))), + ])); + }, +}; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js new file mode 100644 index 00000000..20130c5e --- /dev/null +++ b/src/content/dependencies/generateCoverGrid.js @@ -0,0 +1,42 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.arrayOf(v.isHTML)}, + links: {validate: v => v.arrayOf(v.isHTML)}, + names: {validate: v => v.arrayOf(v.isHTML)}, + info: {validate: v => v.arrayOf(v.isHTML)}, + + lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + return ( + html.tag('div', {class: 'grid-listing'}, + stitchArrays({ + image: slots.images, + link: slots.links, + name: slots.names, + info: slots.info, + }).map(({image, link, name, info}, index) => + link.slots({ + attributes: {class: ['grid-item', 'box']}, + content: [ + image.slots({ + thumb: 'medium', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + html.tag('span', {[html.onlyIfContent]: true}, name), + html.tag('span', {[html.onlyIfContent]: true}, info), + ], + })))); + }, +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 00000000..b4970b17 --- /dev/null +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -0,0 +1,44 @@ +export default { + extraDependencies: [ + 'defaultLanguage', + 'html', + 'language', + 'languages', + 'pagePath', + 'to', + ], + + generate({ + defaultLanguage, + html, + language, + languages, + pagePath, + to, + }) { + const links = Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language) + .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) + .map((language) => + html.tag('span', + html.tag('a', + { + href: + language === defaultLanguage + ? to( + 'localizedDefaultLanguage.' + pagePath[0], + ...pagePath.slice(1)) + : to( + 'localizedWithBaseDirectory.' + pagePath[0], + language.code, + ...pagePath.slice(1)), + }, + language.name))); + + return html.tag('div', {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', { + languages: links.join('\n'), + })); + }, +}; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js new file mode 100644 index 00000000..7b655805 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -0,0 +1,216 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +import { + filterItemsForCarousel, + getTotalDuration, + sortChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateCoverCarousel', + 'generateCoverGrid', + 'generateGroupNavLinks', + 'generateGroupSidebar', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkListing', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({listingSpec, wikiInfo}) { + const sprawl = {}; + sprawl.enableGroupUI = wikiInfo.enableGroupUI; + + if (wikiInfo.enableListings && wikiInfo.enableGroupUI) { + sprawl.groupsByCategoryListing = + listingSpec + .find(l => l.directory === 'groups/by-category'); + } + + return sprawl; + }, + + relations(relation, sprawl, group) { + const relations = {}; + + const albums = + sortChronologically(group.albums.slice(), {latestFirst: true}); + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateGroupNavLinks', group); + + if (sprawl.enableGroupUI) { + relations.sidebar = + relation('generateGroupSidebar', group); + } + + relations.colorStyleRules = + relation('generateColorStyleRules', group.color); + + if (sprawl.groupsByCategoryListing) { + relations.groupListingLink = + relation('linkListing', sprawl.groupsByCategoryListing); + } + + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(carouselAlbums)) { + relations.coverCarousel = + relation('generateCoverCarousel'); + + relations.carouselLinks = + carouselAlbums + .map(album => relation('linkAlbum', album)); + + relations.carouselImages = + carouselAlbums + .map(album => relation('image', album.artTags)); + } + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.gridLinks = + albums + .map(album => relation('linkAlbum', album)); + + relations.gridImages = + albums.map(album => + (album.hasCoverArt + ? relation('image', album.artTags) + : relation('image'))); + + return relations; + }, + + data(sprawl, group) { + const data = {}; + + data.name = group.name; + + const albums = sortChronologically(group.albums.slice(), {latestFirst: true}); + const tracks = albums.flatMap((album) => album.tracks); + + data.numAlbums = albums.length; + data.numTracks = tracks.length; + data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); + + data.gridNames = albums.map(album => album.name); + data.gridDurations = albums.map(album => getTotalDuration(album.tracks)); + data.gridNumTracks = albums.map(album => album.tracks.length); + + data.gridPaths = + albums.map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(group.featuredAlbums)) { + data.carouselPaths = + carouselAlbums.map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + } + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: language.$('groupGalleryPage.title', {group: data.name}), + headingMode: 'static', + + colorStyleRules: [relations.colorStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.coverCarousel + ?.slots({ + links: relations.carouselLinks, + images: + stitchArrays({ + image: relations.carouselImages, + path: data.carouselPaths, + }).map(({image, path}) => + image.slot('path', path)), + }), + + html.tag('p', + {class: 'quick-info'}, + language.$('groupGalleryPage.infoLine', { + tracks: html.tag('b', + language.countTracks(data.numTracks, { + unit: true, + })), + albums: html.tag('b', + language.countAlbums(data.numAlbums, { + unit: true, + })), + time: html.tag('b', + language.formatDuration(data.totalDuration, { + unit: true, + })), + })), + + relations.groupListingLink && + html.tag('p', + {class: 'quick-info'}, + language.$('groupGalleryPage.anotherGroupLine', { + link: + relations.groupListingLink + .slot('content', language.$('groupGalleryPage.anotherGroupLine.link')), + })), + + relations.coverGrid + .slots({ + links: relations.gridLinks, + names: data.gridNames, + images: + stitchArrays({ + image: relations.gridImages, + path: data.gridPaths, + name: data.gridNames, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })), + info: + stitchArrays({ + numTracks: data.gridNumTracks, + duration: data.gridDurations, + }).map(({numTracks, duration}) => + language.$('misc.albumGrid.details', { + tracks: language.countTracks(numTracks, {unit: true}), + time: language.formatDuration(duration), + })), + }), + ], + + ... + relations.sidebar + ?.slot('currentExtra', 'gallery') + ?.content, + + navLinkStyle: 'hierarchical', + navLinks: + relations.navLinks + .slot('currentExtra', 'gallery') + .content, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js new file mode 100644 index 00000000..3cffb748 --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -0,0 +1,170 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateContentHeading', + 'generateGroupNavLinks', + 'generateGroupSidebar', + 'generatePageLayout', + 'linkAlbum', + 'linkExternal', + 'linkGroupGallery', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableGroupUI: wikiInfo.enableGroupUI, + }; + }, + + relations(relation, sprawl, group) { + const relations = {}; + const sec = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateGroupNavLinks', group); + + if (sprawl.enableGroupUI) { + relations.sidebar = + relation('generateGroupSidebar', group); + } + + relations.colorStyleRules = + relation('generateColorStyleRules', group.color); + + sec.info = {}; + + if (!empty(group.urls)) { + sec.info.visitLinks = + group.urls + .map(url => relation('linkExternal', url)); + } + + if (group.description) { + sec.info.description = + relation('transformContent', group.description); + } + + if (!empty(group.albums)) { + sec.albums = {}; + + sec.albums.heading = + relation('generateContentHeading'); + + sec.albums.galleryLink = + relation('linkGroupGallery', group); + + sec.albums.entries = + group.albums.map(album => { + const links = {}; + links.albumLink = relation('linkAlbum', album); + + const otherGroup = album.groups.find(g => g !== group); + if (otherGroup) { + links.groupLink = relation('linkGroup', otherGroup); + } + + return links; + }); + } + + return relations; + }, + + data(sprawl, group) { + const data = {}; + + data.name = group.name; + + if (!empty(group.albums)) { + data.albumYears = + group.albums + .map(album => album.date?.getFullYear()); + } + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('groupInfoPage.title', {group: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + + mainContent: [ + sec.info.visitLinks && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(sec.info.visitLinks), + })), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + sec.info.description + ?.slot('mode', 'multiline')), + + sec.albums && [ + sec.albums.heading + .slots({ + tag: 'h2', + title: language.$('groupInfoPage.albumList.title'), + }), + + html.tag('p', + language.$('groupInfoPage.viewAlbumGallery', { + link: + sec.albums.galleryLink + .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')), + })), + + html.tag('ul', + sec.albums.entries.map(({albumLink, groupLink}, index) => { + // All these strings are really jank, and should probably + // be implemented with the same 'const parts = [], opts = {}' + // form used elsewhere... + const year = data.albumYears[index]; + const item = + (year + ? language.$('groupInfoPage.albumList.item', { + year, + album: albumLink, + }) + : language.$('groupInfoPage.albumList.item.withoutYear', { + album: albumLink, + })); + + return html.tag('li', + (groupLink + ? language.$('groupInfoPage.albumList.item.withAccent', { + item, + accent: + html.tag('span', {class: 'other-group-accent'}, + language.$('groupInfoPage.albumList.item.otherGroupAccent', { + group: + groupLink.slot('color', false), + })), + }) + : item)); + })), + ], + ], + + ...relations.sidebar?.content ?? {}, + + navLinkStyle: 'hierarchical', + navLinks: relations.navLinks.content, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js new file mode 100644 index 00000000..0b525363 --- /dev/null +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -0,0 +1,142 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkGroup', + 'linkGroupGallery', + 'linkGroupExtra', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData, wikiInfo}) { + return { + groupCategoryData, + enableGroupUI: wikiInfo.enableGroupUI, + enableListings: wikiInfo.enableListings, + }; + }, + + relations(relation, sprawl, group) { + if (!sprawl.enableGroupUI) { + return {}; + } + + const relations = {}; + + relations.mainLink = + relation('linkGroup', group); + + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + const groups = sprawl.groupCategoryData + .flatMap(category => category.groups); + + const index = groups.indexOf(group); + + if (index > 0) { + relations.previousLink = + relation('linkGroupExtra', groups[index - 1]); + } + + if (index < groups.length - 1) { + relations.nextLink = + relation('linkGroupExtra', groups[index + 1]); + } + + relations.infoLink = + relation('linkGroup', group); + + if (!empty(group.albums)) { + relations.galleryLink = + relation('linkGroupGallery', group); + } + + return relations; + }, + + data(sprawl) { + return { + enableGroupUI: sprawl.enableGroupUI, + enableListings: sprawl.enableListings, + }; + }, + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {language}) { + if (!data.enableGroupUI) { + return [ + {auto: 'home'}, + {auto: 'current'}, + ]; + } + + const previousNextLinks = + (relations.previousLink || relations.nextLink) && + relations.previousNextLinks.slots({ + previousLink: + relations.previousLink + ?.slot('extra', slots.currentExtra) + ?.content + ?? null, + nextLink: + relations.nextLink + ?.slot('extra', slots.currentExtra) + ?.content + ?? null, + }); + + const previousNextPart = + previousNextLinks && + language.formatUnitList( + previousNextLinks.content.filter(Boolean)); + + const infoLink = + relations.infoLink.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const extraLinks = [ + relations.galleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }), + ]; + + const extrasPart = + (empty(extraLinks) + ? '' + : language.formatUnitList([infoLink, ...extraLinks])); + + const accent = + `(${[extrasPart, previousNextPart].filter(Boolean).join('; ')})`; + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + accent, + html: + language.$('groupPage.nav.group', { + group: relations.mainLink, + }), + }, + ].filter(Boolean); + }, +}; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js new file mode 100644 index 00000000..6baf37f4 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebar.js @@ -0,0 +1,35 @@ +export default { + contentDependencies: ['generateGroupSidebarCategoryDetails'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData}) { + return {groupCategoryData}; + }, + + relations(relation, sprawl, group) { + return { + categoryDetails: + sprawl.groupCategoryData.map(category => + relation('generateGroupSidebarCategoryDetails', category, group)), + }; + }, + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(relations, slots, {html, language}) { + return { + leftSidebarContent: [ + html.tag('h1', + language.$('groupSidebar.title')), + + relations.categoryDetails + .map(details => + details.slot('currentExtra', slots.currentExtra)), + ], + }; + }, +}; diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js new file mode 100644 index 00000000..ec707e39 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleVariables', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, category) { + return { + colorVariables: relation('generateColorStyleVariables', category.color), + + // Which of these is used depends on the currentExtra slot, so all + // available links are included here. + groupLinks: category.groups.map(group => { + const links = {}; + links.info = relation('linkGroup', group); + + if (!empty(group.albums)) { + links.gallery = relation('linkGroupGallery', group); + } + + return links; + }), + }; + }, + + data(category, group) { + const data = {}; + + data.name = category.name; + data.isCurrentCategory = category === group.category; + + if (data.isCurrentCategory) { + data.currentGroupIndex = category.groups.indexOf(group); + } + + return data; + }, + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {html, language}) { + return html.tag('details', + { + open: data.isCurrentCategory, + class: data.isCurrentCategory && 'current', + }, + [ + html.tag('summary', + {style: relations.colorVariables}, + html.tag('span', + language.$('groupSidebar.groupList.category', { + category: + html.tag('span', {class: 'group-name'}, + data.name), + }))), + + html.tag('ul', + relations.groupLinks.map((links, index) => + html.tag('li', + {class: index === data.currentGroupIndex && 'current'}, + language.$('groupSidebar.groupList.item', { + group: + links[slots.currentExtra ?? 'info'] ?? + links.info, + })))), + ]); + }, +}; diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js new file mode 100644 index 00000000..e4a2f5c7 --- /dev/null +++ b/src/content/dependencies/generateListingIndexList.js @@ -0,0 +1,130 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: ['generateColorStyleVariables', 'linkListing'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({listingTargetSpec, wikiInfo}) { + return {listingTargetSpec, wikiInfo}; + }, + + query(sprawl) { + const query = {}; + + const targetListings = + sprawl.listingTargetSpec + .map(({listings}) => + listings + .filter(listing => + !listing.featureFlag || + sprawl.wikiInfo[listing.featureFlag])); + + query.wikiColor = sprawl.wikiInfo.color; + + query.targets = + sprawl.listingTargetSpec + .filter((target, index) => !empty(targetListings[index])); + + query.targetListings = + targetListings + .filter(listings => !empty(listings)) + + return query; + }, + + relations(relation, query) { + return { + wikiColorVariables: relation('generateColorStyleVariables', query.wikiColor), + + listingLinks: + query.targetListings + .map(listings => + listings.map(listing => relation('linkListing', listing))), + }; + }, + + data(query, sprawl, currentListing) { + const data = {}; + + data.targetStringsKeys = + query.targets + .map(({stringsKey}) => stringsKey); + + data.listingStringsKeys = + query.targetListings + .map(listings => + listings.map(({stringsKey}) => stringsKey)); + + if (currentListing) { + data.currentTargetIndex = + query.targets + .indexOf(currentListing.target); + + data.currentListingIndex = + query.targetListings + .find(listings => listings.includes(currentListing)) + .indexOf(currentListing); + } + + return data; + }, + + slots: { + mode: {validate: v => v.is('content', 'sidebar')}, + }, + + generate(data, relations, slots, {html, language}) { + const listingLinkLists = + stitchArrays({ + listingLinks: relations.listingLinks, + listingStringsKeys: data.listingStringsKeys, + }).map(({listingLinks, listingStringsKeys}, targetIndex) => + html.tag('ul', + stitchArrays({ + listingLink: listingLinks, + listingStringsKey: listingStringsKeys, + }).map(({listingLink, listingStringsKey}, listingIndex) => + html.tag('li', + {class: + targetIndex === data.currentTargetIndex && + listingIndex === data.currentListingIndex && + 'current'}, + listingLink + .slot('content', language.$(`listingPage.${listingStringsKey}.title.short`)))))); + + const targetTitles = + data.targetStringsKeys + .map(stringsKey => language.$(`listingPage.target.${stringsKey}`)); + + switch (slots.mode) { + case 'sidebar': + return html.tags( + stitchArrays({ + targetTitle: targetTitles, + listingLinkList: listingLinkLists, + }).map(({targetTitle, listingLinkList}, targetIndex) => + html.tag('details', + { + open: targetIndex === data.currentTargetIndex, + class: targetIndex === data.currentTargetIndex && 'current', + }, + [ + html.tag('summary', {style: relations.wikiColorVariables}, + html.tag('span', {class: 'group-name'}, targetTitle)), + + listingLinkList, + ]))); + + case 'content': + return ( + html.tag('dl', + stitchArrays({ + targetTitle: targetTitles, + listingLinkList: listingLinkLists, + }).map(({targetTitle, listingLinkList}) => [ + html.tag('dt', {class: ['content-heading']}, targetTitle), + html.tag('dd', listingLinkList), + ]))); + } + }, +}; diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js new file mode 100644 index 00000000..cab80a7f --- /dev/null +++ b/src/content/dependencies/generateListingPage.js @@ -0,0 +1,142 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateListingSidebar', + 'generatePageLayout', + 'linkListing', + 'linkListingIndex', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + relations(relation, listing) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generateListingSidebar', listing); + + relations.listingsIndexLink = + relation('linkListingIndex'); + + relations.chunkHeading = + relation('generateContentHeading'); + + if (listing.target.listings.length > 1) { + relations.sameTargetListingLinks = + listing.target.listings + .map(listing => relation('linkListing', listing)); + } + + if (!empty(listing.seeAlso)) { + relations.seeAlsoLinks = + 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), + }; + }, + + slots: { + type: {validate: v => v.is('rows', 'chunks', 'custom')}, + + rows: {validate: v => v.arrayOf(v.isObject)}, + + chunkTitles: {validate: v => v.arrayOf(v.isObject)}, + chunkRows: {validate: v => v.arrayOf(v.isObject)}, + + content: {type: 'html'}, + }, + + generate(data, relations, slots, {html, language}) { + return relations.layout.slots({ + title: language.$(`listingPage.${data.stringsKey}.title`), + headingMode: 'sticky', + + mainContent: [ + relations.sameTargetListingLinks && + html.tag('p', + language.$('listingPage.listingsFor', { + target: language.$(`listingPage.target.${data.targetStringsKey}`), + listings: + language.formatUnitList( + stitchArrays({ + link: relations.sameTargetListingLinks, + stringsKey: data.sameTargetListingStringsKeys, + }).map(({link, stringsKey}, index) => + html.tag('span', + {class: index === data.sameTargetListingsCurrentIndex && 'current'}, + link.slots({ + attributes: {class: 'nowrap'}, + content: language.$(`listingPage.${stringsKey}.title.short`), + })))), + })), + + relations.seeAlsoLinks && + html.tag('p', + language.$('listingPage.seeAlso', { + listings: language.formatUnitList(relations.seeAlsoLinks), + })), + + slots.type === 'rows' && + html.tag('ul', + slots.rows.map(row => + html.tag('li', + language.$(`listingPage.${data.stringsKey}.item`, row)))), + + slots.type === 'chunks' && + html.tag('dl', + stitchArrays({ + title: slots.chunkTitles, + rows: slots.chunkRows, + }).map(({title, rows}) => [ + relations.chunkHeading + .clone() + .slots({ + tag: 'dt', + title: + language.$(`listingPage.${data.stringsKey}.chunk.title`, title), + }), + + html.tag('dd', + html.tag('ul', + rows.map(row => + html.tag('li', + language.$(`listingPage.${data.stringsKey}.chunk.item`, row))))), + ])), + + slots.type === 'custom' && + slots.content, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.listingsIndexLink}, + {auto: 'current'}, + ], + + ...relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js new file mode 100644 index 00000000..fe2a08fa --- /dev/null +++ b/src/content/dependencies/generateListingSidebar.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['generateListingIndexList', 'linkListingIndex'], + extraDependencies: ['html'], + + relations(relation, currentListing) { + return { + listingIndexLink: relation('linkListingIndex'), + listingIndexList: relation('generateListingIndexList', currentListing), + }; + }, + + generate(relations, {html}) { + return { + leftSidebarContent: [ + html.tag('h1', relations.listingIndexLink), + relations.listingIndexList.slot('mode', 'sidebar'), + ], + }; + }, +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 00000000..794b430b --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,546 @@ +import {empty, openAggregate} from '../../util/sugar.js'; + +function sidebarSlots(side) { + return { + // Content is a flat HTML array. It'll generate one sidebar section + // if specified. + [side + 'Content']: {type: 'html'}, + + // Multiple is an array of {content: (HTML)} objects. Each of these + // will generate one sidebar section. + [side + 'Multiple']: { + validate: v => + v.arrayOf( + v.validateProperties({ + content: v.isHTML, + })), + }, + + // Sticky mode controls which sidebar section(s), if any, follow the + // scroll position, "sticking" to the top of the browser viewport. + // + // 'last' - last or only sidebar box is sticky + // 'column' - entire column, incl. multiple boxes from top, is sticky + // 'none' - sidebar not sticky at all, stays at top of page + // + // Note: This doesn't affect the content of any sidebar section, only + // the whole section's containing box (or the sidebar column as a whole). + [side + 'StickyMode']: { + validate: v => v.is('last', 'column', 'static'), + }, + + // Collapsing sidebars disappear when the viewport is sufficiently + // thin. (This is the default.) Override as false to make the sidebar + // stay visible in thinner viewports, where the page layout will be + // reflowed so the sidebar is as wide as the screen and appears below + // nav, above the main content. + [side + 'Collapse']: {type: 'boolean', default: true}, + + // Wide sidebars generally take up more horizontal space in the normal + // page layout, and should be used if the content of the sidebar has + // a greater than typical focus compared to main content. + [side + 'Wide']: {type: 'boolean', defualt: false}, + }; +} + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'cachebust', + 'html', + 'language', + 'to', + 'wikiData', + ], + + sprawl({wikiInfo}) { + return { + footerContent: wikiInfo.footerContent, + wikiColor: wikiInfo.color, + wikiName: wikiInfo.nameShort, + }; + }, + + data({wikiName}) { + return { + wikiName, + }; + }, + + relations(relation, sprawl) { + const relations = {}; + + relations.footerLocalizationLinks = + relation('generateFooterLocalizationLinks'); + + relations.stickyHeadingContainer = + relation('generateStickyHeadingContainer'); + + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + + relations.defaultColorStyleRules = + relation('generateColorStyleRules', sprawl.wikiColor); + + return relations; + }, + + slots: { + title: {type: 'html'}, + showWikiNameInTitle: {type: 'boolean', default: true}, + + cover: {type: 'html'}, + + socialEmbed: {type: 'html'}, + + colorStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + additionalStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + mainClasses: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + // Main + + mainContent: {type: 'html'}, + + headingMode: { + validate: v => v.is('sticky', 'static'), + default: 'static', + }, + + // Sidebars + + ...sidebarSlots('leftSidebar'), + ...sidebarSlots('rightSidebar'), + + // Banner + + banner: {type: 'html'}, + bannerPosition: { + validate: v => v.is('top', 'bottom'), + default: 'top', + }, + + // Nav & Footer + + navContent: {type: 'html'}, + navBottomRowContent: {type: 'html'}, + + navLinkStyle: { + validate: v => v.is('hierarchical', 'index'), + default: 'index', + }, + + navLinks: { + validate: v => + v.arrayOf(object => { + v.isObject(object); + + const aggregate = openAggregate({message: `Errors validating navigation link`}); + + aggregate.call(v.validateProperties({ + auto: () => true, + html: () => true, + + path: () => true, + title: () => true, + accent: () => true, + }), object); + + if (object.auto || object.html) { + if (object.auto && object.html) { + aggregate.push(new TypeError(`Don't specify both auto and html`)); + } else if (object.auto) { + aggregate.call(v.is('home', 'current'), object.auto); + } else { + aggregate.call(v.isHTML, object.html); + } + + if (object.path || object.title) { + aggregate.push(new TypeError(`Don't specify path or title along with auto or html`)); + } + } else { + aggregate.call(v.validateProperties({ + path: v.arrayOf(v.isString), + title: v.isString, + }), { + path: object.path, + title: object.title, + }); + } + + aggregate.close(); + + return true; + }) + }, + + secondaryNav: {type: 'html'}, + + footerContent: {type: 'html'}, + }, + + generate(data, relations, slots, { + cachebust, + html, + language, + to, + }) { + let titleHTML = null; + + if (!html.isBlank(slots.title)) { + switch (slots.headingMode) { + case 'sticky': + titleHTML = + relations.stickyHeadingContainer.slots({ + title: slots.title, + cover: slots.cover, + }); + break; + case 'static': + titleHTML = html.tag('h1', slots.title); + break; + } + } + + let footerContent = slots.footerContent; + + if (html.isBlank(footerContent)) { + footerContent = relations.defaultFooterContent + .slot('mode', 'multiline'); + } + + const mainHTML = + html.tag('main', { + id: 'content', + class: slots.mainClasses, + }, [ + titleHTML, + + slots.cover, + + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'main-content-container', + }, + slots.mainContent), + ]); + + const footerHTML = + html.tag('footer', + {[html.onlyIfContent]: true, id: 'footer'}, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'footer-content', + }, + footerContent), + + relations.footerLocalizationLinks, + ]); + + const navHTML = html.tag('nav', + { + [html.onlyIfContent]: true, + id: 'header', + class: [ + !empty(slots.navLinks) && 'nav-has-main-links', + !html.isBlank(slots.navContent) && 'nav-has-content', + !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row', + ], + }, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: [ + 'nav-main-links', + 'nav-links-' + slots.navLinkStyle, + ], + }, + slots.navLinks?.map((cur, i) => { + let content; + + if (cur.html) { + content = cur.html; + } else { + let title; + let href; + + switch (cur.auto) { + case 'home': + title = data.wikiName; + href = to('localized.home'); + break; + case 'current': + title = slots.title; + href = ''; + break; + case null: + case undefined: + title = cur.title; + href = to(...cur.path); + break; + } + + content = html.tag('a', + {href}, + title); + } + + let className; + + if (cur.auto === 'current') { + className = 'current'; + } else if ( + slots.navLinkStyle === 'hierarchical' && + i === slots.navLinks.length - 1 + ) { + className = 'current'; + } + + return html.tag('span', + {class: className}, + [ + html.tag('span', + {class: 'nav-link-content'}, + content), + html.tag('span', + {[html.onlyIfContent]: true, class: 'nav-link-accent'}, + cur.accent), + ]); + })), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-bottom-row'}, + slots.navBottomRowContent), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-content'}, + slots.navContent), + ]) + + const generateSidebarHTML = (side, id) => { + const content = slots[side + 'Content']; + const multiple = slots[side + 'Multiple']; + const stickyMode = slots[side + 'StickyMode']; + const wide = slots[side + 'Wide']; + const collapse = slots[side + 'Collapse']; + + let sidebarClasses = []; + let sidebarContent = html.blank(); + + if (!html.isBlank(content)) { + sidebarClasses = ['sidebar']; + sidebarContent = content; + } else if (multiple) { + sidebarClasses = ['sidebar-multiple']; + sidebarContent = + multiple + .filter(Boolean) + .map(({content}) => + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'sidebar', + }, + content)); + } + + return html.tag('div', + { + [html.onlyIfContent]: true, + id, + class: [ + 'sidebar-column', + wide && 'wide', + !collapse && 'no-hide', + stickyMode !== 'static' && `sticky-${stickyMode}`, + ...sidebarClasses, + ], + }, + sidebarContent); + } + + const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left'); + const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right'); + const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse; + + const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'}, + html.tag('div', {id: 'image-overlay-content-container'}, [ + html.tag('a', {id: 'image-overlay-image-container'}, [ + html.tag('img', {id: 'image-overlay-image'}), + html.tag('img', {id: 'image-overlay-image-thumb'}), + ]), + html.tag('div', {id: 'image-overlay-action-container'}, [ + html.tag('div', {id: 'image-overlay-action-content-without-size'}, + language.$('releaseInfo.viewOriginalFile', { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$('releaseInfo.viewOriginalFile.link')), + })), + + html.tag('div', {id: 'image-overlay-action-content-with-size'}, [ + language.$('releaseInfo.viewOriginalFile.withSize', { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$('releaseInfo.viewOriginalFile.link')), + size: html.tag('span', + {[html.joinChildren]: ''}, + [ + html.tag('span', {id: 'image-overlay-file-size-kilobytes'}, + language.$('count.fileSize.kilobytes', { + kilobytes: html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + html.tag('span', {id: 'image-overlay-file-size-megabytes'}, + language.$('count.fileSize.megabytes', { + megabytes: html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + ]), + }), + + html.tag('span', {id: 'image-overlay-file-size-warning'}, + language.$('releaseInfo.viewOriginalFile.sizeWarning')), + ]), + ]), + ])); + + const layoutHTML = [ + navHTML, + slots.bannerPosition === 'top' && slots.banner, + slots.secondaryNav, + html.tag('div', + { + class: [ + 'layout-columns', + !collapseSidebars && 'vertical-when-thin', + (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', + (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', + !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', + sidebarLeftHTML && 'has-sidebar-left', + sidebarRightHTML && 'has-sidebar-right', + ], + }, + [ + sidebarLeftHTML, + mainHTML, + sidebarRightHTML, + ]), + slots.bannerPosition === 'bottom' && slots.banner, + footerHTML, + ].filter(Boolean).join('\n'); + + return html.tags([ + `<!DOCTYPE html>`, + html.tag('html', + { + lang: language.intlCode, + 'data-language-code': language.code, + + /* + 'data-url-key': 'localized.' + pagePath[0], + ...Object.fromEntries( + pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])), + */ + + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.root'), + }, + [ + // developersComment, + + html.tag('head', [ + html.tag('title', + (slots.showWikiNameInTitle + ? language.formatString('misc.pageTitle.withWikiName', { + title: slots.title, + wikiName: data.wikiName, + }) + : language.formatString('misc.pageTitle', { + title: slots.title, + }))), + + html.tag('meta', {charset: 'utf-8'}), + html.tag('meta', { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }), + + /* + ...( + Object.entries(meta) + .filter(([key, value]) => value) + .map(([key, value]) => html.tag('meta', {[key]: value}))), + + canonical && + html.tag('link', { + rel: 'canonical', + href: canonical, + }), + + ...( + localizedCanonical + .map(({lang, href}) => html.tag('link', { + rel: 'alternate', + hreflang: lang, + href, + }))), + + */ + + // slots.socialEmbed, + + html.tag('link', { + rel: 'stylesheet', + href: to('shared.staticFile', 'site4.css', cachebust), + }), + + html.tag('style', [ + (empty(slots.colorStyleRules) + ? relations.defaultColorStyleRules + : slots.colorStyleRules), + slots.additionalStyleRules, + ]), + + html.tag('script', { + src: to('shared.staticFile', 'lazy-loading.js', cachebust), + }), + ]), + + html.tag('body', + // {style: body.style || ''}, + [ + html.tag('div', {id: 'page-container'}, [ + // mainHTML && skippersHTML, + layoutHTML, + ]), + + // infoCardHTML, + imageOverlayHTML, + + html.tag('script', { + type: 'module', + src: to('shared.staticFile', 'client.js', cachebust), + }), + ]), + ]) + ]); + }, +}; diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js new file mode 100644 index 00000000..6cffcef4 --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLinks.js @@ -0,0 +1,32 @@ +export default { + // Returns an array with the slotted previous and next links, prepared + // for inclusion in a page's navigation bar. Include with other links + // in the nav bar and then join them all as a unit list, for example. + + extraDependencies: ['html', 'language'], + + slots: { + previousLink: {type: 'html'}, + nextLink: {type: 'html'}, + }, + + generate(slots, {html, language}) { + return [ + !html.isBlank(slots.previousLink) && + slots.previousLink.slots({ + tooltip: true, + color: false, + attributes: {id: 'previous-button'}, + content: language.$('misc.nav.previous'), + }), + + !html.isBlank(slots.nextLink) && + slots.nextLink?.slots({ + tooltip: true, + color: false, + attributes: {id: 'next-button'}, + content: language.$('misc.nav.next'), + }), + ]; + }, +}; diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js new file mode 100644 index 00000000..5a97e651 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -0,0 +1,42 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html', 'language'], + + relations(relation, contributions) { + if (empty(contributions)) { + return {}; + } + + return { + contributionLinks: + contributions + .slice(0, 4) + .map(contrib => relation('linkContribution', contrib)), + }; + }, + + slots: { + stringKey: {type: 'string'}, + + showContribution: {type: 'boolean', default: true}, + showIcons: {type: 'boolean', default: true}, + }, + + generate(relations, slots, {html, language}) { + if (!relations.contributionLinks) { + return html.blank(); + } + + return language.$(slots.stringKey, { + artists: + language.formatConjunctionList( + relations.contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + }); + }, +}; diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js new file mode 100644 index 00000000..6fdfd428 --- /dev/null +++ b/src/content/dependencies/generateSecondaryNav.js @@ -0,0 +1,19 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: {type: 'html'}, + + class: { + validate: v => v.oneOf(v.isString, v.arrayOf(v.isString)), + }, + }, + + generate(slots, {html}) { + return html.tag('nav', { + [html.onlyIfContent]: true, + id: 'secondary-nav', + class: slots.class, + }, slots.content); + }, +}; diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js new file mode 100644 index 00000000..cbd477e0 --- /dev/null +++ b/src/content/dependencies/generateStaticPage.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generatePageLayout', 'transformContent'], + + relations(relation, staticPage) { + return { + layout: relation('generatePageLayout'), + content: relation('transformContent', staticPage.content), + }; + }, + + data(staticPage) { + return { + name: staticPage.name, + stylesheet: staticPage.stylesheet, + }; + }, + + generate(data, relations) { + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + additionalStyleRules: + (data.stylesheet + ? [data.stylesheet] + : []), + + mainClasses: ['long-content'], + mainContent: relations.content, + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js new file mode 100644 index 00000000..5ea10765 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,33 @@ +export default { + extraDependencies: ['html'], + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + }, + + generate(slots, {html}) { + const hasCover = !html.isBlank(slots.cover); + + return html.tag('div', + { + class: [ + 'content-sticky-heading-container', + hasCover && 'has-cover', + ], + }, + [ + html.tag('div', {class: 'content-sticky-heading-row'}, [ + html.tag('h1', slots.title), + + hasCover && + html.tag('div', {class: 'content-sticky-heading-cover-container'}, + html.tag('div', {class: 'content-sticky-heading-cover'}, + slots.cover.slot('mode', 'thumbnail'))), + ]), + + html.tag('div', {class: 'content-sticky-subheading-row'}, + html.tag('h2', {class: 'content-sticky-subheading'})), + ]); + }, +}; diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js new file mode 100644 index 00000000..757ad2d6 --- /dev/null +++ b/src/content/dependencies/generateTrackCoverArtwork.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations(relation, track) { + return { + coverArtwork: + relation('generateCoverArtwork', + (track.hasUniqueCoverArt + ? track.artTags + : track.album.artTags)), + }; + }, + + data(track) { + return { + path: + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]), + }; + }, + + generate(data, relations) { + return relations.coverArtwork + .slots({ + path: data.path, + }); + }, +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 00000000..c4596f14 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,662 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; + +import { + sortAlbumsTracksChronologically, + sortFlashesChronologically, +} from '../../util/wiki-data.js'; + +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generateContentHeading', + 'generatePageLayout', + 'generateTrackCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'generateTrackReleaseInfo', + 'linkAlbum', + 'linkArtist', + 'linkContribution', + 'linkFlash', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + relations(relation, sprawl, track) { + const relations = {}; + const sections = relations.sections = {}; + const {album} = track; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', track.album); + + relations.colorStyleRules = + relation('generateColorStyleRules', track.color); + + relations.artistChronologyContributions = + getChronologyRelations(track, { + contributions: [...track.artistContribs, ...track.contributorContribs], + + linkArtist: artist => relation('linkArtist', artist), + linkThing: track => relation('linkTrack', track), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ]), + }); + + relations.coverArtistChronologyContributions = + getChronologyRelations(track, { + contributions: track.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ], { + getDate: albumOrTrack => albumOrTrack.coverArtDate, + }), + }), + + relations.albumLink = + relation('linkAlbum', track.album); + + relations.trackLink = + relation('linkTrack', track); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', track.album, track); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.sidebar = + relation('generateAlbumSidebar', track.album, track); + + const additionalFilesSection = additionalFiles => ({ + heading: relation('generateContentHeading'), + list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), + }); + + if (track.hasUniqueCoverArt || album.hasCoverArt) { + relations.cover = + relation('generateTrackCoverArtwork', track); + } + + // Section: Release info + + relations.releaseInfo = + relation('generateTrackReleaseInfo', track); + + // Section: Extra links + + const extra = sections.extra = {}; + + if (!empty(track.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', track.additionalFiles); + } + + // Section: Other releases + + if (!empty(track.otherReleases)) { + const otherReleases = sections.otherReleases = {}; + + otherReleases.heading = + relation('generateContentHeading'); + + otherReleases.items = + track.otherReleases.map(track => ({ + trackLink: relation('linkTrack', track), + albumLink: relation('linkAlbum', track.album), + })); + } + + // Section: Contributors + + if (!empty(track.contributorContribs)) { + const contributors = sections.contributors = {}; + + contributors.heading = + relation('generateContentHeading'); + + contributors.contributionLinks = + track.contributorContribs + .map(contrib => relation('linkContribution', contrib)); + } + + // Section: Referenced tracks + + if (!empty(track.referencedTracks)) { + const references = sections.references = {}; + + references.heading = + relation('generateContentHeading'); + + references.list = + relation('generateTrackList', track.referencedTracks); + } + + // Section: Tracks that reference + + if (!empty(track.referencedByTracks)) { + const referencedBy = sections.referencedBy = {}; + + referencedBy.heading = + relation('generateContentHeading'); + + referencedBy.list = + relation('generateTrackListDividedByGroups', + track.referencedByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Sampled tracks + + if (!empty(track.sampledTracks)) { + const samples = sections.samples = {}; + + samples.heading = + relation('generateContentHeading'); + + samples.list = + relation('generateTrackList', track.sampledTracks); + } + + // Section: Tracks that sample + + if (!empty(track.sampledByTracks)) { + const sampledBy = sections.sampledBy = {}; + + sampledBy.heading = + relation('generateContentHeading'); + + sampledBy.list = + relation('generateTrackListDividedByGroups', + track.sampledByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Flashes that feature + + if (sprawl.enableFlashesAndGames) { + const sortedFeatures = + sortFlashesChronologically( + [track, ...track.otherReleases].flatMap(track => + track.featuredInFlashes.map(flash => ({ + // These aren't going to be exposed directly, they're processed + // into the appropriate relations after this sort. + flash, track, + + // These properties are only used for the sort. + act: flash.act, + date: flash.date, + })))); + + if (!empty(sortedFeatures)) { + const flashesThatFeature = sections.flashesThatFeature = {}; + + flashesThatFeature.heading = + relation('generateContentHeading'); + + flashesThatFeature.entries = + sortedFeatures.map(({flash, track: directlyFeaturedTrack}) => + (directlyFeaturedTrack === track + ? { + flashLink: relation('linkFlash', flash), + } + : { + flashLink: relation('linkFlash', flash), + trackLink: relation('linkTrack', directlyFeaturedTrack), + })); + } + } + + // Section: Lyrics + + if (track.lyrics) { + const lyrics = sections.lyrics = {}; + + lyrics.heading = + relation('generateContentHeading'); + + lyrics.content = + relation('transformContent', track.lyrics); + } + + // Sections: Sheet music files, MIDI/proejct files, additional files + + if (!empty(track.sheetMusicFiles)) { + sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles); + } + + if (!empty(track.midiProjectFiles)) { + sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles); + } + + if (!empty(track.additionalFiles)) { + sections.additionalFiles = additionalFilesSection(track.additionalFiles); + } + + // Section: Artist commentary + + if (track.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', track.commentary); + } + + return relations; + }, + + data(sprawl, track) { + return { + name: track.name, + + hasTrackNumbers: track.album.hasTrackNumbers, + trackNumber: track.album.tracks.indexOf(track) + 1, + + numAdditionalFiles: track.additionalFiles.length, + }; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('trackPage.title', {track: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: + (relations.cover + ? relations.cover.slots({ + alt: language.$('misc.alt.trackCover'), + }) + : null), + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + sec.sheetMusicFiles && + language.$('releaseInfo.sheetMusicFiles.shortcut', { + link: html.tag('a', + {href: '#sheet-music-files'}, + language.$('releaseInfo.sheetMusicFiles.shortcut.link')), + }), + + sec.midiProjectFiles && + language.$('releaseInfo.midiProjectFiles.shortcut', { + link: html.tag('a', + {href: '#midi-project-files'}, + language.$('releaseInfo.midiProjectFiles.shortcut.link')), + }), + + sec.additionalFiles && + sec.extra.additionalFilesShortcut, + + sec.artistCommentary && + language.$('releaseInfo.readCommentary', { + link: html.tag('a', + {href: '#artist-commentary'}, + language.$('releaseInfo.readCommentary.link')), + }), + ]), + + sec.otherReleases && [ + sec.otherReleases.heading + .slots({ + id: 'also-released-as', + title: language.$('releaseInfo.alsoReleasedAs'), + }), + + html.tag('ul', + sec.otherReleases.items.map(({trackLink, albumLink}) => + html.tag('li', + language.$('releaseInfo.alsoReleasedAs.item', { + track: trackLink, + album: albumLink, + })))), + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + html.tag('ul', + sec.contributors.contributionLinks.map(contributionLink => + html.tag('li', + contributionLink + .slots({ + showIcons: true, + showContribution: true, + })))), + ], + + sec.references && [ + sec.references.heading + .slots({ + id: 'references', + title: + language.$('releaseInfo.tracksReferenced', { + track: html.tag('i', data.name), + }), + }), + + sec.references.list, + ], + + sec.referencedBy && [ + sec.referencedBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatReference', { + track: html.tag('i', data.name), + }), + }), + + sec.referencedBy.list, + ], + + sec.samples && [ + sec.samples.heading + .slots({ + id: 'samples', + title: + language.$('releaseInfo.tracksSampled', { + track: html.tag('i', data.name), + }), + }), + + sec.samples.list, + ], + + sec.sampledBy && [ + sec.sampledBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatSample', { + track: html.tag('i', data.name), + }), + }), + + sec.sampledBy.list, + ], + + sec.flashesThatFeature && [ + sec.flashesThatFeature.heading + .slots({ + id: 'featured-in', + title: + language.$('releaseInfo.flashesThatFeature', { + track: html.tag('i', data.name), + }), + }), + + html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) => + (trackLink + ? html.tag('li', {class: 'rerelease'}, + language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { + flash: flashLink, + track: trackLink, + })) + : html.tag('li', + language.$('releaseInfo.flashesThatFeature.item', { + flash: flashLink, + }))))), + ], + + sec.lyrics && [ + sec.lyrics.heading + .slots({ + id: 'lyrics', + title: language.$('releaseInfo.lyrics'), + }), + + html.tag('blockquote', + sec.lyrics.content + .slot('mode', 'lyrics')), + ], + + sec.sheetMusicFiles && [ + sec.sheetMusicFiles.heading + .slots({ + id: 'sheet-music-files', + title: language.$('releaseInfo.sheetMusicFiles.heading'), + }), + + sec.sheetMusicFiles.list, + ], + + sec.midiProjectFiles && [ + sec.midiProjectFiles.heading + .slots({ + id: 'midi-project-files', + title: language.$('releaseInfo.midiProjectFiles.heading'), + }), + + sec.midiProjectFiles.list, + ], + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.list, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.albumLink}, + { + html: + (data.hasTrackNumbers + ? language.$('trackPage.nav.track.withNumber', { + number: data.trackNumber, + track: relations.trackLink + .slot('attributes', {class: 'current'}), + }) + : language.$('trackPage.nav.track', { + track: relations.trackLink + .slot('attributes', {class: 'current'}), + })), + }, + ], + + navBottomRowContent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + }), + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.track', + contributions: relations.artistChronologyContributions, + }, + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + ...relations.sidebar, + }); + }, +}; + +/* + const data = { + type: 'data', + path: ['track', track.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForTrack, + serializeLink, + }) => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate, + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artistsContribs: serializeContribs(track.artistContribs), + contributorContribs: serializeContribs(track.contributorContribs), + coverArtistContribs: serializeContribs(track.coverArtistContribs || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map((track) => ({ + track: serializeLink(track), + album: serializeLink(track.album), + })), + }), + }; + + const getSocialEmbedDescription = ({ + getArtistString: _getArtistString, + language, + }) => { + const hasArtists = !empty(track.artistContribs); + const hasCoverArtists = !empty(track.coverArtistContribs); + const getArtistString = (contribs) => + _getArtistString(contribs, { + // We don't want to put actual HTML tags in social embeds (sadly + // they don't get parsed and displayed, generally speaking), so + // override the link argument so that artist "links" just show + // their names. + link: {artist: (artist) => artist.name}, + }); + if (!hasArtists && !hasCoverArtists) return ''; + return language.formatString( + 'trackPage.socialEmbed.body' + + [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] + .filter(Boolean) + .join(''), + Object.fromEntries( + [ + hasArtists && ['artists', getArtistString(track.artistContribs)], + hasCoverArtists && [ + 'coverArtists', + getArtistString(track.coverArtistContribs), + ], + ].filter(Boolean) + ) + ); + }; + + const page = { + page: () => { + return { + title: language.$('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + + themeColor: track.color, + theme: + getThemeString(track.color, { + additionalVariables: [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ] + }), + + socialEmbed: { + heading: language.$('trackPage.socialEmbed.heading', { + album: track.album.name, + }), + headingLink: absoluteTo('localized.album', album.directory), + title: language.$('trackPage.socialEmbed.title', { + track: track.name, + }), + description: getSocialEmbedDescription({getArtistString, language}), + image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), + color: track.color, + }, + + secondaryNav: generateAlbumSecondaryNav(album, track, { + getLinkThemeString, + html, + language, + link, + }), + }; + }, + }; +*/ diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js new file mode 100644 index 00000000..d0f14618 --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,49 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkTrack', 'linkContribution'], + + extraDependencies: ['html', 'language'], + + relations(relation, tracks) { + if (empty(tracks)) { + return {}; + } + + return { + items: tracks.map(track => ({ + trackLink: + relation('linkTrack', track), + + contributionLinks: + track.artistContribs + .map(contrib => relation('linkContribution', contrib)), + })), + }; + }, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + generate(relations, slots, {html, language}) { + return html.tag('ul', + relations.items.map(({trackLink, contributionLinks}) => + html.tag('li', + language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))); + }, +}; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js new file mode 100644 index 00000000..1f1ebef8 --- /dev/null +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -0,0 +1,53 @@ +import {empty} from '../../util/sugar.js'; + +import groupTracksByGroup from '../util/groupTracksByGroup.js'; + +export default { + contentDependencies: ['generateTrackList', 'linkGroup'], + extraDependencies: ['html', 'language'], + + relations(relation, tracks, groups) { + if (empty(tracks)) { + return {}; + } + + if (empty(groups)) { + return { + flatList: + relation('generateTrackList', tracks), + }; + } + + const lists = groupTracksByGroup(tracks, groups); + + return { + groupedLists: + Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({ + ...(groupOrOther === 'other' + ? {other: true} + : {groupLink: relation('linkGroup', groupOrOther)}), + + list: + relation('generateTrackList', tracks), + })), + }; + }, + + generate(relations, {html, language}) { + if (relations.flatList) { + return relations.flatList; + } + + return html.tag('dl', + relations.groupedLists.map(({other, groupLink, list}) => [ + html.tag('dt', + (other + ? language.$('trackList.group.fromOther') + : language.$('trackList.group', { + group: groupLink + }))), + + html.tag('dd', list), + ])); + }, +}; diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js new file mode 100644 index 00000000..2ac20388 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -0,0 +1,87 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, track) { + const relations = {}; + + relations.artistContributionLinks = + relation('generateReleaseInfoContributionsLine', track.artistContribs); + + if (track.hasUniqueCoverArt) { + relations.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', track.coverArtistContribs); + } + + if (!empty(track.urls)) { + relations.externalLinks = + track.urls.map(url => + relation('linkExternal', url)); + } + + return relations; + }, + + data(track) { + const data = {}; + + data.name = track.name; + data.date = track.date; + data.duration = track.duration; + + if ( + track.hasUniqueCoverArt && + track.coverArtDate && + +track.coverArtDate !== +track.date + ) { + data.coverArtDate = track.coverArtDate; + } + + return data; + }, + + generate(data, relations, {html, language}) { + return html.tags([ + html.tag('p', { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, [ + relations.artistContributionLinks + .slots({stringKey: 'releaseInfo.by'}), + + relations.coverArtistContributionsLine + ?.slots({stringKey: 'releaseInfo.coverArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: language.formatDuration(data.duration), + }), + ]), + + html.tag('p', + (relations.externalLinks + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(relations.externalLinks), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + ]); + }, +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js new file mode 100644 index 00000000..2fbe1188 --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,204 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'getSizeOfImageFile', + 'html', + 'language', + 'thumb', + 'to', + ], + + data(artTags) { + const data = {}; + + if (artTags) { + data.contentWarnings = + artTags + .filter(tag => tag.isContentWarning) + .map(tag => tag.name); + } else { + data.contentWarnings = null; + } + + return data; + }, + + slots: { + src: {type: 'string'}, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + thumb: {type: 'string'}, + + reveal: {type: 'boolean', default: true}, + link: {type: 'boolean', default: false}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + + id: {type: 'string'}, + class: {type: 'string'}, + alt: {type: 'string'}, + width: {type: 'number'}, + height: {type: 'number'}, + + missingSourceContent: {type: 'html'}, + }, + + generate(data, slots, { + getSizeOfImageFile, + html, + language, + thumb, + to, + }) { + let originalSrc; + + if (slots.src) { + originalSrc = slots.src; + } else if (!empty(slots.path)) { + originalSrc = to(...slots.path); + } else { + originalSrc = ''; + } + + const thumbSrc = + originalSrc && + (slots.thumb + ? thumb[slots.thumb](originalSrc) + : originalSrc); + + const willLink = typeof slots.link === 'string' || slots.link; + + const willReveal = + slots.reveal && + originalSrc && + !empty(data.contentWarnings); + + const willSquare = slots.square; + + const idOnImg = willLink ? null : slots.id; + const idOnLink = willLink ? slots.id : null; + const classOnImg = willLink ? null : slots.class; + const classOnLink = willLink ? slots.class : null; + + if (!originalSrc) { + return prepare( + html.tag('div', {class: 'image-text-area'}, + slots.missingSourceContent)); + } + + let fileSize = null; + if (willLink) { + const mediaRoot = to('media.root'); + if (originalSrc.startsWith(mediaRoot)) { + fileSize = + getSizeOfImageFile( + originalSrc + .slice(mediaRoot.length) + .replace(/^\//, '')); + } + } + + let reveal = null; + if (willReveal) { + reveal = [ + language.$('misc.contentWarnings', { + warnings: language.formatUnitList(data.contentWarnings), + }), + html.tag('br'), + html.tag('span', {class: 'reveal-interaction'}, + language.$('misc.contentWarnings.reveal')), + ]; + } + + const imgAttributes = { + id: idOnImg, + class: classOnImg, + alt: slots.alt, + width: slots.width, + height: slots.height, + 'data-original-size': fileSize, + }; + + const nonlazyHTML = + originalSrc && + prepare( + html.tag('img', { + ...imgAttributes, + src: thumbSrc, + })); + + if (slots.lazy) { + return html.tags([ + html.tag('noscript', nonlazyHTML), + prepare( + html.tag('img', + { + ...imgAttributes, + class: 'lazy', + 'data-original': thumbSrc, + }), + true), + ]); + } + + return nonlazyHTML; + + function prepare(content, hide = false) { + let wrapped = content; + + wrapped = + html.tag('div', {class: 'image-container'}, + html.tag('div', {class: 'image-inner-area'}, + wrapped)); + + if (willReveal) { + wrapped = + html.tag('div', {class: 'reveal'}, [ + wrapped, + html.tag('span', {class: 'reveal-text-container'}, + html.tag('span', {class: 'reveal-text'}, + reveal)), + ]); + } + + if (willSquare) { + wrapped = + html.tag('div', + { + class: [ + 'square', + hide && !willLink && 'js-hide' + ], + }, + + html.tag('div', {class: 'square-content'}, + wrapped)); + } + + if (willLink) { + wrapped = html.tag('a', + { + id: idOnLink, + class: [ + 'box', + 'image-link', + hide && 'js-hide', + classOnLink, + ], + + href: + (typeof slots.link === 'string' + ? slots.link + : originalSrc), + }, + wrapped); + } + + return wrapped; + } + }, +}; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js new file mode 100644 index 00000000..36cd27fc --- /dev/null +++ b/src/content/dependencies/index.js @@ -0,0 +1,255 @@ +import chokidar from 'chokidar'; +import {ESLint} from 'eslint'; + +import EventEmitter from 'node:events'; +import {readdir} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import contentFunction, {ContentFunctionSpecError} from '../../content-function.js'; +import {color, logWarn} from '../../util/cli.js'; +import {annotateFunction} from '../../util/sugar.js'; + +function cachebust(filePath) { + if (filePath in cachebust.cache) { + cachebust.cache[filePath] += 1; + return `${filePath}?cachebust${cachebust.cache[filePath]}`; + } else { + cachebust.cache[filePath] = 0; + return filePath; + } +} + +cachebust.cache = Object.create(null); + +export function watchContentDependencies({ + mock = null, + logging = true, +} = {}) { + const events = new EventEmitter(); + const contentDependencies = {}; + + let emittedReady = false; + let allDependenciesFulfilled = false; + let closed = false; + + let _close = () => {}; + + Object.assign(events, { + contentDependencies, + close, + }); + + const eslint = new ESLint(); + + const metaPath = fileURLToPath(import.meta.url); + const metaDirname = path.dirname(metaPath); + const watchPath = metaDirname; + + const mockKeys = new Set(); + if (mock) { + const errors = []; + + for (const [functionName, spec] of Object.entries(mock)) { + mockKeys.add(functionName); + try { + const fn = processFunctionSpec(functionName, spec); + contentDependencies[functionName] = fn; + } catch (error) { + error.message = `(${functionName}) ${error.message}`; + errors.push(error); + } + } + + if (errors.length) { + throw new AggregateError(errors, `Errors processing mocked content functions`); + } + } + + // Chokidar's 'ready' event is supposed to only fire once an 'add' event + // has been fired for everything in the watched directory, but it's not + // totally reliable. https://github.com/paulmillr/chokidar/issues/1011 + // + // Workaround here is to readdir for the names of all dependencies ourselves, + // and enter null for each into the contentDependencies object. We'll emit + // 'ready' ourselves only once no nulls remain. And we won't actually start + // watching until the readdir is done and nulls are entered (so we don't + // prematurely find out there aren't any nulls - before the nulls have + // been entered at all!). + + readdir(metaDirname).then(files => { + if (closed) { + return; + } + + const filePaths = files.map(file => path.join(metaDirname, file)); + for (const filePath of filePaths) { + if (filePath === metaPath) continue; + const functionName = getFunctionName(filePath); + if (!isMocked(functionName)) { + contentDependencies[functionName] = null; + } + } + + const watcher = chokidar.watch(metaDirname); + + watcher.on('all', (event, filePath) => { + if (!['add', 'change'].includes(event)) return; + if (filePath === metaPath) return; + handlePathUpdated(filePath); + + }); + + watcher.on('unlink', (filePath) => { + if (filePath === metaPath) { + console.error(`Yeowzers content dependencies just got nuked.`); + return; + } + + handlePathRemoved(filePath); + }); + + _close = () => watcher.close(); + }); + + return events; + + async function close() { + closed = true; + return _close(); + } + + function checkReadyConditions() { + if (emittedReady) return; + if (Object.values(contentDependencies).includes(null)) return; + + events.emit('ready'); + emittedReady = true; + } + + function getFunctionName(filePath) { + const shortPath = path.basename(filePath); + const functionName = shortPath.slice(0, -path.extname(shortPath).length); + return functionName; + } + + function isMocked(functionName) { + return mockKeys.has(functionName); + } + + async function handlePathRemoved(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + delete contentDependencies[functionName]; + } + + async function handlePathUpdated(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + let error = null; + + main: { + const eslintResults = await eslint.lintFiles([filePath]); + const eslintFormatter = await eslint.loadFormatter('stylish'); + const eslintResultText = eslintFormatter.format(eslintResults); + if (eslintResultText.trim().length) { + console.log(eslintResultText); + } + + let spec; + try { + spec = (await import(cachebust(filePath))).default; + } catch (caughtError) { + error = caughtError; + error.message = `Error importing: ${error.message}`; + break main; + } + + // Just skip newly created files. They'll be processed again when + // written. + if (spec === undefined) { + contentDependencies[functionName] = null; + return; + } + + let fn; + try { + fn = processFunctionSpec(functionName, spec); + } catch (caughtError) { + error = caughtError; + break main; + } + + if (logging && emittedReady) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(color.green(`[${timestamp}] Updated ${functionName}`)); + } + + contentDependencies[functionName] = fn; + + events.emit('update', functionName); + checkReadyConditions(); + } + + if (!error) { + return true; + } + + if (!(functionName in contentDependencies)) { + contentDependencies[functionName] = null; + } + + events.emit('error', functionName, error); + + if (logging) { + if (contentDependencies[functionName]) { + logWarn`Failed to import ${functionName} - using existing version`; + } else { + logWarn`Failed to import ${functionName} - no prior version loaded`; + } + + if (typeof error === 'string') { + console.error(color.yellow(error)); + } else if (error instanceof ContentFunctionSpecError) { + console.error(color.yellow(error.message)); + } else { + console.error(error); + } + } + + return false; + } + + function processFunctionSpec(functionName, spec) { + if (typeof spec?.data === 'function') { + annotateFunction(spec.data, {name: functionName, description: 'data'}); + } + + if (typeof spec?.generate === 'function') { + annotateFunction(spec.generate, {name: functionName}); + } + + return contentFunction(spec); + } +} + +export function quickLoadContentDependencies(opts) { + return new Promise((resolve, reject) => { + const watcher = watchContentDependencies(opts); + + watcher.on('error', (name, error) => { + watcher.close().then(() => { + error.message = `Error loading dependency ${name}: ${error}`; + reject(error); + }); + }); + + watcher.on('ready', () => { + watcher.close().then(() => { + resolve(watcher.contentDependencies); + }); + }); + }); +} diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js new file mode 100644 index 00000000..36b0d13a --- /dev/null +++ b/src/content/dependencies/linkAlbum.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.album', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js new file mode 100644 index 00000000..39e7111e --- /dev/null +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -0,0 +1,24 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(album, file) { + return { + albumDirectory: album.directory, + file, + }; + }, + + generate(data, relations) { + return relations.linkTemplate + .slots({ + path: ['media.albumAdditionalFile', data.albumDirectory, data.file], + content: data.file, + }); + }, +}; diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js new file mode 100644 index 00000000..ab519fd6 --- /dev/null +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumCommentary', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js new file mode 100644 index 00000000..e3f30a29 --- /dev/null +++ b/src/content/dependencies/linkAlbumGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumGallery', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js new file mode 100644 index 00000000..7ddb7786 --- /dev/null +++ b/src/content/dependencies/linkArtTag.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.tag', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js new file mode 100644 index 00000000..718ee6fa --- /dev/null +++ b/src/content/dependencies/linkArtist.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artist', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js new file mode 100644 index 00000000..66dc172d --- /dev/null +++ b/src/content/dependencies/linkArtistGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistGallery', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js new file mode 100644 index 00000000..f4c05388 --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,72 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkExternalAsIcon', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, contribution) { + const relations = {}; + + relations.artistLink = + relation('linkArtist', contribution.who); + + if (!empty(contribution.who.urls)) { + relations.artistIcons = + contribution.who.urls + .slice(0, 4) + .map(url => relation('linkExternalAsIcon', url)); + } + + return relations; + }, + + data(contribution) { + return { + what: contribution.what, + }; + }, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + generate(data, relations, slots, {html, language}) { + const hasContributionPart = !!(slots.showContribution && data.what); + const hasExternalPart = !!(slots.showIcons && relations.artistIcons); + + const externalLinks = hasExternalPart && + html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'icons'}, + language.formatUnitList(relations.artistIcons)); + + const parts = ['misc.artistLink']; + const options = {artist: relations.artistLink}; + + if (hasContributionPart) { + parts.push('withContribution'); + options.contrib = data.what; + } + + if (hasExternalPart) { + parts.push('withExternalLinks'); + options.links = externalLinks; + } + + const content = language.formatString(parts.join('.'), options); + + return ( + (parts.length > 1 + ? html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'nowrap'}, + content) + : content)); + }, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 00000000..7c3d86a8 --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,90 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language'], + + data(url) { + return {url}; + }, + + slots: { + mode: { + validate: v => v.is('generic', 'album'), + default: 'generic', + }, + }, + + generate(data, slots, {html, language}) { + let isLocal; + let domain; + try { + domain = new URL(data.url).hostname; + } catch (error) { + // No support for relative local URLs yet, sorry! (I.e, local URLs must + // be absolute relative to the domain name in order to work.) + isLocal = true; + } + + const a = html.tag('a', + { + href: data.url, + class: 'nowrap', + }, + + // truly unhinged indentation here + isLocal + ? language.$('misc.external.local') + + : domain.includes('bandcamp.com') + ? language.$('misc.external.bandcamp') + + : BANDCAMP_DOMAINS.includes(domain) + ? language.$('misc.external.bandcamp.domain', {domain}) + + : MASTODON_DOMAINS.includes(domain) + ? language.$('misc.external.mastodon.domain', {domain}) + + : domain.includes('youtu') + ? slots.mode === 'album' + ? data.url.includes('list=') + ? language.$('misc.external.youtube.playlist') + : language.$('misc.external.youtube.fullAlbum') + : language.$('misc.external.youtube') + + : domain.includes('soundcloud') + ? language.$('misc.external.soundcloud') + + : domain.includes('tumblr.com') + ? language.$('misc.external.tumblr') + + : domain.includes('twitter.com') + ? language.$('misc.external.twitter') + + : domain.includes('deviantart.com') + ? language.$('misc.external.deviantart') + + : domain.includes('wikipedia.org') + ? language.$('misc.external.wikipedia') + + : domain.includes('poetryfoundation.org') + ? language.$('misc.external.poetryFoundation') + + : domain.includes('instagram.com') + ? language.$('misc.external.instagram') + + : domain.includes('patreon.com') + ? language.$('misc.external.patreon') + + : domain.includes('spotify.com') + ? language.$('misc.external.spotify') + + : domain.includes('newgrounds.com') + ? language.$('misc.external.newgrounds') + + : domain); + + return a; + } +}; diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js new file mode 100644 index 00000000..cd168992 --- /dev/null +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -0,0 +1,46 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language', 'to'], + + data(url) { + return {url}; + }, + + generate(data, {html, language, to}) { + const domain = new URL(data.url).hostname; + const [id, msg] = ( + domain.includes('bandcamp.com') + ? ['bandcamp', language.$('misc.external.bandcamp')] + : BANDCAMP_DOMAINS.includes(domain) + ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] + : MASTODON_DOMAINS.includes(domain) + ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] + : domain.includes('youtu') + ? ['youtube', language.$('misc.external.youtube')] + : domain.includes('soundcloud') + ? ['soundcloud', language.$('misc.external.soundcloud')] + : domain.includes('tumblr.com') + ? ['tumblr', language.$('misc.external.tumblr')] + : domain.includes('twitter.com') + ? ['twitter', language.$('misc.external.twitter')] + : domain.includes('deviantart.com') + ? ['deviantart', language.$('misc.external.deviantart')] + : domain.includes('instagram.com') + ? ['instagram', language.$('misc.external.bandcamp')] + : domain.includes('newgrounds.com') + ? ['newgrounds', language.$('misc.external.newgrounds')] + : ['globe', language.$('misc.external.domain', {domain})]); + + return html.tag('a', + {href: data.url, class: 'icon'}, + html.tag('svg', [ + html.tag('title', msg), + html.tag('use', { + href: to('shared.staticIcon', id), + }), + ])); + }, +}; diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js new file mode 100644 index 00000000..65158ff8 --- /dev/null +++ b/src/content/dependencies/linkExternalFlash.js @@ -0,0 +1,41 @@ +// Note: This function is seriously hard-coded for HSMusic, with custom +// presentation of links to Homestuck flashes hosted various places. + +export default { + contentDependencies: ['linkExternal'], + extraDependencies: ['html', 'language'], + + relations(relation, url) { + return { + link: relation('linkExternal', url), + }; + }, + + data(url, flash) { + return { + url, + page: flash.page, + }; + }, + + generate(data, relations, {html, language}) { + const {link} = relations; + const {url, page} = data; + + return html.tag('span', + {class: 'nowrap'}, + + url.includes('homestuck.com') + ? isNaN(Number(page)) + ? language.$('misc.external.flash.homestuck.secret', {link}) + : language.$('misc.external.flash.homestuck.page', {link, page}) + + : url.includes('bgreco.net') + ? language.$('misc.external.flash.bgreco', {link}) + + : url.includes('youtu') + ? language.$('misc.external.flash.youtube', {link}) + + : link); + }, +}; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js new file mode 100644 index 00000000..93dd5a28 --- /dev/null +++ b/src/content/dependencies/linkFlash.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, flash) => + ({link: relation('linkThing', 'localized.flash', flash)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js new file mode 100644 index 00000000..ebab1b5b --- /dev/null +++ b/src/content/dependencies/linkGroup.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupInfo', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js new file mode 100644 index 00000000..ee6a3b1d --- /dev/null +++ b/src/content/dependencies/linkGroupExtra.js @@ -0,0 +1,34 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html'], + + relations(relation, group) { + const relations = {}; + + relations.info = + relation('linkGroup', group); + + if (!empty(group.albums)) { + relations.gallery = + relation('linkGroupGallery', group); + } + + return relations; + }, + + slots: { + extra: { + validate: v => v.is('gallery'), + }, + }, + + generate(relations, slots) { + return relations[slots.extra ?? 'info'] ?? relations.info; + }, +}; diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js new file mode 100644 index 00000000..86c4a0f3 --- /dev/null +++ b/src/content/dependencies/linkGroupGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupGallery', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js new file mode 100644 index 00000000..2fc516bc --- /dev/null +++ b/src/content/dependencies/linkListing.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkThing'], + extraDependencies: ['language'], + + relations: (relation, listing) => + ({link: relation('linkThing', 'localized.listing', listing)}), + + data: (listing) => + ({stringsKey: listing.stringsKey}), + + generate: (data, relations, {language}) => + relations.link + .slot('content', language.$(`listingPage.${data.stringsKey}.title`)), +}; diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js new file mode 100644 index 00000000..1bfaf46e --- /dev/null +++ b/src/content/dependencies/linkListingIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.listingIndex', + 'listingIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js new file mode 100644 index 00000000..1fb32dd9 --- /dev/null +++ b/src/content/dependencies/linkNewsEntry.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, newsEntry) => + ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js new file mode 100644 index 00000000..032af6c9 --- /dev/null +++ b/src/content/dependencies/linkStaticPage.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, staticPage) => + ({link: relation('linkThing', 'localized.staticPage', staticPage)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js new file mode 100644 index 00000000..d5506e60 --- /dev/null +++ b/src/content/dependencies/linkStationaryIndex.js @@ -0,0 +1,24 @@ +// Not to be confused with "html.Stationery". + +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['language'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, stringKey) { + return {pathKey, stringKey}; + }, + + generate(data, relations, {language}) { + return relations.linkTemplate + .slots({ + path: [data.pathKey], + content: language.formatString(data.stringKey), + }); + } +} diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js new file mode 100644 index 00000000..98e2c8b9 --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,67 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'appendIndexHTML', + 'getColors', + 'html', + 'to', + ], + + slots: { + href: {type: 'string'}, + path: {validate: v => v.validateArrayItems(v.isString)}, + hash: {type: 'string'}, + + tooltip: {validate: v => v.isString}, + attributes: {validate: v => v.isAttributes}, + color: {validate: v => v.isColor}, + content: {type: 'html'}, + }, + + generate(slots, { + appendIndexHTML, + getColors, + html, + to, + }) { + let href = slots.href; + let style; + let title; + + if (!href && !empty(slots.path)) { + href = to(...slots.path); + } + + if (appendIndexHTML) { + if ( + /^(?!https?:\/\/).+\/$/.test(href) && + href.endsWith('/') + ) { + href += 'index.html'; + } + } + + if (slots.hash) { + href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash; + } + + if (slots.color) { + const {primary, dim} = getColors(slots.color); + style = `--primary-color: ${primary}; --dim-color: ${dim}`; + } + + if (slots.tooltip) { + title = slots.tooltip; + } + + return html.tag('a', + { + ...slots.attributes ?? {}, + href, + style, + title, + }, + slots.content); + }, +} diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js new file mode 100644 index 00000000..4ebf4d76 --- /dev/null +++ b/src/content/dependencies/linkThing.js @@ -0,0 +1,84 @@ +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['html'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, thing) { + return { + pathKey, + + color: thing.color, + directory: thing.directory, + + name: thing.name, + nameShort: thing.nameShort, + }; + }, + + slots: { + content: {type: 'html'}, + + preferShortName: {type: 'boolean', default: false}, + + tooltip: { + validate: v => v.oneOf(v.isBoolean, v.isString), + default: false, + }, + + color: { + validate: v => v.oneOf(v.isBoolean, v.isColor), + default: true, + }, + + anchor: {type: 'boolean', default: false}, + + attributes: {validate: v => v.isAttributes}, + hash: {type: 'string'}, + }, + + generate(data, relations, slots, {html}) { + const path = [data.pathKey, data.directory]; + + let content = slots.content; + + const name = + (slots.preferShortName + ? data.nameShort ?? data.name + : data.name); + + if (html.isBlank(content)) { + content = name; + } + + let color = null; + if (slots.color === true) { + color = data.color ?? null; + } else if (typeof slots.color === 'string') { + color = slots.color; + } + + let tooltip = null; + if (slots.tooltip === true) { + tooltip = name; + } else if (typeof slots.tooltip === 'string') { + tooltip = slots.tooltip; + } + + return relations.linkTemplate + .slots({ + path: slots.anchor ? [] : path, + href: slots.anchor ? '' : null, + content, + color, + tooltip, + + attributes: slots.attributes, + hash: slots.hash, + }); + }, +} diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js new file mode 100644 index 00000000..d5d96726 --- /dev/null +++ b/src/content/dependencies/linkTrack.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.track', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js new file mode 100644 index 00000000..1c584282 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDate.js @@ -0,0 +1,52 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + + albums: + sortChronologically(albumData.filter(album => album.date)), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + dates: + query.albums + .map(album => album.date), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + date: data.dates, + }).map(({link, date}) => ({ + album: link, + date: language.formatDate(date), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js new file mode 100644 index 00000000..e2ff8461 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDateAdded.js @@ -0,0 +1,59 @@ +import {chunkByProperties, sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + + chunks: + chunkByProperties( + sortAlphabetically(albumData.filter(a => a.dateAddedToWiki)) + .sort((a, b) => { + if (a.dateAddedToWiki < b.dateAddedToWiki) return -1; + if (a.dateAddedToWiki > b.dateAddedToWiki) return 1; + }), + ['dateAddedToWiki']), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.chunks.map(({chunk}) => + chunk.map(album => relation('linkAlbum', album))), + }; + }, + + data(query) { + return { + dates: + query.chunks.map(({dateAddedToWiki}) => dateAddedToWiki), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + data.dates.map(date => ({ + date: language.formatDate(date), + })), + + chunkRows: + relations.albumLinks.map(albumLinks => + albumLinks.map(link => ({ + album: link, + }))), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js new file mode 100644 index 00000000..650a5d1e --- /dev/null +++ b/src/content/dependencies/listAlbumsByDuration.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = albumData.slice(); + const durations = albums.map(album => getTotalDuration(album.tracks)); + + filterByCount(albums, durations); + sortByCount(albums, durations, {greatestFirst: true}); + + return {spec, albums, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + album: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js new file mode 100644 index 00000000..c302a9cb --- /dev/null +++ b/src/content/dependencies/listAlbumsByName.js @@ -0,0 +1,50 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + albums: sortAlphabetically(albumData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + counts: + query.albums + .map(album => album.tracks.length), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + count: data.counts, + }).map(({link, count}) => ({ + album: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js new file mode 100644 index 00000000..c31609bd --- /dev/null +++ b/src/content/dependencies/listAlbumsByTracks.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = albumData.slice(); + const counts = albums.map(album => album.tracks.length); + + filterByCount(albums, counts); + sortByCount(albums, counts, {greatestFirst: true}); + + return {spec, albums, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + count: data.counts, + }).map(({link, count}) => ({ + album: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js new file mode 100644 index 00000000..eae6dd6e --- /dev/null +++ b/src/content/dependencies/listArtistsByCommentaryEntries.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = artistData.slice(); + const counts = + artists.map(artist => + artist.tracksAsCommentator.length + + artist.albumsAsCommentator.length); + + filterByCount(artists, counts); + sortByCount(artists, counts, {greatestFirst: true}); + + return {artists, counts, spec}; + }, + + relations(relation, query) { + return { + page: + relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + entries: language.countCommentaryEntries(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js new file mode 100644 index 00000000..442b8329 --- /dev/null +++ b/src/content/dependencies/listArtistsByContributions.js @@ -0,0 +1,163 @@ +import {stitchArrays, unique} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return { + artistData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, spec) { + const query = { + spec, + enableFlashesAndGames: sprawl.enableFlashesAndGames, + }; + + const queryContributionInfo = (artistsKey, countsKey, fn) => { + const artists = sprawl.artistData.slice(); + const counts = artists.map(artist => fn(artist)); + + filterByCount(artists, counts); + sortByCount(artists, counts, {greatestFirst: true}); + + query[artistsKey] = artists; + query[countsKey] = counts; + }; + + queryContributionInfo( + 'artistsByTrackContributions', + 'countsByTrackContributions', + artist => + unique([ + ...artist.tracksAsContributor, + ...artist.tracksAsArtist, + ]).length); + + queryContributionInfo( + 'artistsByArtworkContributions', + 'countsByArtworkContributions', + artist => + artist.tracksAsCoverArtist.length + + artist.albumsAsCoverArtist.length + + artist.albumsAsWallpaperArtist.length + + artist.albumsAsBannerArtist.length); + + if (sprawl.enableFlashesAndGames) { + queryContributionInfo( + 'artistsByFlashContributions', + 'countsByFlashContributions', + artist => + artist.flashesAsContributor.length); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + relations.artistLinksByTrackContributions = + query.artistsByTrackContributions + .map(artist => relation('linkArtist', artist)); + + relations.artistLinksByArtworkContributions = + query.artistsByArtworkContributions + .map(artist => relation('linkArtist', artist)); + + if (query.enableFlashesAndGames) { + relations.artistLinksByFlashContributions = + query.artistsByFlashContributions + .map(artist => relation('linkArtist', artist)); + } + + return relations; + }, + + data(query) { + const data = {}; + + data.enableFlashesAndGames = query.enableFlashesAndGames; + + data.countsByTrackContributions = query.countsByTrackContributions; + data.countsByArtworkContributions = query.countsByArtworkContributions; + + if (query.enableFlashesAndGames) { + data.countsByFlashContributions = query.countsByFlashContributions; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const lists = Object.fromEntries( + ([ + ['tracks', [ + relations.artistLinksByTrackContributions, + data.countsByTrackContributions, + 'countTracks', + ]], + + ['artworks', [ + relations.artistLinksByArtworkContributions, + data.countsByArtworkContributions, + 'countArtworks', + ]], + + data.enableFlashesAndGames && + ['flashes', [ + relations.artistLinksByFlashContributions, + data.countsByFlashContributions, + 'countFlashes', + ]], + ]).filter(Boolean) + .map(([key, [artistLinks, counts, countFunction]]) => [ + key, + html.tag('ul', + stitchArrays({ + artistLink: artistLinks, + count: counts, + }).map(({artistLink, count}) => + html.tag('li', + language.$('listingPage.listArtists.byContribs.item', { + artist: artistLink, + contributions: language[countFunction](count, {unit: true}), + })))), + ])); + + return relations.page.slots({ + type: 'custom', + content: + html.tag('div', {class: 'content-columns'}, [ + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$('listingPage.misc.trackContributors')), + + lists.tracks, + ]), + + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$( + 'listingPage.misc.artContributors')), + + lists.artworks, + + lists.flashes && [ + html.tag('h2', + language.$('listingPage.misc.flashContributors')), + + lists.flashes, + ], + ]), + ]), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js new file mode 100644 index 00000000..478e99bb --- /dev/null +++ b/src/content/dependencies/listArtistsByDuration.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = artistData.slice(); + const durations = artists.map(artist => + getTotalDuration([ + ...(artist.tracksAsArtist ?? []), + ...(artist.tracksAsContributor ?? []), + ], {originalReleasesOnly: true})); + + filterByCount(artists, durations); + sortByCount(artists, durations, {greatestFirst: true}); + + return {spec, artists, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + artist: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js new file mode 100644 index 00000000..3b9b3a51 --- /dev/null +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -0,0 +1,367 @@ +import {transposeArrays, empty, stitchArrays} from '../../util/sugar.js'; + +import { + chunkMultipleArrays, + compareCaseLessSensitive, + compareDates, + filterMultipleArrays, + reduceMultipleArrays, + sortAlphabetically, + sortMultipleArrays, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkArtist', + 'linkFlash', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return { + artistData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, spec) { + const query = { + spec, + enableFlashesAndGames: sprawl.enableFlashesAndGames, + }; + + const queryContributionInfo = ( + artistsKey, + chunkThingsKey, + datesKey, + datelessArtistsKey, + fn, + ) => { + const artists = sortAlphabetically(sprawl.artistData.slice()); + + // Each value stored in dateLists, corresponding to each artist, + // is going to be a list of dates and nulls. Any nulls represent + // a contribution which isn't associated with a particular date. + const [chunkThingLists, dateLists] = + transposeArrays(artists.map(artist => fn(artist))); + + // Scrap artists who don't even have any relevant contributions. + // These artists may still have other contributions across the wiki, but + // they weren't returned by the callback and so aren't relevant to this + // list. + filterMultipleArrays( + artists, + chunkThingLists, + dateLists, + (artists, chunkThings, dates) => !empty(dates)); + + // Also exclude artists whose remaining contributions are all dateless. + // But keep track of the artists removed here, since they'll be displayed + // in an additional list in the final listing page. + const {removed: [datelessArtists]} = + filterMultipleArrays( + artists, + chunkThingLists, + dateLists, + (artist, chunkThings, dates) => !empty(dates.filter(Boolean))); + + // Cut out dateless contributions. They're not relevant to finding the + // latest date. + for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) { + filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date); + } + + const [chunkThings, dates] = + transposeArrays( + transposeArrays([chunkThingLists, dateLists]) + .map(([chunkThings, dates]) => + reduceMultipleArrays( + chunkThings, dates, + (accChunkThing, accDate, chunkThing, date) => + (date && date > accDate + ? [chunkThing, date] + : [accChunkThing, accDate])))); + + sortMultipleArrays(artists, dates, chunkThings, + (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => { + const dateComparison = compareDates(dateA, dateB, {latestFirst: true}); + if (dateComparison !== 0) { + return dateComparison; + } + + // TODO: Compare alphabetically, not just by directory. + return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory); + }); + + const chunks = + chunkMultipleArrays(artists, dates, chunkThings, + (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) => + +date !== +lastDate || chunkThing !== lastChunkThing); + + query[chunkThingsKey] = + chunks.map(([artists, dates, chunkThings]) => chunkThings[0]); + + query[datesKey] = + chunks.map(([artists, dates, chunkThings]) => dates[0]); + + query[artistsKey] = + chunks.map(([artists, dates, chunkThings]) => artists); + + query[datelessArtistsKey] = datelessArtists; + }; + + queryContributionInfo( + 'artistsByTrackContributions', + 'albumsByTrackContributions', + 'datesByTrackContributions', + 'datelessArtistsByTrackContributions', + artist => { + const tracks = + [...artist.tracksAsArtist, ...artist.tracksAsContributor] + .filter(track => !track.originalReleaseTrack); + + const albums = tracks.map(track => track.album); + const dates = tracks.map(track => track.date); + + return [albums, dates]; + }); + + queryContributionInfo( + 'artistsByArtworkContributions', + 'albumsByArtworkContributions', + 'datesByArtworkContributions', + 'datelessArtistsByArtworkContributions', + artist => [ + [ + ...artist.tracksAsCoverArtist.map(track => track.album), + ...artist.albumsAsCoverArtist, + ...artist.albumsAsWallpaperArtist, + ...artist.albumsAsBannerArtist, + ], + [ + // TODO: Per-artwork dates, see #90. + ...artist.tracksAsCoverArtist.map(track => track.coverArtDate), + ...artist.albumsAsCoverArtist.map(album => album.coverArtDate), + ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate), + ...artist.albumsAsBannerArtist.map(album => album.coverArtDate), + ], + ]); + + if (sprawl.enableFlashesAndGames) { + queryContributionInfo( + 'artistsByFlashContributions', + 'flashesByFlashContributions', + 'datesByFlashContributions', + 'datelessArtistsByFlashContributions', + artist => [ + [ + ...artist.flashesAsContributor, + ], + [ + ...artist.flashesAsContributor.map(flash => flash.date), + ], + ]); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + // Track contributors + + relations.albumLinksByTrackContributions = + query.albumsByTrackContributions + .map(album => relation('linkAlbum', album)); + + relations.artistLinksByTrackContributions = + query.artistsByTrackContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByTrackContributions = + query.datelessArtistsByTrackContributions + .map(artist => relation('linkArtist', artist)); + + // Artwork contributors + + relations.albumLinksByArtworkContributions = + query.albumsByArtworkContributions + .map(album => relation('linkAlbum', album)); + + relations.artistLinksByArtworkContributions = + query.artistsByArtworkContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByArtworkContributions = + query.datelessArtistsByArtworkContributions + .map(artist => relation('linkArtist', artist)); + + // Flash contributors + + if (query.enableFlashesAndGames) { + relations.flashLinksByFlashContributions = + query.flashesByFlashContributions + .map(flash => relation('linkFlash', flash)); + + relations.artistLinksByFlashContributions = + query.artistsByFlashContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByFlashContributions = + query.datelessArtistsByFlashContributions + .map(artist => relation('linkArtist', artist)); + } + + return relations; + }, + + data(query) { + const data = {}; + + data.enableFlashesAndGames = query.enableFlashesAndGames; + + data.datesByTrackContributions = query.datesByTrackContributions; + data.datesByArtworkContributions = query.datesByArtworkContributions; + + if (query.enableFlashesAndGames) { + data.datesByFlashContributions = query.datesByFlashContributions; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const chunkTitles = Object.fromEntries( + ([ + ['tracks', [ + 'album', + relations.albumLinksByTrackContributions, + data.datesByTrackContributions, + ]], + + ['artworks', [ + 'album', + relations.albumLinksByArtworkContributions, + data.datesByArtworkContributions, + ]], + + data.enableFlashesAndGames && + ['flashes', [ + 'flash', + relations.flashLinksByFlashContributions, + data.datesByFlashContributions, + ]], + ]).filter(Boolean) + .map(([key, [stringsKey, links, dates]]) => [ + key, + stitchArrays({link: links, date: dates}) + .map(({link, date}) => + html.tag('dt', + language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, { + [stringsKey]: link, + date: language.formatDate(date), + }))), + ])); + + const chunkItems = Object.fromEntries( + ([ + ['tracks', relations.artistLinksByTrackContributions], + ['artworks', relations.artistLinksByArtworkContributions], + data.enableFlashesAndGames && + ['flashes', relations.artistLinksByFlashContributions], + ]).filter(Boolean) + .map(([key, artistLinkLists]) => [ + key, + artistLinkLists.map(artistLinks => + html.tag('dd', + html.tag('ul', + artistLinks.map(artistLink => + html.tag('li', + language.$('listingPage.listArtists.byLatest.chunk.item', { + artist: artistLink, + })))))), + ])); + + const lists = Object.fromEntries( + ([ + ['tracks', [ + chunkTitles.tracks, + chunkItems.tracks, + relations.datelessArtistLinksByTrackContributions, + ]], + + ['artworks', [ + chunkTitles.artworks, + chunkItems.artworks, + relations.datelessArtistLinksByArtworkContributions, + ]], + + data.enableFlashesAndGames && + ['flashes', [ + chunkTitles.flashes, + chunkItems.flashes, + relations.datelessArtistLinksByFlashContributions, + ]], + ]).filter(Boolean) + .map(([key, [titles, items, datelessArtistLinks]]) => [ + key, + html.tags([ + html.tag('dl', + stitchArrays({ + title: titles, + items: items, + }).map(({title, items}) => [title, items])), + + !empty(datelessArtistLinks) && [ + html.tag('p', + language.$('listingPage.listArtists.byLatest.dateless.title')), + + html.tag('ul', + datelessArtistLinks.map(artistLink => + html.tag('li', + language.$('listingPage.listArtists.byLatest.dateless.item', { + artist: artistLink, + })))), + ], + ]), + ])); + + return relations.page.slots({ + type: 'custom', + content: + html.tag('div', {class: 'content-columns'}, [ + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$('listingPage.misc.trackContributors')), + + lists.tracks, + ]), + + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$( + 'listingPage.misc.artContributors')), + + lists.artworks, + + lists.flashes && [ + html.tag('h2', + language.$('listingPage.misc.flashContributors')), + + lists.flashes, + ], + ]), + ]), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js new file mode 100644 index 00000000..1b93eca8 --- /dev/null +++ b/src/content/dependencies/listArtistsByName.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + getArtistNumContributions, + sortAlphabetically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + return { + spec, + + artists: sortAlphabetically(artistData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(album => relation('linkArtist', album)), + }; + }, + + data(query) { + return { + counts: + query.artists + .map(artist => getArtistNumContributions(artist)), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js new file mode 100644 index 00000000..2235c0dd --- /dev/null +++ b/src/content/dependencies/listGroupsByAlbums.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const counts = groups.map(group => group.albums.length); + + filterByCount(groups, counts); + sortByCount(groups, counts, {greatestFirst: true}); + + return {spec, groups, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + count: data.counts, + }).map(({link, count}) => ({ + group: link, + albums: language.countAlbums(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js new file mode 100644 index 00000000..84a895f6 --- /dev/null +++ b/src/content/dependencies/listGroupsByCategory.js @@ -0,0 +1,76 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupCategoryData}) { + return {groupCategoryData}; + }, + + query({groupCategoryData}, spec) { + return { + spec, + groupCategories: groupCategoryData, + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + categoryLinks: + query.groupCategories + .map(category => relation('linkGroup', category.groups[0])), + + infoLinks: + query.groupCategories + .map(category => + category.groups + .map(group => relation('linkGroup', group))), + + galleryLinks: + query.groupCategories + .map(category => + category.groups + .map(group => relation('linkGroupGallery', group))) + }; + }, + + data(query) { + return { + categoryNames: + query.groupCategories + .map(category => category.name), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + stitchArrays({ + link: relations.categoryLinks, + name: data.categoryNames, + }).map(({link, name}) => ({ + category: link.slot('content', name), + })), + + chunkRows: + stitchArrays({ + infoLinks: relations.infoLinks, + galleryLinks: relations.galleryLinks, + }).map(({infoLinks, galleryLinks}) => + stitchArrays({ + infoLink: infoLinks, + galleryLink: galleryLinks, + }).map(({infoLink, galleryLink}) => ({ + group: infoLink, + gallery: + galleryLink + .slot('content', language.$('listingPage.listGroups.byCategory.chunk.item.gallery')), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js new file mode 100644 index 00000000..cf24a472 --- /dev/null +++ b/src/content/dependencies/listGroupsByDuration.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const durations = + groups.map(group => + getTotalDuration( + group.albums.flatMap(album => album.tracks), + {originalReleasesOnly: true})); + + filterByCount(groups, durations); + sortByCount(groups, durations, {greatestFirst: true}); + + return {spec, groups, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + group: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js new file mode 100644 index 00000000..0d2ee5c2 --- /dev/null +++ b/src/content/dependencies/listGroupsByLatestAlbum.js @@ -0,0 +1,78 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + compareDates, + filterMultipleArrays, + sortChronologically, + sortMultipleArrays, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = sortChronologically(groupData.slice()); + + const albums = + groups + .map(group => + sortChronologically( + group.albums.filter(album => album.date), + {latestFirst: true})) + .map(albums => albums[0]); + + filterMultipleArrays(groups, albums, (group, album) => album); + + const dates = albums.map(album => album.date); + + // Note: After this sort, the groups/dates arrays are misaligned with + // albums. That's OK only because we aren't doing anything further with + // the albums array. + sortMultipleArrays(groups, dates, + (groupA, groupB, dateA, dateB) => + compareDates(dateA, dateB, {latestFirst: true})); + + return {spec, groups, dates}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + dates: query.dates, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + groupLink: relations.groupLinks, + date: data.dates, + }).map(({groupLink, date}) => ({ + group: groupLink, + date: language.formatDate(date), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js new file mode 100644 index 00000000..df35937b --- /dev/null +++ b/src/content/dependencies/listGroupsByName.js @@ -0,0 +1,49 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + return { + spec, + + groups: sortAlphabetically(groupData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + infoLinks: + query.groups + .map(group => relation('linkGroup', group)), + + galleryLinks: + query.groups + .map(group => relation('linkGroupGallery', group)), + }; + }, + + generate(relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + infoLink: relations.infoLinks, + galleryLink: relations.galleryLinks, + }).map(({infoLink, galleryLink}) => ({ + group: infoLink, + gallery: + galleryLink + .slot('content', language.$('listingPage.listGroups.byName.item.gallery')), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js new file mode 100644 index 00000000..35ce153d --- /dev/null +++ b/src/content/dependencies/listGroupsByTracks.js @@ -0,0 +1,55 @@ +import {accumulateSum, stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const counts = + groups.map(group => + accumulateSum( + group.albums, + ({tracks}) => tracks.length)); + + filterByCount(groups, counts); + sortByCount(groups, counts, {greatestFirst: true}); + + return {spec, groups, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + count: data.counts, + }).map(({link, count}) => ({ + group: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js new file mode 100644 index 00000000..7010e9de --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,322 @@ +import {marked} from 'marked'; + +import {bindFind} from '../../util/find.js'; +import {parseInput} from '../../util/replacer.js'; +import {replacerSpec} from '../../util/transform-content.js'; + +const linkThingRelationMap = { + album: 'linkAlbum', + albumCommentary: 'linkAlbumCommentary', + albumGallery: 'linkAlbumGallery', + artist: 'linkArtist', + artistGallery: 'linkArtistGallery', + flash: 'linkFlash', + groupInfo: 'linkGroup', + groupGallery: 'linkGroupGallery', + listing: 'linkListing', + newsEntry: 'linkNewsEntry', + staticPage: 'linkStaticPage', + tag: 'linkArtTag', + track: 'linkTrack', +}; + +const linkValueRelationMap = { + // media: 'linkPathFromMedia', + // root: 'linkPathFromRoot', + // site: 'linkPathFromSite', +}; + +const linkIndexRelationMap = { + // commentaryIndex: 'linkCommentaryIndex', + // flashIndex: 'linkFlashIndex', + // home: 'linkHome', + // listingIndex: 'linkListingIndex', + // newsIndex: 'linkNewsIndex', +}; + +function getPlaceholder(node, content) { + return {type: 'text', data: content.slice(node.i, node.iEnd)}; +} + +export default { + contentDependencies: [ + ...Object.values(linkThingRelationMap), + ...Object.values(linkValueRelationMap), + ...Object.values(linkIndexRelationMap), + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl(wikiData, content) { + const find = bindFind(wikiData); + + const parsedNodes = parseInput(content); + + return { + nodes: parsedNodes + .map(node => { + if (node.type !== 'tag') { + return node; + } + + const placeholder = getPlaceholder(node, content); + + const replacerKeyImplied = !node.data.replacerKey; + const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data; + + // TODO: We don't support recursive nodes like before, at the moment. Sorry! + // const replacerValue = transformNodes(node.data.replacerValue, opts); + const replacerValue = node.data.replacerValue[0].data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return placeholder; + } + + if (spec.link) { + let data = {key: spec.link}; + + determineData: { + // No value at all: this is an index link. + if (!replacerValue) { + break determineData; + } + + // Nothing to find: the link operates on a path or string, not a data object. + if (!spec.find) { + data.value = replacerValue; + break determineData; + } + + const thing = + find[spec.find]( + (replacerKeyImplied + ? replacerValue + : replacerKey + `:` + replacerValue), + wikiData); + + // Nothing was found: this is unexpected, so return placeholder. + if (!thing) { + return placeholder; + } + + // Something was found: the link operates on that thing. + data.thing = thing; + } + + const {transformName} = spec; + + // TODO: Again, no recursive nodes. Sorry! + // const enteredLabel = node.data.label && transformNode(node.data.label, opts); + const enteredLabel = node.data.label?.data; + const enteredHash = node.data.hash?.data; + + data.label = + enteredLabel ?? + (transformName && data.thing.name + ? transformName(data.thing.name, node, content) + : null); + + data.hash = enteredHash ?? null; + + return {i: node.i, iEnd: node.iEnd, type: 'link', data}; + } + + // This will be another {type: 'tag'} node which gets processed in + // generate. + return node; + }), + }; + }, + + data(sprawl, content) { + return { + content, + + nodes: + sprawl.nodes + .map(node => { + // Replace link nodes with a stub. It'll be replaced (by position) + // with an item from relations. + if (node.type === 'link') { + return {type: 'link'}; + } + + // Other nodes will get processed in generate. + return node; + }), + }; + }, + + relations(relation, sprawl, content) { + const {nodes} = sprawl; + + const relationOrPlaceholder = + (node, name, arg) => + (name + ? { + link: relation(name, arg), + label: node.data.label, + hash: node.data.hash, + } + : getPlaceholder(node, content)); + + return { + links: + nodes + .filter(({type}) => type === 'link') + .map(node => { + const {key, thing, value} = node.data; + + if (thing) { + return relationOrPlaceholder(node, linkThingRelationMap[key], thing); + } else if (value) { + return relationOrPlaceholder(node, linkValueRelationMap[key], value); + } else { + return relationOrPlaceholder(node, linkIndexRelationMap[key]); + } + }), + }; + }, + + slots: { + mode: { + validate: v => v.is('inline', 'multiline', 'lyrics'), + default: 'multiline', + }, + }, + + generate(data, relations, slots, {html, language}) { + let linkIndex = 0; + + // This array contains only straight text and link nodes, which are directly + // representable in html (so no further processing is needed on the level of + // individual nodes). + const contentFromNodes = + data.nodes.map(node => { + if (node.type === 'text') { + return {type: 'text', data: node.data}; + } + + if (node.type === 'link') { + const linkNode = relations.links[linkIndex++]; + if (linkNode.type === 'text') { + return {type: 'text', data: linkNode.data}; + } + + const {link, label, hash} = linkNode; + + return { + type: 'link', + data: link.slots({content: label, hash}), + }; + } + + if (node.type === 'tag') { + const {replacerKey, replacerValue} = node.data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return getPlaceholder(node, data.content); + } + + const {value: valueFn, html: htmlFn} = spec; + + const value = + (valueFn + ? valueFn(replacerValue) + : replacerValue); + + const contents = + (htmlFn + ? htmlFn(value, {html, language}) + : value); + + return {type: 'text', data: contents}; + } + + return getPlaceholder(node, data.content); + }); + + // In inline mode, no further processing is needed! + + if (slots.mode === 'inline') { + return html.tags(contentFromNodes.map(node => node.data)); + } + + // Multiline mode has a secondary processing stage where it's passed... + // through marked! Rolling your own Markdown only gets you so far :D + + const markedOptions = { + headerIds: false, + mangle: false, + }; + + // 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 = + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data; + } else { + return node.data.toString(); + } + }) + .join('') + + // Compress multiple line breaks into single line breaks. + .replace(/\n{2,}/g, '\n') + // Expand line breaks which don't follow a list, quote, + // or <br> / " ". + .replace(/(?<!^ *-.*|^>.*| $|<br>$)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n') + // Expand line breaks which are at the end of a quote. + .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + + return marked.parse(markedInput, markedOptions); + } + + if (slots.mode === 'multiline') { + // Unfortunately, we kind of have to be super evil here and stringify + // the links, or else parse marked's output into html tags, which is + // very out of scope at the moment. + return transformMultiline(); + } + + // Lyrics mode goes through marked too, but line breaks are processed + // differently. Instead of having each line get its own paragraph, + // "adjacent" lines are joined together (with blank lines separating + // each verse/paragraph). + + if (slots.mode === 'lyrics') { + // If it looks like old data, using <br> instead of bunched together + // lines... then oh god... just use transformMultiline. Perishes. + if ( + contentFromNodes.some(node => + node.type === 'text' && + node.data.includes('<br')) + ) { + return transformMultiline(); + } + + // Lyrics mode is also evil for the same stringifying reasons as + // multiline. + return marked.parse( + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data.replace(/\b\n\b/g, '<br>\n'); + } else { + return node.data.toString(); + } + }) + .join(''), + markedOptions); + } + }, +} diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js new file mode 100644 index 00000000..11281e75 --- /dev/null +++ b/src/content/util/getChronologyRelations.js @@ -0,0 +1,42 @@ +export default function getChronologyRelations(thing, { + contributions, + linkArtist, + linkThing, + getThings, +}) { + // One call to getChronologyRelations is considered "lumping" together all + // contributions as carrying equivalent meaning (for example, "artist" + // contributions and "contributor" contributions are bunched together in + // one call to getChronologyRelations, while "cover artist" contributions + // are a separate call). getChronologyRelations prevents duplicates that + // carry the same meaning by only using the first instance of each artist + // in the contributions array passed to it. It's expected that the string + // identifying which kind of contribution ("track" or "cover art") is + // shared and applied to all contributions, as providing them together + // in one call to getChronologyRelations implies they carry the same + // meaning. + + const artistsSoFar = new Set(); + + contributions = contributions.filter(({who}) => { + if (artistsSoFar.has(who)) { + return false; + } else { + artistsSoFar.add(who); + return true; + } + }); + + return contributions.map(({who}) => { + const things = Array.from(new Set(getThings(who))); + const index = things.indexOf(thing); + const previous = things[index - 1]; + const next = things[index + 1]; + return { + index: index + 1, + artistLink: linkArtist(who), + previousLink: previous ? linkThing(previous) : null, + nextLink: next ? linkThing(next) : null, + }; + }); +} diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js new file mode 100644 index 00000000..559967bc --- /dev/null +++ b/src/content/util/groupTracksByGroup.js @@ -0,0 +1,23 @@ +import {empty} from '../../util/sugar.js'; + +export default function groupTracksByGroup(tracks, groups) { + const lists = new Map(groups.map(group => [group, []])); + lists.set('other', []); + + for (const track of tracks) { + const group = groups.find(group => group.albums.includes(track.album)); + if (group) { + lists.get(group).push(track); + } else { + lists.get('other').push(track); + } + } + + for (const [key, tracks] of lists.entries()) { + if (empty(tracks)) { + lists.delete(key); + } + } + + return lists; +} diff --git a/src/data/things/album.js b/src/data/things/album.js index 2a188f2d..d371f51f 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,5 +1,6 @@ import Thing from './thing.js'; +import {empty} from '../../util/sugar.js'; import find from '../../util/find.js'; export class Album extends Thing { @@ -34,12 +35,12 @@ export class Album extends Thing { update: {validate: isDate}, expose: { - dependencies: ['date', 'hasCoverArt'], + dependencies: ['date', 'coverArtistContribsByRef'], transform: (coverArtDate, { + coverArtistContribsByRef, date, - hasCoverArt, }) => - (hasCoverArt + (!empty(coverArtistContribsByRef) ? coverArtDate ?? date ?? null : null), }, @@ -103,7 +104,6 @@ export class Album extends Thing { update: {validate: isDimensions}, }, - hasCoverArt: Thing.common.flag(true), hasTrackArt: Thing.common.flag(true), hasTrackNumbers: Thing.common.flag(true), isListedOnHomepage: Thing.common.flag(true), @@ -123,18 +123,16 @@ export class Album extends Thing { artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs( - 'trackCoverArtistContribsByRef' - ), - wallpaperArtistContribs: Thing.common.dynamicContribs( - 'wallpaperArtistContribsByRef' - ), - bannerArtistContribs: Thing.common.dynamicContribs( - 'bannerArtistContribsByRef' - ), + trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), + wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), + bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), commentatorArtists: Thing.common.commentatorArtists(), + hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), + hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), + hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), + tracks: { flags: {expose: true}, diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 303f33f3..f144b21f 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -27,9 +27,8 @@ export class Artist extends Thing { aliasNames: { flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(isName), - }, + update: {validate: validateArrayItems(isName)}, + expose: {transform: (names) => names ?? []}, }, isAlias: Thing.common.flag(), diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index c18e8110..a79dd77a 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -68,10 +68,10 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { Group, validators: { + is, isCountingNumber, isString, validateArrayItems, - validateFromConstants, }, } = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), @@ -95,7 +95,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { flags: {update: true, expose: true}, update: { - validate: validateFromConstants('grid', 'carousel'), + validate: is('grid', 'carousel'), }, expose: { diff --git a/src/data/things/language.js b/src/data/things/language.js index 3086ad2e..7755c505 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -101,7 +101,7 @@ export class Language extends Thing { dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], compute({strings, inheritedStrings, escapeHTML}) { if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})}; + const allStrings = {...inheritedStrings, ...strings}; return Object.fromEntries( Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) ); @@ -252,19 +252,19 @@ export class Language extends Thing { // Conjunction list: A, B, and C formatConjunctionList(array) { this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array); + return this.intl_listConjunction.format(array.map(arr => arr.toString())); } // Disjunction lists: A, B, or C formatDisjunctionList(array) { this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array); + return this.intl_listDisjunction.format(array.map(arr => arr.toString())); } // Unit lists: A, B, C formatUnitList(array) { this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array); + return this.intl_listUnit.format(array.map(arr => arr.toString())); } // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB @@ -311,6 +311,8 @@ const countHelper = (stringKey, argName = stringKey) => Object.assign(Language.prototype, { countAdditionalFiles: countHelper('additionalFiles', 'files'), countAlbums: countHelper('albums'), + countArtworks: countHelper('artworks'), + countFlashes: countHelper('flashes'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), countCoverArts: countHelper('coverArts'), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9c59436e..5004f4e6 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -23,6 +23,7 @@ import { import {inspect} from 'util'; import {color} from '../../util/cli.js'; +import {empty} from '../../util/sugar.js'; import {getKebabCase} from '../../util/wiki-data.js'; import find from '../../util/find.js'; @@ -63,6 +64,7 @@ export default class Thing extends CacheableObject { urls: () => ({ flags: {update: true, expose: true}, update: {validate: validateArrayItems(isURL)}, + expose: {transform: (value) => value ?? []}, }), // A file extension! Or the default, if provided when calling this. @@ -312,6 +314,20 @@ export default class Thing extends CacheableObject { }, }), + // Nice 'n simple shorthand for an exposed-only flag which is true when any + // contributions are present in the specified property. + contribsPresent: (contribsByRefProperty) => ({ + flags: {expose: true}, + expose: { + dependencies: [contribsByRefProperty], + compute({ + [contribsByRefProperty]: contribsByRef, + }) { + return !empty(contribsByRef); + }, + } + }), + // Neat little shortcut for "reversing" the reference lists stored on other // things - for example, tracks specify a "referenced tracks" property, and // you would use this to compute a corresponding "referenced *by* tracks" diff --git a/src/data/things/validators.js b/src/data/things/validators.js index b116120a..14092102 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -112,7 +112,12 @@ export function isInstance(value, constructor) { } export function isDate(value) { - return isInstance(value, Date); + isInstance(value, Date); + + if (isNaN(value)) + throw new TypeError(`Expected valid date`); + + return true; } export function isObject(value) { @@ -133,6 +138,34 @@ export function isArray(value) { return true; } +// This one's shaped a bit different from other "is" functions. +// More like validate functions, it returns a function. +export function is(...values) { + if (Array.isArray(values)) { + values = new Set(values); + } + + if (values.size === 1) { + const expected = Array.from(values)[0]; + + return (value) => { + if (value !== expected) { + throw new TypeError(`Expected ${expected}, got ${value}`); + } + + return true; + }; + } + + return (value) => { + if (!values.has(value)) { + throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); + } + + return true; + }; +} + function validateArrayItemsHelper(itemValidator) { return (item, index) => { try { @@ -162,18 +195,12 @@ export function validateArrayItems(itemValidator) { }; } -export function validateInstanceOf(constructor) { - return (object) => isInstance(object, constructor); +export function arrayOf(itemValidator) { + return validateArrayItems(itemValidator); } -export function validateFromConstants(...values) { - return (value) => { - if (!values.includes(value)) { - throw new TypeError(`Expected one of ${values.join(', ')}`); - } - - return true; - }; +export function validateInstanceOf(constructor) { + return (object) => isInstance(object, constructor); } // Wiki data (primitives & non-primitives) diff --git a/src/data/yaml.js b/src/data/yaml.js index de0b506b..73450f17 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -192,7 +192,6 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { color: 'Color', urls: 'URLs', - hasCoverArt: 'Has Cover Art', hasTrackArt: 'Has Track Art', hasTrackNumbers: 'Has Track Numbers', isListedOnHomepage: 'Listed on Homepage', diff --git a/src/listing-spec.js b/src/listing-spec.js index 36637ee0..4853f812 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,8 +1,9 @@ import {OFFICIAL_GROUP_DIRECTORY} from './util/magic-constants.js'; import { - empty, accumulateSum, + empty, + showAggregate, } from './util/sugar.js'; import { @@ -20,565 +21,111 @@ const listingSpec = []; listingSpec.push({ directory: 'albums/by-name', stringsKey: 'listAlbums.byName', + contentFunction: 'listAlbumsByName', seeAlso: [ 'tracks/by-album', ], - - data: ({wikiData: {albumData}}) => - sortAlphabetically(albumData.slice()), - - row: (album, {language, link}) => - language.$('listingPage.listAlbums.byName.item', { - album: link.album(album), - tracks: language.countTracks(album.tracks.length, {unit: true}), - }), }); listingSpec.push({ directory: 'albums/by-tracks', stringsKey: 'listAlbums.byTracks', - - data: ({wikiData: {albumData}}) => - albumData.slice() - .sort((a, b) => b.tracks.length - a.tracks.length), - - row: (album, {language, link}) => - language.$('listingPage.listAlbums.byTracks.item', { - album: link.album(album), - tracks: language.countTracks(album.tracks.length, {unit: true}), - }), + contentFunction: 'listAlbumsByTracks', }); listingSpec.push({ directory: 'albums/by-duration', stringsKey: 'listAlbums.byDuration', - - data: ({wikiData: {albumData}}) => - albumData - .map(album => ({ - album, - duration: getTotalDuration(album.tracks), - })) - .filter(({duration}) => duration > 0) - .sort((a, b) => b.duration - a.duration), - - row: ({album, duration}, {language, link}) => - language.$('listingPage.listAlbums.byDuration.item', { - album: link.album(album), - duration: language.formatDuration(duration), - }), + contentFunction: 'listAlbumsByDuration', }); listingSpec.push({ directory: 'albums/by-date', stringsKey: 'listAlbums.byDate', + contentFunction: 'listAlbumsByDate', seeAlso: [ 'tracks/by-date', ], - - data: ({wikiData: {albumData}}) => - sortChronologically( - albumData - .filter(album => album.date)), - - row: (album, {language, link}) => - language.$('listingPage.listAlbums.byDate.item', { - album: link.album(album), - date: language.formatDate(album.date), - }), }); listingSpec.push({ directory: 'albums/by-date-added', stringsKey: 'listAlbums.byDateAdded', - - data: ({wikiData: {albumData}}) => - chunkByProperties( - sortAlphabetically(albumData.filter(a => a.dateAddedToWiki)) - .sort((a, b) => { - if (a.dateAddedToWiki < b.dateAddedToWiki) return -1; - if (a.dateAddedToWiki > b.dateAddedToWiki) return 1; - }), - ['dateAddedToWiki']), - - html: (data, {html, language, link}) => - html.tag('dl', - data.flatMap(({dateAddedToWiki, chunk: albums}) => [ - html.tag('dt', - {class: ['content-heading']}, - language.$('listingPage.listAlbums.byDateAdded.date', { - date: language.formatDate(dateAddedToWiki), - })), - - html.tag('dd', - html.tag('ul', - albums.map((album) => - html.tag('li', - language.$('listingPage.listAlbums.byDateAdded.album', { - album: link.album(album), - }))))), - ])), + contentFunction: 'listAlbumsByDateAdded', }); listingSpec.push({ directory: 'artists/by-name', stringsKey: 'listArtists.byName', - - data: ({wikiData: {artistData}}) => - sortAlphabetically(artistData.slice()) - .map(artist => ({ - artist, - contributions: getArtistNumContributions(artist), - })), - - row: ({artist, contributions}, {language, link}) => - language.$('listingPage.listArtists.byName.item', { - artist: link.artist(artist), - contributions: language.countContributions(contributions, { - unit: true, - }), - }), + contentFunction: 'listArtistsByName', }); listingSpec.push({ directory: 'artists/by-contribs', stringsKey: 'listArtists.byContribs', - - data: ({wikiData: {artistData, wikiInfo}}) => ({ - toTracks: artistData - .map(artist => ({ - artist, - contributions: - artist.tracksAsContributor.length + - artist.tracksAsArtist.length, - })) - .sort((a, b) => b.contributions - a.contributions) - .filter(({contributions}) => contributions), - - toArtAndFlashes: artistData - .map(artist => ({ - artist, - contributions: - artist.tracksAsCoverArtist.length + - artist.albumsAsCoverArtist.length + - artist.albumsAsWallpaperArtist.length + - artist.albumsAsBannerArtist.length + - (wikiInfo.enableFlashesAndGames - ? artist.flashesAsContributor.length - : 0), - })) - .sort((a, b) => b.contributions - a.contributions) - .filter(({contributions}) => contributions), - - // This is a kinda naughty hack, 8ut like, it's the only place - // we'd 8e passing wikiData to html() otherwise, so like.... - // (Ok we do do this again once later.) - showAsFlashes: wikiInfo.enableFlashesAndGames, - }), - - html: ( - {toTracks, toArtAndFlashes, showAsFlashes}, - {html, language, link} - ) => - html.tag('div', {class: 'content-columns'}, [ - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$('listingPage.misc.trackContributors')), - - html.tag('ul', - toTracks.map(({artist, contributions}) => - html.tag('li', - language.$('listingPage.listArtists.byContribs.item', { - artist: link.artist(artist), - contributions: language.countContributions(contributions, { - unit: true, - }), - })))), - ]), - - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$( - 'listingPage.misc' + - (showAsFlashes - ? '.artAndFlashContributors' - : '.artContributors'))), - - html.tag('ul', - toArtAndFlashes.map(({artist, contributions}) => - html.tag('li', - language.$('listingPage.listArtists.byContribs.item', { - artist: link.artist(artist), - contributions: - language.countContributions(contributions, {unit: true}), - })))), - ]), - ]), + contentFunction: 'listArtistsByContributions', }); listingSpec.push({ directory: 'artists/by-commentary', stringsKey: 'listArtists.byCommentary', - - data: ({wikiData: {artistData}}) => - artistData - .map(artist => ({ - artist, - entries: - artist.tracksAsCommentator.length + - artist.albumsAsCommentator.length, - })) - .filter(({entries}) => entries) - .sort((a, b) => b.entries - a.entries), - - row: ({artist, entries}, {language, link}) => - language.$('listingPage.listArtists.byCommentary.item', { - artist: link.artist(artist), - entries: language.countCommentaryEntries(entries, {unit: true}), - }), + contentFunction: 'listArtistsByCommentaryEntries', }); listingSpec.push({ directory: 'artists/by-duration', stringsKey: 'listArtists.byDuration', - - data: ({wikiData: {artistData}}) => - artistData - .map((artist) => ({ - artist, - duration: getTotalDuration([ - ...(artist.tracksAsArtist ?? []), - ...(artist.tracksAsContributor ?? []), - ], {originalReleasesOnly: true}), - })) - .filter(({duration}) => duration > 0) - .sort((a, b) => b.duration - a.duration), - - row: ({artist, duration}, {language, link}) => - language.$('listingPage.listArtists.byDuration.item', { - artist: link.artist(artist), - duration: language.formatDuration(duration), - }), + contentFunction: 'listArtistsByDuration', }); listingSpec.push({ directory: 'artists/by-latest', stringsKey: 'listArtists.byLatest', - - data({wikiData: { - albumData, - flashData, - trackData, - wikiInfo, - }}) { - const processContribs = values => { - const filteredValues = values - .filter(value => value.date && !empty(value.contribs)); - - const datedArtistLists = sortByDate(filteredValues) - .map(({ - contribs, - date, - }) => ({ - artists: contribs.map(({who}) => who), - date, - })); - - const remainingArtists = new Set(datedArtistLists.flatMap(({artists}) => artists)); - const artistEntries = []; - - for (let i = datedArtistLists.length - 1; i >= 0; i--) { - const {artists, date} = datedArtistLists[i]; - for (const artist of artists) { - if (!remainingArtists.has(artist)) - continue; - - remainingArtists.delete(artist); - artistEntries.push({ - artist, - date, - - // For sortChronologically! - directory: artist.directory, - name: artist.name, - }); - } - - // Early exit: If we've gotten every artist, there's no need to keep - // going. - if (remainingArtists.size === 0) - break; - } - - return sortChronologically(artistEntries, {latestFirst: true}); - }; - - // Tracks are super easy to sort because they only have one pertinent - // date: the date the track was released on. - - const toTracks = processContribs( - trackData.map(({ - artistContribs, - date, - }) => ({ - contribs: artistContribs, - date, - }))); - - // Artworks are a bit more involved because there are multiple dates - // involved - cover artists correspond to one date, wallpaper artists to - // another, etc. - - const toArtAndFlashes = processContribs([ - ...trackData.map(({ - coverArtistContribs, - coverArtDate, - }) => ({ - contribs: coverArtistContribs, - date: coverArtDate, - })), - - ...flashData - ? flashData.map(({ - contributorContribs, - date, - }) => ({ - contribs: contributorContribs, - date, - })) - : [], - - ...albumData.flatMap(({ - bannerArtistContribs, - coverArtistContribs, - coverArtDate, - date, - wallpaperArtistContribs, - }) => [ - { - contribs: coverArtistContribs, - date: coverArtDate, - }, - { - contribs: bannerArtistContribs, - date, // TODO: bannerArtDate (see issue #90) - }, - { - contribs: wallpaperArtistContribs, - date, // TODO: wallpaperArtDate (see issue #90) - }, - ]), - ]); - - return { - toArtAndFlashes, - toTracks, - - // (Ok we did it again.) - // This is a kinda naughty hack, 8ut like, it's the only place - // we'd 8e passing wikiData to html() otherwise, so like.... - showAsFlashes: wikiInfo.enableFlashesAndGames, - }; - }, - - html: ( - {toTracks, toArtAndFlashes, showAsFlashes}, - {html, language, link} - ) => - html.tag('div', {class: 'content-columns'}, [ - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$('listingPage.misc.trackContributors')), - - html.tag('ul', - toTracks.map(({artist, date}) => - html.tag('li', - language.$('listingPage.listArtists.byLatest.item', { - artist: link.artist(artist), - date: language.formatDate(date), - })))), - ]), - - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$( - 'listingPage.misc' + - (showAsFlashes - ? '.artAndFlashContributors' - : '.artContributors'))), - - html.tag('ul', - toArtAndFlashes.map(({artist, date}) => - html.tag('li', - language.$('listingPage.listArtists.byLatest.item', { - artist: link.artist(artist), - date: language.formatDate(date), - })))), - ]), - ]), + contentFunction: 'listArtistsByLatestContribution', }); listingSpec.push({ directory: 'groups/by-name', stringsKey: 'listGroups.byName', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupData}}) => - sortAlphabetically(groupData.slice()), - - row: (group, {language, link}) => - language.$('listingPage.listGroups.byCategory.group', { - group: link.groupInfo(group), - gallery: link.groupGallery(group, { - text: language.$('listingPage.listGroups.byCategory.group.gallery'), - }), - }), + contentFunction: 'listGroupsByName', + featureFlag: 'enableGroupUI', }); listingSpec.push({ directory: 'groups/by-category', stringsKey: 'listGroups.byCategory', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupCategoryData}}) => - groupCategoryData - .map(category => ({ - category, - groups: category.groups, - })), - - html: (data, {html, language, link}) => - html.tag('dl', - data.flatMap(({category, groups}) => [ - html.tag('dt', - {class: ['content-heading']}, - language.$('listingPage.listGroups.byCategory.category', { - category: empty(groups) - ? category.name - : link.groupInfo(groups[0], { - text: category.name, - }), - })), - - html.tag('dd', - empty(groups) - ? null // todo: #85 - : html.tag('ul', - category.groups.map(group => - html.tag('li', - language.$('listingPage.listGroups.byCategory.group', { - group: link.groupInfo(group), - gallery: link.groupGallery(group, { - text: language.$('listingPage.listGroups.byCategory.group.gallery'), - }), - }))))), - ])), + contentFunction: 'listGroupsByCategory', + featureFlag: 'enableGroupUI', }); listingSpec.push({ directory: 'groups/by-albums', stringsKey: 'listGroups.byAlbums', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupData}}) => - groupData - .map(group => ({ - group, - albums: group.albums.length - })) - .sort((a, b) => b.albums - a.albums), - - row: ({group, albums}, {language, link}) => - language.$('listingPage.listGroups.byAlbums.item', { - group: link.groupInfo(group), - albums: language.countAlbums(albums, {unit: true}), - }), + contentFunction: 'listGroupsByAlbums', + featureFlag: 'enableGroupUI', }); listingSpec.push({ directory: 'groups/by-tracks', stringsKey: 'listGroups.byTracks', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupData}}) => - groupData - .map((group) => ({ - group, - tracks: accumulateSum( - group.albums, - ({tracks}) => tracks.length), - })) - .sort((a, b) => b.tracks - a.tracks), - - row: ({group, tracks}, {language, link}) => - language.$('listingPage.listGroups.byTracks.item', { - group: link.groupInfo(group), - tracks: language.countTracks(tracks, {unit: true}), - }), + contentFunction: 'listGroupsByTracks', + featureFlag: 'enableGroupUI', }); listingSpec.push({ directory: 'groups/by-duration', stringsKey: 'listGroups.byDuration', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupData}}) => - groupData - .map(group => ({ - group, - duration: getTotalDuration( - group.albums.flatMap(album => album.tracks), - {originalReleasesOnly: true}), - })) - .filter(({duration}) => duration > 0) - .sort((a, b) => b.duration - a.duration), - - row: ({group, duration}, {language, link}) => - language.$('listingPage.listGroups.byDuration.item', { - group: link.groupInfo(group), - duration: language.formatDuration(duration), - }), + contentFunction: 'listGroupsByDuration', + featureFlag: 'enableGroupUI', }); listingSpec.push({ directory: 'groups/by-latest-album', stringsKey: 'listGroups.byLatest', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableGroupUI, - - data: ({wikiData: {groupData}}) => - sortChronologically( - groupData - .map(group => { - const albums = group.albums.filter(a => a.date); - return !empty(albums) && { - group, - directory: group.directory, - name: group.name, - date: albums[albums.length - 1].date, - }; - }) - .filter(Boolean), - {latestFirst: true}), - - row: ({group, date}, {language, link}) => - language.$('listingPage.listGroups.byLatest.item', { - group: link.groupInfo(group), - date: language.formatDate(date), - }), + contentFunction: 'listGroupsByLatestAlbum', + featureFlag: 'enableGroupUI', }); listingSpec.push({ @@ -737,9 +284,7 @@ listingSpec.push({ listingSpec.push({ directory: 'tracks/in-flashes/by-album', stringsKey: 'listTracks.inFlashes.byAlbum', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableFlashesAndGames, + featureFlag: 'enableFlashesAndGames', data: ({wikiData: {trackData}}) => chunkByProperties( @@ -771,9 +316,7 @@ listingSpec.push({ listingSpec.push({ directory: 'tracks/in-flashes/by-flash', stringsKey: 'listTracks.inFlashes.byFlash', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableFlashesAndGames, + featureFlag: 'enableFlashesAndGames', data: ({wikiData: {flashData}}) => sortFlashesChronologically(flashData.slice()) @@ -872,9 +415,7 @@ listingSpec.push(listTracksWithProperty('midiProjectFiles', { listingSpec.push({ directory: 'tags/by-name', stringsKey: 'listTags.byName', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableArtTagUI, + featureFlag: 'enableArtTagUI', data: ({wikiData: {artTagData}}) => sortAlphabetically( @@ -899,9 +440,7 @@ listingSpec.push({ listingSpec.push({ directory: 'tags/by-uses', stringsKey: 'listTags.byUses', - - condition: ({wikiData: {wikiInfo}}) => - wikiInfo.enableArtTagUI, + featureFlag: 'enableArtTagUI', data: ({wikiData: {artTagData}}) => artTagData @@ -1086,34 +625,75 @@ listingSpec.push({ ]), }); +{ + const errors = []; + + for (const listing of listingSpec) { + if (listing.seeAlso) { + const suberrors = []; + + for (let i = 0; i < listing.seeAlso.length; i++) { + const directory = listing.seeAlso[i]; + const match = listingSpec.find(listing => listing.directory === directory); + + if (match) { + listing.seeAlso[i] = match; + } else { + listing.seeAlso[i] = null; + suberrors.push(new Error(`(index: ${i}) Didn't find a listing matching ${directory}`)) + } + } + + listing.seeAlso = listing.seeAlso.filter(Boolean); + + if (!empty(suberrors)) { + errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`)); + } + } else { + listing.seeAlso = null; + } + } + + if (!empty(errors)) { + const aggregate = new AggregateError(errors, `Errors validating listings`); + showAggregate(aggregate, {showTraces: false}); + } +} + const filterListings = (directoryPrefix) => listingSpec.filter(l => l.directory.startsWith(directoryPrefix)); const listingTargetSpec = [ { - title: ({language}) => language.$('listingPage.target.album'), + stringsKey: 'album', listings: filterListings('album'), }, { - title: ({language}) => language.$('listingPage.target.artist'), + stringsKey: 'artist', listings: filterListings('artist'), }, { - title: ({language}) => language.$('listingPage.target.group'), + stringsKey: 'group', listings: filterListings('group'), }, { - title: ({language}) => language.$('listingPage.target.track'), + stringsKey: 'track', listings: filterListings('track'), }, { - title: ({language}) => language.$('listingPage.target.tag'), + stringsKey: 'tag', listings: filterListings('tag'), }, { - title: ({language}) => language.$('listingPage.target.other'), + stringsKey: 'other', listings: listingSpec.filter(l => l.groupUnderOther), }, ]; +for (const target of listingTargetSpec) { + for (const listing of target.listings) { + listing.target = target; + } +} + export {listingSpec, listingTargetSpec}; diff --git a/src/misc-templates.js b/src/misc-templates.js index a34771c7..ba1a60f1 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -16,547 +16,8 @@ import { getTotalDuration, sortAlbumsTracksChronologically, sortChronologically, - sortFlashesChronologically, } from './util/wiki-data.js'; -const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; - -const MASTODON_DOMAINS = ['types.pl']; - -// "Additional Files" listing - -function unbound_generateAdditionalFilesShortcut(additionalFiles, { - html, - language, -}) { - if (empty(additionalFiles)) return ''; - - return language.$('releaseInfo.additionalFiles.shortcut', { - anchorLink: - html.tag('a', - {href: '#additional-files'}, - language.$('releaseInfo.additionalFiles.shortcut.anchorLink')), - titles: language.formatUnitList( - additionalFiles.map(g => g.title)), - }); -} - -function unbound_generateAdditionalFilesList(additionalFiles, { - html, - language, - - getFileSize, - linkFile, -}) { - if (empty(additionalFiles)) return []; - - return html.tag('dl', - additionalFiles.flatMap(({title, description, files}) => [ - html.tag('dt', - (description - ? language.$('releaseInfo.additionalFiles.entry.withDescription', { - title, - description, - }) - : language.$('releaseInfo.additionalFiles.entry', {title}))), - - html.tag('dd', - html.tag('ul', - files.map((file) => { - const size = (getFileSize && getFileSize(file)); - return html.tag('li', - (size - ? language.$('releaseInfo.additionalFiles.file.withSize', { - file: linkFile(file), - size: language.formatFileSize(size), - }) - : language.$('releaseInfo.additionalFiles.file', { - file: linkFile(file), - }))) - }))), - ])); -} - -// Artist strings - -function unbound_getArtistString(artists, { - html, - language, - link, - - iconifyURL, - - showIcons = false, - showContrib = false, -}) { - return language.formatConjunctionList( - artists.map(({who, what}) => { - const {urls} = who; - - const hasContribPart = !!(showContrib && what); - const hasExternalPart = !!(showIcons && !empty(urls)); - - const artistLink = link.artist(who); - - const externalLinks = hasExternalPart && - html.tag('span', - { - [html.noEdgeWhitespace]: true, - class: 'icons' - }, - language.formatUnitList( - urls.slice(0, 4).map(url => iconifyURL(url, {language})))); - - return html.tag('span', {class: 'nowrap'}, - (hasContribPart - ? (hasExternalPart - ? language.$('misc.artistLink.withContribution.withExternalLinks', { - artist: artistLink, - contrib: what, - links: externalLinks, - }) - : language.$('misc.artistLink.withContribution', { - artist: artistLink, - contrib: what, - })) - : (hasExternalPart - ? language.$('misc.artistLink.withExternalLinks', { - artist: artistLink, - links: externalLinks, - }) - : language.$('misc.artistLink', { - artist: artistLink, - })))); - })); -} - -// Chronology links - -function unbound_generateChronologyLinks(currentThing, { - html, - language, - link, - - generateNavigationLinks, - - dateKey = 'date', - contribKey, - getThings, - headingString, -}) { - const contributions = currentThing[contribKey]; - - if (empty(contributions)) { - return []; - } - - if (contributions.length > 8) { - return html.tag('div', {class: 'chronology'}, - language.$('misc.chronology.seeArtistPages')); - } - - return contributions - .map(({who: artist}) => { - const thingsUnsorted = unique(getThings(artist)) - .filter((t) => t[dateKey]); - - // Kinda a hack, but we automatically detect which is (probably) the - // right function to use here. - const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}]; - const things = ( - thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track) - ? sortAlbumsTracksChronologically(...args) - : thingsUnsorted.every(t => t instanceof T.Flash) - ? sortFlashesChronologically(...args) - : sortChronologically(...args)); - - if (things.length === 0) return ''; - - const index = things.indexOf(currentThing); - - if (index === -1) return ''; - - const heading = ( - html.tag('span', {class: 'heading'}, - language.$(headingString, { - index: language.formatIndex(index + 1, {language}), - artist: link.artist(artist), - }))); - - const navigation = things.length > 1 && - html.tag('span', - { - [html.onlyIfContent]: true, - class: 'buttons', - }, - generateNavigationLinks(currentThing, { - data: things, - isMain: false, - })); - - return ( - html.tag('div', {class: 'chronology'}, - (navigation - ? language.$('misc.chronology.withNavigation', { - heading, - navigation, - }) - : heading))); - }); -} - -// Content warning tags - -function unbound_getRevealStringFromContentWarningMessage(warnings, { - html, - language, -}) { - return ( - language.$('misc.contentWarnings', {warnings}) + - html.tag('br') + - html.tag('span', {class: 'reveal-interaction'}, - language.$('misc.contentWarnings.reveal')) - ); -} - -function unbound_getRevealStringFromArtTags(tags, { - getRevealStringFromContentWarningMessage, - language, -}) { - return ( - tags?.some(tag => tag.isContentWarning) && - getRevealStringFromContentWarningMessage( - language.formatUnitList( - tags - .filter(tag => tag.isContentWarning) - .map(tag => tag.name))) - ); -} - -// Cover art links - -function unbound_generateCoverLink({ - html, - img, - language, - link, - - getRevealStringFromArtTags, - - alt, - path, - src, - tags = [], - to, - wikiData, -}) { - const {wikiInfo} = wikiData; - - if (!src && path) { - src = to(...path); - } - - if (!src) { - throw new Error(`Expected src or path`); - } - - const linkedTags = tags.filter(tag => !tag.isContentWarning); - - return html.tag('div', {id: 'cover-art-container'}, [ - img({ - src, - alt, - thumb: 'medium', - id: 'cover-art', - link: true, - square: true, - reveal: getRevealStringFromArtTags(tags), - }), - - wikiInfo.enableArtTagUI && - linkedTags.length && - html.tag('p', {class: 'tags'}, - language.$('releaseInfo.artTags.inline', { - tags: language.formatUnitList( - linkedTags.map(tag => link.tag(tag))), - })), - ]); -} - -// CSS & color shenanigans - -function unbound_getThemeString(color, { - getColors, - - additionalVariables = [], -} = {}) { - if (!color) return ''; - - const { - primary, - dark, - dim, - dimGhost, - bg, - bgBlack, - shadow, - } = getColors(color); - - const variables = [ - `--primary-color: ${primary}`, - `--dark-color: ${dark}`, - `--dim-color: ${dim}`, - `--dim-ghost-color: ${dimGhost}`, - `--bg-color: ${bg}`, - `--bg-black-color: ${bgBlack}`, - `--shadow-color: ${shadow}`, - ...additionalVariables, - ].filter(Boolean); - - if (!variables.length) return ''; - - return [ - `:root {`, - ...variables.map((line) => ` ${line};`), - `}` - ].join('\n'); -} - -function unbound_getAlbumStylesheet(album, { - to, -}) { - const hasWallpaper = album.wallpaperArtistContribs.length >= 1; - const hasWallpaperStyle = !!album.wallpaperStyle; - const hasBannerStyle = !!album.bannerStyle; - - const wallpaperSource = - (hasWallpaper && - to( - 'media.albumWallpaper', - album.directory, - album.wallpaperFileExtension)); - - const wallpaperPart = - (hasWallpaper - ? [ - `body::before {`, - ` background-image: url("${wallpaperSource}");`, - ...(hasWallpaperStyle - ? album.wallpaperStyle - .split('\n') - .map(line => ` ${line}`) - : []), - `}`, - ] - : []); - - const bannerPart = - (hasBannerStyle - ? [ - `#banner img {`, - ...album.bannerStyle - .split('\n') - .map(line => ` ${line}`), - `}`, - ] - : []); - - return [ - ...wallpaperPart, - ...bannerPart, - ] - .filter(Boolean) - .join('\n'); -} - -// Divided track lists - -function unbound_generateTrackListDividedByGroups(tracks, { - html, - language, - - getTrackItem, - wikiData, -}) { - const {divideTrackListsByGroups: groups} = wikiData.wikiInfo; - - if (empty(groups)) { - return html.tag('ul', - tracks.map(t => getTrackItem(t))); - } - - const lists = Object.fromEntries( - groups.map((group) => [ - group.directory, - {group, tracks: []} - ])); - - const other = []; - - for (const track of tracks) { - const {album} = track; - const group = groups.find((g) => g.albums.includes(album)); - if (group) { - lists[group.directory].tracks.push(track); - } else { - other.push(track); - } - } - - const dt = name => - html.tag('dt', - language.$('trackList.group', { - group: name, - })); - - const ddul = tracks => - html.tag('dd', - html.tag('ul', - tracks.map(t => getTrackItem(t)))); - - return html.tag('dl', [ - ...Object.values(lists) - .filter(({tracks}) => tracks.length) - .flatMap(({group, tracks}) => [ - dt(group.name), - ddul(tracks), - ]), - - ...html.fragment( - other.length && [ - dt(language.$('trackList.group.other')), - ddul(other), - ]), - ]); -} - -// Fancy lookin' links - -function unbound_fancifyURL(url, { - html, - language, - - album = false, -} = {}) { - let local = Symbol(); - let domain; - try { - domain = new URL(url).hostname; - } catch (error) { - // No support for relative local URLs yet, sorry! (I.e, local URLs must - // be absolute relative to the domain name in order to work.) - domain = local; - } - - return html.tag('a', - { - href: url, - class: 'nowrap', - }, - - // truly unhinged indentation here - domain === local - ? language.$('misc.external.local') - : domain.includes('bandcamp.com') - ? language.$('misc.external.bandcamp') - : BANDCAMP_DOMAINS.includes(domain) - ? language.$('misc.external.bandcamp.domain', {domain}) - : MASTODON_DOMAINS.includes(domain) - ? language.$('misc.external.mastodon.domain', {domain}) - : domain.includes('youtu') - ? album - ? url.includes('list=') - ? language.$('misc.external.youtube.playlist') - : language.$('misc.external.youtube.fullAlbum') - : language.$('misc.external.youtube') - : domain.includes('soundcloud') - ? language.$('misc.external.soundcloud') - : domain.includes('tumblr.com') - ? language.$('misc.external.tumblr') - : domain.includes('twitter.com') - ? language.$('misc.external.twitter') - : domain.includes('deviantart.com') - ? language.$('misc.external.deviantart') - : domain.includes('wikipedia.org') - ? language.$('misc.external.wikipedia') - : domain.includes('poetryfoundation.org') - ? language.$('misc.external.poetryFoundation') - : domain.includes('instagram.com') - ? language.$('misc.external.instagram') - : domain.includes('patreon.com') - ? language.$('misc.external.patreon') - : domain.includes('spotify.com') - ? language.$('misc.external.spotify') - : domain.includes('newgrounds.com') - ? language.$('misc.external.newgrounds') - : domain); -} - -function unbound_fancifyFlashURL(url, flash, { - html, - language, - - fancifyURL, -}) { - const link = fancifyURL(url); - return html.tag('span', - {class: 'nowrap'}, - url.includes('homestuck.com') - ? isNaN(Number(flash.page)) - ? language.$('misc.external.flash.homestuck.secret', {link}) - : language.$('misc.external.flash.homestuck.page', { - link, - page: flash.page, - }) - : url.includes('bgreco.net') - ? language.$('misc.external.flash.bgreco', {link}) - : url.includes('youtu') - ? language.$('misc.external.flash.youtube', {link}) - : link); -} - -function unbound_iconifyURL(url, { - html, - language, - to, -}) { - const domain = new URL(url).hostname; - const [id, msg] = ( - domain.includes('bandcamp.com') - ? ['bandcamp', language.$('misc.external.bandcamp')] - : BANDCAMP_DOMAINS.includes(domain) - ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] - : MASTODON_DOMAINS.includes(domain) - ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] - : domain.includes('youtu') - ? ['youtube', language.$('misc.external.youtube')] - : domain.includes('soundcloud') - ? ['soundcloud', language.$('misc.external.soundcloud')] - : domain.includes('tumblr.com') - ? ['tumblr', language.$('misc.external.tumblr')] - : domain.includes('twitter.com') - ? ['twitter', language.$('misc.external.twitter')] - : domain.includes('deviantart.com') - ? ['deviantart', language.$('misc.external.deviantart')] - : domain.includes('instagram.com') - ? ['instagram', language.$('misc.external.bandcamp')] - : domain.includes('newgrounds.com') - ? ['newgrounds', language.$('misc.external.newgrounds')] - : ['globe', language.$('misc.external.domain', {domain})]); - - return html.tag('a', - { - href: url, - class: 'icon', - }, - html.tag('svg', [ - html.tag('title', msg), - html.tag('use', { - href: to('shared.staticIcon', id), - }), - ])); -} - // Grids function unbound_getGridHTML({ @@ -636,167 +97,8 @@ function unbound_getFlashGridHTML({ }); } -// Images - -function unbound_img({ - getSizeOfImageFile, - html, - to, - - src, - alt, - noSrcText = '', - thumb: thumbKey, - reveal, - id, - class: className, - width, - height, - link = false, - lazy = false, - square = false, -}) { - const willSquare = square; - const willLink = typeof link === 'string' || link; - - const originalSrc = src; - const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src); - - const href = - (willLink - ? (typeof link === 'string' - ? link - : originalSrc) - : null); - - let fileSize = null; - const mediaRoot = to('media.root'); - if (href?.startsWith(mediaRoot)) { - fileSize = getSizeOfImageFile(href.slice(mediaRoot.length).replace(/^\//, '')); - } - - const imgAttributes = { - id: link ? '' : id, - class: className, - alt, - width, - height, - 'data-original-size': fileSize, - }; - - const noSrcHTML = - !src && - wrap( - html.tag('div', - {class: 'image-text-area'}, - noSrcText)); - - const nonlazyHTML = - src && - wrap( - html.tag('img', { - ...imgAttributes, - src: thumbSrc, - })); - - const lazyHTML = - src && - lazy && - wrap( - html.tag('img', - { - ...imgAttributes, - class: [className, 'lazy'], - 'data-original': thumbSrc, - }), - true); - - if (!src) { - return noSrcHTML; - } else if (lazy) { - return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML; - } else { - return nonlazyHTML; - } - - function wrap(input, hide = false) { - let wrapped = input; - - wrapped = html.tag('div', {class: 'image-container'}, wrapped); - - if (reveal) { - wrapped = html.tag('div', {class: 'reveal'}, [ - wrapped, - html.tag('span', {class: 'reveal-text-container'}, - html.tag('span', {class: 'reveal-text'}, reveal)), - ]); - } - - if (willSquare) { - wrapped = html.tag('div', {class: 'square-content'}, wrapped); - wrapped = html.tag('div', - {class: ['square', hide && !willLink && 'js-hide']}, - wrapped); - } - - if (willLink) { - wrapped = html.tag('a', - { - id, - class: ['box', hide && 'js-hide', 'image-link'], - href, - }, - wrapped); - } - - return wrapped; - } -} - // Carousel reels -// Layout constants: -// -// Carousels support fitting 4-18 items, with a few "dead" zones to watch out -// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. -// -// Carousels are limited to 1-3 rows and 4-6 columns. -// Lower edge case: 1-3 items are treated as 4 items (with blank space). -// Upper edge case: all items past 18 are dropped (treated as 18 items). -// -// This is all done through JS instead of CSS because it's just... ANNOYING... -// to write a mapping like this in CSS lol. -const carouselLayoutMap = [ - // 0-3 - null, null, null, null, - - // 4-6 - {rows: 1, columns: 4}, // 4: 1x4, drop 0 - {rows: 1, columns: 5}, // 5: 1x5, drop 0 - {rows: 1, columns: 6}, // 6: 1x6, drop 0 - - // 7-12 - {rows: 1, columns: 6}, // 7: 1x6, drop 1 - {rows: 2, columns: 4}, // 8: 2x4, drop 0 - {rows: 2, columns: 4}, // 9: 2x4, drop 1 - {rows: 2, columns: 5}, // 10: 2x5, drop 0 - {rows: 2, columns: 5}, // 11: 2x5, drop 1 - {rows: 2, columns: 6}, // 12: 2x6, drop 0 - - // 13-18 - {rows: 2, columns: 6}, // 13: 2x6, drop 1 - {rows: 2, columns: 6}, // 14: 2x6, drop 2 - {rows: 3, columns: 5}, // 15: 3x5, drop 0 - {rows: 3, columns: 5}, // 16: 3x5, drop 1 - {rows: 3, columns: 5}, // 17: 3x5, drop 2 - {rows: 3, columns: 6}, // 18: 3x6, drop 0 -]; - -const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); -const maxCarouselLayoutItems = carouselLayoutMap.length - 1; -const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; -const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; - function unbound_getCarouselHTML({ html, img, @@ -808,271 +110,13 @@ function unbound_getCarouselHTML({ linkFn = (x, {text}) => text, srcFn, }) { - if (empty(items)) { - return; - } - - const {rows, columns} = ( - items.length < minCarouselLayoutItems ? shortestCarouselLayout : - items.length > maxCarouselLayoutItems ? longestCarouselLayout : - carouselLayoutMap[items.length]); - - items = items.slice(0, maxCarouselLayoutItems + 1); - - return html.tag('div', - { - class: 'carousel-container', - 'data-carousel-rows': rows, - 'data-carousel-columns': columns, - }, - repeat(3, - html.tag('div', - { - class: 'carousel-grid', - 'aria-hidden': 'true', - }, - items - .filter(item => srcFn(item)) - .filter(item => item.artTags.every(tag => !tag.isContentWarning)) - .map((item, i) => - html.tag('div', {class: 'carousel-item'}, - linkFn(item, { - attributes: { - tabindex: '-1', - }, - text: - img({ - src: srcFn(item), - alt: altFn(item), - thumb: 'small', - lazy: typeof lazy === 'number' ? i >= lazy : lazy, - square: true, - }), - })))))); -} - -// Nav-bar links - -function unbound_generateInfoGalleryLinks(currentThing, isGallery, { - link, - language, - - linkKeyGallery, - linkKeyInfo, -}) { - return [ - link[linkKeyInfo](currentThing, { - class: isGallery ? '' : 'current', - text: language.$('misc.nav.info'), - }), - link[linkKeyGallery](currentThing, { - class: isGallery ? 'current' : '', - text: language.$('misc.nav.gallery'), - }), - ].join(', '); -} - -// Generate "previous" and "next" links relative to a given current thing and a -// data set (array of things) which includes it, optionally including additional -// provided links like "random". This is for use in navigation bars and other -// inline areas. -// -// By default, generated links include ID attributes which enable client-side -// keyboard shortcuts. Provide isMain: false to disable this (if the generated -// links aren't the for the page's primary navigation). -function unbound_generateNavigationLinks(current, { - language, - link, - - additionalLinks = [], - data, - isMain = true, - linkKey = 'anything', - returnAsArray = false, -}) { - let previousLink, nextLink; - - if (current) { - const linkFn = link[linkKey].bind(link); - - const index = data.indexOf(current); - const previousThing = data[index - 1]; - const nextThing = data[index + 1]; - - previousLink = previousThing && - linkFn(previousThing, { - attributes: { - id: isMain && 'previous-button', - title: previousThing.name, - }, - text: language.$('misc.nav.previous'), - color: false, - }); - - nextLink = nextThing && - linkFn(nextThing, { - attributes: { - id: isMain && 'next-button', - title: nextThing.name, - }, - text: language.$('misc.nav.next'), - color: false, - }); - } - - const links = [ - previousLink, - nextLink, - ...additionalLinks, - ].filter(Boolean); - - if (returnAsArray) { - return links; - } else if (empty(links)) { - return ''; - } else { - return language.formatUnitList(links); - } -} - -// Sticky heading, ooooo - -function unbound_generateContentHeading({ - html, - - id, - title, -}) { - return html.tag('p', - { - class: 'content-heading', - id, - tabindex: '0', - }, - title); -} - -function unbound_generateStickyHeadingContainer({ - html, - img, - - class: classes, - coverSrc, - coverAlt, - coverArtTags, - title, -}) { - return html.tag('div', - {class: [ - 'content-sticky-heading-container', - coverSrc && 'has-cover', - ].concat(classes)}, - [ - html.tag('div', {class: 'content-sticky-heading-row'}, [ - html.tag('h1', title), - - // Cover art in the sticky heading never uses the 'reveal' setting - // because it's too small to effectively display content warnings. - // Instead, if art has content warnings, it's hidden from the sticky - // heading by default, and will be enabled once the main cover art - // is revealed. - coverSrc && - html.tag('div', {class: 'content-sticky-heading-cover-container'}, - html.tag('div', - { - class: [ - 'content-sticky-heading-cover', - coverArtTags.some(tag => tag.isContentWarning) && - 'content-sticky-heading-cover-needs-reveal', - ], - }, - img({ - src: coverSrc, - alt: coverAlt, - thumb: 'small', - link: false, - square: true, - }))), - ]), - - html.tag('div', {class: 'content-sticky-subheading-row'}, - html.tag('h2', {class: 'content-sticky-subheading'})), - ]); -} - -// Footer stuff - -function unbound_getFooterLocalizationLinks({ - html, - defaultLanguage, - language, - languages, - pagePath, - to, -}) { - const links = Object.entries(languages) - .filter(([code, language]) => code !== 'default' && !language.hidden) - .map(([code, language]) => language) - .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) - .map((language) => - html.tag('span', - html.tag('a', - { - href: - language === defaultLanguage - ? to( - 'localizedDefaultLanguage.' + pagePath[0], - ...pagePath.slice(1)) - : to( - 'localizedWithBaseDirectory.' + pagePath[0], - language.code, - ...pagePath.slice(1)), - }, - language.name))); - - return html.tag('div', {class: 'footer-localization-links'}, - language.$('misc.uiLanguage', { - languages: links.join('\n'), - })); } // Exports export { - unbound_generateAdditionalFilesList as generateAdditionalFilesList, - unbound_generateAdditionalFilesShortcut as generateAdditionalFilesShortcut, - - unbound_getArtistString as getArtistString, - - unbound_generateChronologyLinks as generateChronologyLinks, - - unbound_getRevealStringFromContentWarningMessage as getRevealStringFromContentWarningMessage, - unbound_getRevealStringFromArtTags as getRevealStringFromArtTags, - - unbound_generateCoverLink as generateCoverLink, - - unbound_getThemeString as getThemeString, - unbound_getAlbumStylesheet as getAlbumStylesheet, - - unbound_generateTrackListDividedByGroups as generateTrackListDividedByGroups, - - unbound_fancifyURL as fancifyURL, - unbound_fancifyFlashURL as fancifyFlashURL, - unbound_iconifyURL as iconifyURL, - unbound_getGridHTML as getGridHTML, unbound_getAlbumGridHTML as getAlbumGridHTML, unbound_getFlashGridHTML as getFlashGridHTML, - unbound_getCarouselHTML as getCarouselHTML, - - unbound_img as img, - - unbound_generateInfoGalleryLinks as generateInfoGalleryLinks, - unbound_generateNavigationLinks as generateNavigationLinks, - - unbound_generateContentHeading as generateContentHeading, - unbound_generateStickyHeadingContainer as generateStickyHeadingContainer, - - unbound_getFooterLocalizationLinks as getFooterLocalizationLinks, } diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js deleted file mode 100644 index eb462d9a..00000000 --- a/src/page/album-commentary.js +++ /dev/null @@ -1,137 +0,0 @@ -// Album commentary page and index specifications. - -import {generateAlbumExtrasPageNav} from './album.js'; -import {accumulateSum} from '../util/sugar.js'; -import {filterAlbumsByCommentary} from '../util/wiki-data.js'; - -export const description = `per-album artist commentary pages & index` - -export function condition({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData).length; -} - -export function targets({wikiData}) { - return filterAlbumsByCommentary(wikiData.albumData); -} - -export function write(album) { - const entries = [album, ...album.tracks] - .filter((x) => x.commentary) - .map((x) => x.commentary); - const words = entries.join(' ').split(' ').length; - - const page = { - type: 'page', - path: ['albumCommentary', album.directory], - page: ({ - getAlbumStylesheet, - getLinkThemeString, - getThemeString, - html, - language, - link, - transformMultiline, - }) => ({ - title: language.$('albumCommentaryPage.title', {album: album.name}), - stylesheet: getAlbumStylesheet(album), - theme: getThemeString(album.color), - - main: { - classes: ['long-content'], - headingMode: 'sticky', - - content: [ - html.tag('p', - language.$('albumCommentaryPage.infoLine', { - words: html.tag('b', language.formatWordCount(words, {unit: true})), - entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})), - })), - - ...html.fragment(album.commentary && [ - html.tag('h3', - {class: ['content-heading']}, - language.$('albumCommentaryPage.entry.title.albumCommentary')), - - html.tag('blockquote', - transformMultiline(album.commentary)), - ]), - - ...album.tracks.filter(t => t.commentary).flatMap(track => [ - html.tag('h3', - {id: track.directory, class: ['content-heading']}, - language.$('albumCommentaryPage.entry.title.trackCommentary', { - track: link.track(track), - })), - - html.tag('blockquote', - {style: getLinkThemeString(track.color)}, - transformMultiline(track.commentary)), - ]) - ], - }, - - nav: generateAlbumExtrasPageNav(album, 'commentary', { - html, - language, - link, - }), - }), - }; - - return [page]; -} - -export function writeTargetless({wikiData}) { - const data = filterAlbumsByCommentary(wikiData.albumData) - .map((album) => ({ - album, - entries: [album, ...album.tracks] - .filter((x) => x.commentary) - .map((x) => x.commentary), - })) - .map(({album, entries}) => ({ - album, - entries, - words: entries.join(' ').split(' ').length, - })); - - const totalEntries = accumulateSum(data, ({entries}) => entries.length); - const totalWords = accumulateSum(data, ({words}) => words); - - const page = { - type: 'page', - path: ['commentaryIndex'], - page: ({ - html, - language, - link, - }) => ({ - title: language.$('commentaryIndex.title'), - - main: { - classes: ['long-content'], - headingMode: 'static', - - content: [ - html.tag('p', language.$('commentaryIndex.infoLine', { - words: html.tag('b', language.formatWordCount(totalWords, {unit: true})), - entries: html.tag('b', language.countCommentaryEntries(totalEntries, {unit: true})), - })), - - html.tag('p', language.$('commentaryIndex.albumList.title')), - - html.tag('ul', data.map(({album, entries, words}) => - html.tag('li', language.$('commentaryIndex.albumList.item', { - album: link.albumCommentary(album), - words: language.formatWordCount(words, {unit: true}), - entries: language.countCommentaryEntries(entries.length, {unit: true}), - })))), - ], - }, - - nav: {simple: true}, - }), - }; - - return [page]; -} diff --git a/src/page/album.js b/src/page/album.js index 9ee57c09..a8e0b591 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -1,66 +1,62 @@ // Album page specification. -import { - bindOpts, - compareArrays, - empty, -} from '../util/sugar.js'; - -import { - getAlbumCover, - getAlbumListTag, - getTotalDuration, -} from '../util/wiki-data.js'; - -export const description = `per-album info & track artwork gallery pages`; +export const description = `per-album info, artwork gallery & commentary pages`; export function targets({wikiData}) { return wikiData.albumData; } -export function write(album, {wikiData}) { - const unbound_trackToListItem = (track, { - getArtistString, - getLinkThemeString, - html, - language, - link, - }) => { - const itemOpts = { - duration: language.formatDuration(track.duration ?? 0), - track: link.track(track), - }; +export function pathsForTarget(album) { + const hasGalleryPage = album.tracks.some(t => t.hasUniqueCoverArt); + const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary); - return html.tag('li', - {style: getLinkThemeString(track.color)}, - compareArrays( - track.artistContribs.map((c) => c.who), - album.artistContribs.map((c) => c.who), - {checkOrder: false} - ) - ? language.$('trackList.item.withDuration', itemOpts) - : language.$('trackList.item.withDuration.withArtists', { - ...itemOpts, - by: html.tag('span', - {class: 'by'}, - language.$('trackList.item.withArtists.by', { - artists: getArtistString(track.artistContribs), - })), - })); - }; + return [ + { + type: 'page', + path: ['album', album.directory], - const hasAdditionalFiles = !empty(album.additionalFiles); - const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length; + contentFunction: { + name: 'generateAlbumInfoPage', + args: [album], + }, + }, - const albumDuration = getTotalDuration(album.tracks); + hasGalleryPage && { + type: 'page', + path: ['albumGallery', album.directory], - const displayTrackSections = - album.trackSections && - (album.trackSections.length > 1 || - !album.trackSections[0]?.isDefaultTrackSection); + contentFunction: { + name: 'generateAlbumGalleryPage', + args: [album], + }, + }, + + hasCommentaryPage && { + type: 'page', + path: ['albumCommentary', album.directory], + + contentFunction: { + name: 'generateAlbumCommentaryPage', + args: [album], + }, + }, - const listTag = getAlbumListTag(album); + /* + { + type: 'data', + path: ['album', album.directory], + contentFunction: { + name: 'generateAlbumDataFile', + args: [album], + }, + }, + */ + ]; +} + +/* +export function write(album, {wikiData}) { const getSocialEmbedDescription = ({ getArtistString: _getArtistString, language, @@ -123,297 +119,6 @@ export function write(album, {wikiData}) { }), }; - const infoPage = { - type: 'page', - path: ['album', album.directory], - page: ({ - absoluteTo, - fancifyURL, - generateAdditionalFilesShortcut, - generateAdditionalFilesList, - generateChronologyLinks, - generateContentHeading, - generateNavigationLinks, - getAlbumCover, - getAlbumStylesheet, - getArtistString, - getLinkThemeString, - getSizeOfAdditionalFile, - getThemeString, - html, - link, - language, - transformMultiline, - urls, - }) => { - const trackToListItem = bindOpts(unbound_trackToListItem, { - getArtistString, - getLinkThemeString, - html, - language, - link, - }); - - return { - title: language.$('albumPage.title', {album: album.name}), - stylesheet: getAlbumStylesheet(album), - - themeColor: album.color, - theme: - getThemeString(album.color, { - additionalVariables: [ - `--album-directory: ${album.directory}`, - ], - }), - - socialEmbed: { - heading: - (empty(album.groups) - ? '' - : language.$('albumPage.socialEmbed.heading', { - group: album.groups[0].name, - })), - headingLink: - (empty(album.groups) - ? null - : absoluteTo('localized.album', album.groups[0].directory)), - title: language.$('albumPage.socialEmbed.title', { - album: album.name, - }), - description: getSocialEmbedDescription({getArtistString, language}), - image: '/' + getAlbumCover(album, {to: urls.from('shared.root').to}), - color: album.color, - }, - - banner: !empty(album.bannerArtistContribs) && { - dimensions: album.bannerDimensions, - path: [ - 'media.albumBanner', - album.directory, - album.bannerFileExtension, - ], - alt: language.$('misc.alt.albumBanner'), - position: 'top', - }, - - cover: { - src: getAlbumCover(album), - alt: language.$('misc.alt.albumCover'), - artTags: album.artTags, - }, - - main: { - headingMode: 'sticky', - - content: [ - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - !empty(album.artistContribs) && - language.$('releaseInfo.by', { - artists: getArtistString(album.artistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - !empty(album.coverArtistContribs) && - language.$('releaseInfo.coverArtBy', { - artists: getArtistString(album.coverArtistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - !empty(album.wallpaperArtistContribs) && - language.$('releaseInfo.wallpaperArtBy', { - artists: getArtistString(album.wallpaperArtistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - !empty(album.bannerArtistContribs) && - language.$('releaseInfo.bannerArtBy', { - artists: getArtistString(album.bannerArtistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - album.date && - language.$('releaseInfo.released', { - date: language.formatDate(album.date), - }), - - album.hasCoverArt && - album.coverArtDate && - +album.coverArtDate !== +album.date && - language.$('releaseInfo.artReleased', { - date: language.formatDate(album.coverArtDate), - }), - - albumDuration > 0 && - language.$('releaseInfo.duration', { - duration: language.formatDuration(albumDuration, { - approximate: album.tracks.length > 1, - }), - }), - ]), - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - hasAdditionalFiles && - generateAdditionalFilesShortcut(album.additionalFiles), - - checkGalleryPage(album) && - language.$('releaseInfo.viewGallery', { - link: link.albumGallery(album, { - text: language.$('releaseInfo.viewGallery.link'), - }), - }), - - checkCommentaryPage(album) && - language.$('releaseInfo.viewCommentary', { - link: link.albumCommentary(album, { - text: language.$('releaseInfo.viewCommentary.link'), - }), - }), - ]), - - !empty(album.urls) && - html.tag('p', - language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList( - album.urls.map(url => fancifyURL(url, {album: true})) - ), - })), - - displayTrackSections && - !empty(album.trackSections) && - html.tag('dl', - {class: 'album-group-list'}, - album.trackSections.flatMap(({ - name, - startIndex, - tracks, - }) => [ - html.tag('dt', - {class: ['content-heading']}, - language.$('trackList.section.withDuration', { - duration: language.formatDuration(getTotalDuration(tracks), { - approximate: tracks.length > 1, - }), - section: name, - })), - html.tag('dd', - html.tag(listTag, - listTag === 'ol' ? {start: startIndex + 1} : {}, - tracks.map(trackToListItem))), - ])), - - !displayTrackSections && - !empty(album.tracks) && - html.tag(listTag, - album.tracks.map(trackToListItem)), - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - album.dateAddedToWiki && - language.$('releaseInfo.addedToWiki', { - date: language.formatDate( - album.dateAddedToWiki - ), - }) - ]), - - ...html.fragment( - hasAdditionalFiles && [ - generateContentHeading({ - id: 'additional-files', - title: language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: language.countAdditionalFiles(numAdditionalFiles, { - unit: true, - }), - }), - }), - - generateAlbumAdditionalFilesList(album, album.additionalFiles, { - generateAdditionalFilesList, - getSizeOfAdditionalFile, - link, - urls, - }), - ]), - - ...html.fragment( - album.commentary && [ - generateContentHeading({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary'), - }), - - html.tag('blockquote', transformMultiline(album.commentary)), - ]), - ], - }, - - sidebarLeft: generateAlbumSidebar(album, null, { - fancifyURL, - getLinkThemeString, - html, - link, - language, - transformMultiline, - wikiData, - }), - - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - html: language.$('albumPage.nav.album', { - album: link.album(album, {class: 'current'}), - }), - }, - { - divider: false, - html: generateAlbumNavLinks(album, null, { - generateNavigationLinks, - html, - language, - link, - }), - } - ], - content: generateAlbumChronologyLinks(album, null, { - generateChronologyLinks, - html, - }), - }, - - secondaryNav: generateAlbumSecondaryNav(album, null, { - getLinkThemeString, - html, - language, - link, - }), - }; - }, - }; - // TODO: only gen if there are any tracks with art const galleryPage = { type: 'page', @@ -494,153 +199,6 @@ export function write(album, {wikiData}) { }), }), }; - - return [ - infoPage, - galleryPage, - data, - ]; -} - -// Utility functions - -export function generateAlbumSidebar(album, currentTrack, { - fancifyURL, - getLinkThemeString, - html, - language, - link, - transformMultiline, -}) { - const isAlbumPage = !currentTrack; - const isTrackPage = !!currentTrack; - - const listTag = getAlbumListTag(album); - - const {trackSections} = album; - - const trackToListItem = (track) => - html.tag('li', - {class: track === currentTrack && 'current'}, - language.$('albumSidebar.trackList.item', { - track: link.track(track), - })); - - const nameOrDefault = (isDefaultTrackSection, name) => - isDefaultTrackSection - ? language.$('albumSidebar.trackList.fallbackSectionName') - : name; - - const trackListPart = [ - html.tag('h1', link.album(album)), - ...trackSections.map(({name, color, startIndex, tracks, isDefaultTrackSection}) => { - const groupName = - html.tag('span', - {class: 'group-name'}, - nameOrDefault( - isDefaultTrackSection, - name - )); - return html.tag('details', - { - // Leave side8ar track groups collapsed on al8um homepage, - // since there's already a view of all the groups expanded - // in the main content area. - open: isTrackPage && tracks.includes(currentTrack), - class: tracks.includes(currentTrack) && 'current', - }, - [ - html.tag( - 'summary', - {style: getLinkThemeString(color)}, - html.tag('span', [ - listTag === 'ol' && - language.$('albumSidebar.trackList.group.withRange', { - group: groupName, - range: `${startIndex + 1}–${ - startIndex + tracks.length - }`, - }), - listTag === 'ul' && - language.$('albumSidebar.trackList.group', { - group: groupName, - }), - ])), - html.tag(listTag, - listTag === 'ol' ? {start: startIndex + 1} : {}, - tracks.map(trackToListItem)), - ]); - }), - ]; - - const {groups} = album; - - const groupParts = groups - .map((group) => { - const albums = group.albums.filter((album) => album.date); - const index = albums.indexOf(album); - const next = index >= 0 && albums[index + 1]; - const previous = index > 0 && albums[index - 1]; - return {group, next, previous}; - }) - // This is a map and not a flatMap because the distinction between which - // group sets of elements belong to matters. That means this variable is an - // array of arrays, and we'll need to treat it as such later! - .map(({group, next, previous}) => [ - html.tag('h1', language.$('albumSidebar.groupBox.title', { - group: link.groupInfo(group), - })), - - isAlbumPage && - transformMultiline(group.descriptionShort), - - !empty(group.urls) && - html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList( - group.urls.map((url) => fancifyURL(url)) - ), - })), - - ...html.fragment( - isAlbumPage && [ - next && - html.tag('p', - {class: 'group-chronology-link'}, - language.$('albumSidebar.groupBox.next', { - album: link.album(next), - })), - - previous && - html.tag('p', - {class: 'group-chronology-link'}, - language.$('albumSidebar.groupBox.previous', { - album: link.album(previous), - })), - ]), - ]); - - if (empty(groupParts)) { - return { - stickyMode: 'column', - content: trackListPart, - }; - } else if (isTrackPage) { - const combinedGroupPart = { - classes: ['no-sticky-header'], - content: groupParts - .map(groupPart => groupPart.filter(Boolean).join('\n')) - .join('\n<hr>\n'), - }; - return { - stickyMode: 'column', - multiple: [trackListPart, combinedGroupPart], - }; - } else { - return { - stickyMode: 'last', - multiple: [...groupParts, trackListPart], - }; - } } export function generateAlbumSecondaryNav(album, currentTrack, { @@ -696,174 +254,4 @@ export function generateAlbumSecondaryNav(album, currentTrack, { content: groupParts, }; } - -function checkGalleryPage(album) { - return album.tracks.some(t => t.hasUniqueCoverArt); -} - -function checkCommentaryPage(album) { - return !!album.commentary || album.tracks.some(t => t.commentary); -} - -export function generateAlbumNavLinks(album, currentTrack, { - generateNavigationLinks, - html, - language, - link, - - currentExtra = null, - showTrackNavigation = true, - showExtraLinks = null, -}) { - const isTrackPage = !!currentTrack; - - showExtraLinks ??= currentTrack ? false : true; - - const extraLinks = showExtraLinks ? [ - checkGalleryPage(album) && - link.albumGallery(album, { - class: [currentExtra === 'gallery' && 'current'], - text: language.$('albumPage.nav.gallery'), - }), - - checkCommentaryPage(album) && - link.albumCommentary(album, { - class: [currentExtra === 'commentary' && 'current'], - text: language.$('albumPage.nav.commentary'), - }), - ].filter(Boolean) : []; - - const previousNextLinks = - showTrackNavigation && - album.tracks.length > 1 && - generateNavigationLinks(currentTrack, { - data: album.tracks, - linkKey: 'track', - returnAsArray: true, - }) - - const randomLink = - showTrackNavigation && - album.tracks.length > 1 && - html.tag('a', - { - href: '#', - 'data-random': 'track-in-album', - id: 'random-button' - }, - (isTrackPage - ? language.$('trackPage.nav.random') - : language.$('albumPage.nav.randomTrack'))); - - const allLinks = [ - ...previousNextLinks || [], - ...extraLinks || [], - randomLink, - ].filter(Boolean); - - if (empty(allLinks)) { - return ''; - } - - return `(${language.formatUnitList(allLinks)})`; -} - -export function generateAlbumExtrasPageNav(album, currentExtra, { - html, - language, - link, -}) { - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - html: language.$('albumPage.nav.album', { - album: link.album(album, {class: 'current'}), - }), - }, - { - divider: false, - html: generateAlbumNavLinks(album, null, { - currentExtra, - showTrackNavigation: false, - showExtraLinks: true, - - html, - language, - link, - }), - } - ], - }; -} - -export function generateAlbumChronologyLinks(album, currentTrack, { - generateChronologyLinks, - html, -}) { - return html.tag( - 'div', - { - [html.onlyIfContent]: true, - class: 'nav-chronology-links', - }, - [ - ...html.fragment( - currentTrack && [ - ...html.fragment( - generateChronologyLinks(currentTrack, { - contribKey: 'artistContribs', - getThings: (artist) => [ - ...artist.tracksAsArtist, - ...artist.tracksAsContributor, - ], - headingString: 'misc.chronology.heading.track', - })), - - ...html.fragment( - generateChronologyLinks(currentTrack, { - contribKey: 'contributorContribs', - getThings: (artist) => [ - ...artist.tracksAsArtist, - ...artist.tracksAsContributor, - ], - headingString: 'misc.chronology.heading.track', - })), - ]), - - ...html.fragment( - generateChronologyLinks(currentTrack || album, { - contribKey: 'coverArtistContribs', - dateKey: 'coverArtDate', - getThings: (artist) => [ - ...artist.albumsAsCoverArtist, - ...artist.tracksAsCoverArtist, - ], - headingString: 'misc.chronology.heading.coverArt', - })), - ]); -} - -export function generateAlbumAdditionalFilesList(album, additionalFiles, { - fileSize = true, - - generateAdditionalFilesList, - getSizeOfAdditionalFile, - link, - urls, -}) { - return generateAdditionalFilesList(additionalFiles, { - getFileSize: - (fileSize - ? (file) => - // TODO: Kinda near the metal here... - getSizeOfAdditionalFile( - urls - .from('media.root') - .to('media.albumAdditionalFile', album.directory, file)) - : () => null), - linkFile: (file) => - link.albumAdditionalFile({album, file}), - }); -} +*/ diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js index f867d123..9e9fdf5b 100644 --- a/src/page/artist-alias.js +++ b/src/page/artist-alias.js @@ -7,15 +7,15 @@ export function targets({wikiData}) { return wikiData.artistAliasData; } -export function write(aliasArtist) { +export function pathsForTarget(aliasArtist) { const {aliasedArtist} = aliasArtist; - const redirect = { - type: 'redirect', - fromPath: ['artist', aliasArtist.directory], - toPath: ['artist', aliasedArtist.directory], - title: () => aliasedArtist.name, - }; - - return [redirect]; + return [ + { + type: 'redirect', + fromPath: ['artist', aliasArtist.directory], + toPath: ['artist', aliasedArtist.directory], + title: () => aliasedArtist.name, + }, + ]; } diff --git a/src/page/artist.js b/src/page/artist.js index 4ef44d32..c53a4913 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -2,18 +2,7 @@ // // NB: See artist-alias.js for artist alias redirect pages. -import { - bindOpts, - empty, - unique, -} from '../util/sugar.js'; - -import { - chunkByProperties, - getTotalDuration, - sortAlbumsTracksChronologically, - sortFlashesChronologically, -} from '../util/wiki-data.js'; +import {empty} from '../util/sugar.js'; export const description = `per-artist info & artwork gallery pages`; @@ -21,663 +10,97 @@ export function targets({wikiData}) { return wikiData.artistData; } -export function write(artist, {wikiData}) { - const {groupData, wikiInfo} = wikiData; - - const {name, urls, contextNotes} = artist; - - const artThingsAll = sortAlbumsTracksChronologically( - unique([ - ...(artist.albumsAsCoverArtist ?? []), - ...(artist.albumsAsWallpaperArtist ?? []), - ...(artist.albumsAsBannerArtist ?? []), - ...(artist.tracksAsCoverArtist ?? []), - ]), - {getDate: (o) => o.coverArtDate}); - - const artThingsGallery = sortAlbumsTracksChronologically( - [ - ...(artist.albumsAsCoverArtist ?? []), - ...(artist.tracksAsCoverArtist ?? []), - ], - {latestFirst: true, getDate: (o) => o.coverArtDate}); - - const commentaryThings = sortAlbumsTracksChronologically([ - ...(artist.albumsAsCommentator ?? []), - ...(artist.tracksAsCommentator ?? []), - ]); - - const hasGallery = !empty(artThingsGallery); - - const getArtistsAndContrib = (thing, key) => ({ - artists: thing[key]?.filter(({who}) => who !== artist), - contrib: thing[key]?.find(({who}) => who === artist), - thing, - key, - }); - - const artListChunks = chunkByProperties( - artThingsAll.flatMap((thing) => - ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs'] - .map((key) => getArtistsAndContrib(thing, key)) - .filter(({contrib}) => contrib) - .map((props) => ({ - album: thing.album || thing, - track: thing.album ? thing : null, - date: thing.date, - ...props, - }))), - ['date', 'album']); - - const commentaryListChunks = chunkByProperties( - commentaryThings.map((thing) => ({ - album: thing.album || thing, - track: thing.album ? thing : null, - })), - ['album']); - - const allTracks = sortAlbumsTracksChronologically( - unique([ - ...(artist.tracksAsArtist ?? []), - ...(artist.tracksAsContributor ?? []), - ])); - - const chunkTracks = (tracks) => - chunkByProperties( - tracks.map((track) => ({ - track, - date: +track.date, - album: track.album, - duration: track.duration, - originalReleaseTrack: track.originalReleaseTrack, - artists: track.artistContribs.some(({who}) => who === artist) - ? track.artistContribs.filter(({who}) => who !== artist) - : track.contributorContribs.filter(({who}) => who !== artist), - contrib: { - who: artist, - whatArray: [ - track.artistContribs.find(({who}) => who === artist)?.what, - track.contributorContribs.find(({who}) => who === artist)?.what, - ].filter(Boolean), - }, - })), - ['date', 'album']) - .map(({date, album, chunk}) => ({ - date, - album, - chunk, - duration: getTotalDuration(chunk, {originalReleasesOnly: true}), - })); - - const trackListChunks = chunkTracks(allTracks); - const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack)); - - const countGroups = (things) => { - const usedGroups = things.flatMap( - (thing) => thing.groups || thing.album?.groups || []); - return groupData - .map((group) => ({ - group, - contributions: usedGroups.filter(g => g === group).length, - })) - .filter(({contributions}) => contributions > 0) - .sort((a, b) => b.contributions - a.contributions); - }; - - const musicGroups = countGroups(allTracks); - const artGroups = countGroups(artThingsAll); - - let flashes, flashListChunks; - if (wikiInfo.enableFlashesAndGames) { - flashes = sortFlashesChronologically(artist.flashesAsContributor.slice()); - flashListChunks = chunkByProperties( - flashes.map((flash) => ({ - act: flash.act, - flash, - date: flash.date, - // Manual artists/contrib properties here, 8ecause we don't - // want to show the full list of other contri8utors inline. - // (It can often 8e very, very large!) - artists: [], - contrib: flash.contributorContribs.find(({who}) => who === artist), - })), - ['act'] - ).map(({act, chunk}) => ({ - act, - chunk, - dateFirst: chunk[0].date, - dateLast: chunk[chunk.length - 1].date, - })); - } - - const generateEntryAccents = ({ - getArtistString, - language, - original, - entry, - artists, - contrib, - }) => - original - ? language.$('artistPage.creditList.entry.rerelease', {entry}) - : !empty(artists) - ? contrib.what || contrib.whatArray?.length - ? language.$('artistPage.creditList.entry.withArtists.withContribution', { - entry, - artists: getArtistString(artists), - contribution: contrib.whatArray - ? language.formatUnitList(contrib.whatArray) - : contrib.what, - }) - : language.$('artistPage.creditList.entry.withArtists', { - entry, - artists: getArtistString(artists), - }) - : contrib.what || contrib.whatArray?.length - ? language.$('artistPage.creditList.entry.withContribution', { - entry, - contribution: contrib.whatArray - ? language.formatUnitList(contrib.whatArray) - : contrib.what, - }) - : entry; - - const unbound_generateTrackList = (chunks, { - getArtistString, - html, - language, - link, - }) => - html.tag('dl', - chunks.flatMap(({date, album, chunk, duration}) => [ - html.tag('dt', - date && duration ? - language.$('artistPage.creditList.album.withDate.withDuration', { - album: link.album(album), - date: language.formatDate(date), - duration: language.formatDuration(duration, { - approximate: true, - }), - }) : - - date ? - language.$('artistPage.creditList.album.withDate', { - album: link.album(album), - date: language.formatDate(date), - }) : - - duration ? - language.$('artistPage.creditList.album.withDuration', { - album: link.album(album), - duration: language.formatDuration(duration, { - approximate: true, - }), - }) : - - language.$('artistPage.creditList.album', { - album: link.album(album), - })), - - html.tag('dd', - html.tag('ul', - chunk - .map(({track, ...props}) => ({ - original: track.originalReleaseTrack, - entry: language.$('artistPage.creditList.entry.track.withDuration', { - track: link.track(track), - duration: language.formatDuration(track.duration ?? 0), - }), - ...props, - })) - .map(({original, ...opts}) => - html.tag('li', - {class: original && 'rerelease'}, - generateEntryAccents({ - getArtistString, - language, - original, - ...opts, - }) - ) - ))), - ])); - - const unbound_serializeArtistsAndContrib = - (key, {serializeContribs, serializeLink}) => - (thing) => { - const {artists, contrib} = getArtistsAndContrib(thing, key); - const ret = {}; - ret.link = serializeLink(thing); - if (contrib.what) ret.contribution = contrib.what; - if (!empty(artists)) ret.otherArtists = serializeContribs(artists); - return ret; - }; - - const unbound_serializeTrackListChunks = (chunks, {serializeLink}) => - chunks.map(({date, album, chunk, duration}) => ({ - album: serializeLink(album), - date, - duration, - tracks: chunk.map(({track}) => ({ - link: serializeLink(track), - duration: track.duration, - })), - })); - - const jumpTo = { - tracks: !empty(allTracks), - art: !empty(artThingsAll), - flashes: wikiInfo.enableFlashesAndGames && !empty(flashes), - commentary: !empty(commentaryThings), - }; - - const showJumpTo = Object.values(jumpTo).includes(true); - - const data = { - type: 'data', - path: ['artist', artist.directory], - data: ({serializeContribs, serializeLink}) => { - const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, { - serializeContribs, - serializeLink, - }); - - const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, { - serializeLink, - }); - - return { - albums: { - asCoverArtist: artist.albumsAsCoverArtist - .map(serializeArtistsAndContrib('coverArtistContribs')), - asWallpaperArtist: artist.albumsAsWallpaperArtist - .map(serializeArtistsAndContrib('wallpaperArtistContribs')), - asBannerArtist: artist.albumsAsBannerArtis - .map(serializeArtistsAndContrib('bannerArtistContribs')), - }, - flashes: wikiInfo.enableFlashesAndGames - ? { - asContributor: artist.flashesAsContributor - .map(flash => getArtistsAndContrib(flash, 'contributorContribs')) - .map(({contrib, thing: flash}) => ({ - link: serializeLink(flash), - contribution: contrib.what, - })), - } - : null, - tracks: { - asArtist: artist.tracksAsArtist - .map(serializeArtistsAndContrib('artistContribs')), - asContributor: artist.tracksAsContributo - .map(serializeArtistsAndContrib('contributorContribs')), - chunked: serializeTrackListChunks(trackListChunks), - }, - }; - }, - }; - - const infoPage = { - type: 'page', - path: ['artist', artist.directory], - page: ({ - fancifyURL, - generateInfoGalleryLinks, - getArtistAvatar, - getArtistString, - html, - link, - language, - transformMultiline, - }) => { - const generateTrackList = bindOpts(unbound_generateTrackList, { - getArtistString, - html, - language, - link, - }); - - return { - title: language.$('artistPage.title', {artist: name}), - - cover: artist.hasAvatar && { - src: getArtistAvatar(artist), - alt: language.$('misc.alt.artistAvatar'), - }, - - main: { - headingMode: 'sticky', +export function pathsForTarget(artist) { + const hasGalleryPage = + !empty(artist.tracksAsCoverArtist) || + !empty(artist.albumsAsCoverArtist); - content: [ - ...html.fragment( - contextNotes && [ - html.tag('p', - language.$('releaseInfo.note')), + return [ + { + type: 'page', + path: ['artist', artist.directory], - html.tag('blockquote', - transformMultiline(contextNotes)), - - html.tag('hr'), - ]), - - !empty(urls) && - html.tag('p', - language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList( - urls.map((url) => fancifyURL(url, {language})) - ), - })), - - hasGallery && - html.tag('p', - language.$('artistPage.viewArtGallery', { - link: link.artistGallery(artist, { - text: language.$('artistPage.viewArtGallery.link'), - }), - })), - - showJumpTo && - html.tag('p', - language.$('misc.jumpTo.withLinks', { - links: language.formatUnitList( - [ - jumpTo.tracks && - html.tag('a', - {href: '#tracks'}, - language.$('artistPage.trackList.title')), - - jumpTo.art && - html.tag('a', - {href: '#art'}, - language.$('artistPage.artList.title')), - - jumpTo.flashes && - html.tag('a', - {href: '#flashes'}, - language.$('artistPage.flashList.title')), - - jumpTo.commentary && - html.tag('a', - {href: '#commentary'}, - language.$('artistPage.commentaryList.title')), - ].filter(Boolean)), - })), - - ...html.fragment( - !empty(allTracks) && [ - html.tag('h2', - {id: 'tracks', class: ['content-heading']}, - language.$('artistPage.trackList.title')), - - totalDuration > 0 && - html.tag('p', - language.$('artistPage.contributedDurationLine', { - artist: artist.name, - duration: language.formatDuration( - totalDuration, - { - approximate: true, - unit: true, - } - ), - })), - - !empty(musicGroups) && - html.tag('p', - language.$('artistPage.musicGroupsLine', { - groups: language.formatUnitList( - musicGroups.map(({group, contributions}) => - language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: - language.countContributions( - contributions - ), - }) - ) - ), - })), - - generateTrackList(trackListChunks), - ]), - - ...html.fragment( - !empty(artThingsAll) && [ - html.tag('h2', - {id: 'art', class: ['content-heading']}, - language.$('artistPage.artList.title')), - - hasGallery && - html.tag('p', - language.$('artistPage.viewArtGallery.orBrowseList', { - link: link.artistGallery(artist, { - text: language.$('artistPage.viewArtGallery.link'), - }) - })), - - !empty(artGroups) && - html.tag('p', - language.$('artistPage.artGroupsLine', { - groups: language.formatUnitList( - artGroups.map(({group, contributions}) => - language.$('artistPage.groupsLine.item', { - group: link.groupInfo(group), - contributions: - language.countContributions( - contributions - ), - }) - ) - ), - })), - - html.tag('dl', - artListChunks.flatMap(({date, album, chunk}) => [ - html.tag('dt', language.$('artistPage.creditList.album.withDate', { - album: link.album(album), - date: language.formatDate(date), - })), - - html.tag('dd', - html.tag('ul', - chunk - .map(({track, key, ...props}) => ({ - ...props, - entry: - track - ? language.$('artistPage.creditList.entry.track', { - track: link.track(track), - }) - : html.tag('i', - language.$('artistPage.creditList.entry.album.' + { - wallpaperArtistContribs: - 'wallpaperArt', - bannerArtistContribs: - 'bannerArt', - coverArtistContribs: - 'coverArt', - }[key])), - })) - .map((opts) => generateEntryAccents({ - getArtistString, - language, - ...opts, - })) - .map(row => html.tag('li', row)))), - ])), - ]), - - ...html.fragment( - wikiInfo.enableFlashesAndGames && - !empty(flashes) && [ - html.tag('h2', - {id: 'flashes', class: ['content-heading']}, - language.$('artistPage.flashList.title')), - - html.tag('dl', - flashListChunks.flatMap(({ - act, - chunk, - dateFirst, - dateLast, - }) => [ - html.tag('dt', - language.$('artistPage.creditList.flashAct.withDateRange', { - act: link.flash(chunk[0].flash, { - text: act.name, - }), - dateRange: language.formatDateRange( - dateFirst, - dateLast - ), - })), - - html.tag('dd', - html.tag('ul', - chunk - .map(({flash, ...props}) => ({ - ...props, - entry: language.$('artistPage.creditList.entry.flash', { - flash: link.flash(flash), - }), - })) - .map(opts => generateEntryAccents({ - getArtistString, - language, - ...opts, - })) - .map(row => html.tag('li', row)))), - ])), - ]), - - ...html.fragment( - !empty(commentaryThings) && [ - html.tag('h2', - {id: 'commentary', class: ['content-heading']}, - language.$('artistPage.commentaryList.title')), - - html.tag('dl', - commentaryListChunks.flatMap(({album, chunk}) => [ - html.tag('dt', - language.$('artistPage.creditList.album', { - album: link.album(album), - })), - - html.tag('dd', - html.tag('ul', - chunk - .map(({track}) => track - ? language.$('artistPage.creditList.entry.track', { - track: link.track(track), - }) - : html.tag('i', - language.$('artistPage.creditList.entry.album.commentary'))) - .map(row => html.tag('li', row)))), - ])), - ]), - ], - }, - - nav: generateNavForArtist(artist, false, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData, - }), - }; + contentFunction: { + name: 'generateArtistInfoPage', + args: [artist], + }, }, - }; - - const galleryPage = hasGallery && { - type: 'page', - path: ['artistGallery', artist.directory], - page: ({ - generateInfoGalleryLinks, - getAlbumCover, - getGridHTML, - getTrackCover, - html, - link, - language, - }) => ({ - title: language.$('artistGalleryPage.title', {artist: name}), - - main: { - classes: ['top-index'], - headingMode: 'static', - content: [ - html.tag('p', - {class: 'quick-info'}, - language.$('artistGalleryPage.infoLine', { - coverArts: language.countCoverArts(artThingsGallery.length, { - unit: true, - }), - })), + hasGalleryPage && { + type: 'page', + path: ['artistGallery', artist.directory], - html.tag('div', - {class: 'grid-listing'}, - getGridHTML({ - entries: artThingsGallery.map((item) => ({item})), - srcFn: (thing) => - thing.album - ? getTrackCover(thing) - : getAlbumCover(thing), - linkFn: (thing, opts) => - thing.album - ? link.track(thing, opts) - : link.album(thing, opts), - })), - ], + contentFunction: { + name: 'generateArtistGalleryPage', + args: [artist], }, - - nav: generateNavForArtist(artist, true, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData, - }), - }), - }; - - return [data, infoPage, galleryPage].filter(Boolean); + }, + ]; } -// Utility functions +/* +const unbound_serializeArtistsAndContrib = + (key, {serializeContribs, serializeLink}) => + (thing) => { + const {artists, contrib} = getArtistsAndContrib(thing, key); + const ret = {}; + ret.link = serializeLink(thing); + if (contrib.what) ret.contribution = contrib.what; + if (!empty(artists)) ret.otherArtists = serializeContribs(artists); + return ret; + }; -function generateNavForArtist(artist, isGallery, hasGallery, { - generateInfoGalleryLinks, - language, - link, - wikiData, -}) { - const {wikiInfo} = wikiData; +const unbound_serializeTrackListChunks = (chunks, {serializeLink}) => + chunks.map(({date, album, chunk, duration}) => ({ + album: serializeLink(album), + date, + duration, + tracks: chunk.map(({track}) => ({ + link: serializeLink(track), + duration: track.duration, + })), + })); + +const data = { + type: 'data', + path: ['artist', artist.directory], + data: ({serializeContribs, serializeLink}) => { + const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, { + serializeContribs, + serializeLink, + }); - const infoGalleryLinks = - hasGallery && - generateInfoGalleryLinks(artist, isGallery, { - link, - language, - linkKeyGallery: 'artistGallery', - linkKeyInfo: 'artist', + const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, { + serializeLink, }); - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiInfo.enableListings && { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title'), + return { + albums: { + asCoverArtist: artist.albumsAsCoverArtist + .map(serializeArtistsAndContrib('coverArtistContribs')), + asWallpaperArtist: artist.albumsAsWallpaperArtist + .map(serializeArtistsAndContrib('wallpaperArtistContribs')), + asBannerArtist: artist.albumsAsBannerArtis + .map(serializeArtistsAndContrib('bannerArtistContribs')), }, - { - html: language.$('artistPage.nav.artist', { - artist: link.artist(artist, {class: 'current'}), - }), + flashes: wikiInfo.enableFlashesAndGames + ? { + asContributor: artist.flashesAsContributor + .map(flash => getArtistsAndContrib(flash, 'contributorContribs')) + .map(({contrib, thing: flash}) => ({ + link: serializeLink(flash), + contribution: contrib.what, + })), + } + : null, + tracks: { + asArtist: artist.tracksAsArtist + .map(serializeArtistsAndContrib('artistContribs')), + asContributor: artist.tracksAsContributo + .map(serializeArtistsAndContrib('contributorContribs')), + chunked: serializeTrackListChunks(trackListChunks), }, - hasGallery && { - divider: false, - html: `(${infoGalleryLinks})`, - }, - ], - }; -} + }; + }, +}; +*/ diff --git a/src/page/group.js b/src/page/group.js index 81e1728d..4d5f91c8 100644 --- a/src/page/group.js +++ b/src/page/group.js @@ -15,307 +15,28 @@ export function targets({wikiData}) { return wikiData.groupData; } -export function write(group, {wikiData}) { - const {listingSpec, wikiInfo} = wikiData; +export function pathsForTarget(group) { + const hasGalleryPage = !empty(group.albums); - const tracks = group.albums.flatMap((album) => album.tracks); - const totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); + return [ + { + type: 'page', + path: ['groupInfo', group.directory], - const albumLines = group.albums.map((album) => ({ - album, - otherGroup: album.groups.find((g) => g !== group), - })); - - const infoPage = { - type: 'page', - path: ['groupInfo', group.directory], - page: ({ - fancifyURL, - generateInfoGalleryLinks, - generateNavigationLinks, - getLinkThemeString, - getThemeString, - html, - language, - link, - transformMultiline, - }) => ({ - title: language.$('groupInfoPage.title', {group: group.name}), - - themeColor: group.color, - theme: getThemeString(group.color), - - main: { - headingMode: 'sticky', - - content: [ - !empty(group.urls) && - html.tag('p', - language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList( - group.urls.map(url => fancifyURL(url, {language}))), - })), - - group.description && - html.tag('blockquote', - transformMultiline(group.description)), - - ...html.fragment( - !empty(group.albums) && [ - html.tag('h2', - {class: ['content-heading']}, - language.$('groupInfoPage.albumList.title')), - - html.tag('p', - language.$('groupInfoPage.viewAlbumGallery', { - link: link.groupGallery(group, { - text: language.$('groupInfoPage.viewAlbumGallery.link'), - }), - })), - - html.tag('ul', - albumLines.map(({album, otherGroup}) => { - const item = album.date - ? language.$('groupInfoPage.albumList.item', { - year: album.date.getFullYear(), - album: link.album(album), - }) - : language.$('groupInfoPage.albumList.item.withoutYear', { - album: link.album(album), - }); - return html.tag('li', - otherGroup - ? language.$('groupInfoPage.albumList.item.withAccent', { - item, - accent: html.tag('span', - {class: 'other-group-accent'}, - language.$('groupInfoPage.albumList.item.otherGroupAccent', { - group: link.groupInfo(otherGroup, { - color: false, - }), - })), - }) - : item); - })), - ]), - ], - }, - - sidebarLeft: generateGroupSidebar(group, false, { - getLinkThemeString, - html, - language, - link, - wikiData, - }), - - nav: generateGroupNav(group, false, { - generateInfoGalleryLinks, - generateNavigationLinks, - language, - link, - wikiData, - }), - }), - }; - - const galleryPage = !empty(group.albums) && { - type: 'page', - path: ['groupGallery', group.directory], - page: ({ - generateInfoGalleryLinks, - generateNavigationLinks, - getAlbumCover, - getAlbumGridHTML, - getCarouselHTML, - getLinkThemeString, - getThemeString, - html, - language, - link, - }) => ({ - title: language.$('groupGalleryPage.title', {group: group.name}), - - themeColor: group.color, - theme: getThemeString(group.color), - - main: { - classes: ['top-index'], - headingMode: 'static', - - content: [ - getCarouselHTML({ - items: group.featuredAlbums.slice(0, 12 + 1), - srcFn: getAlbumCover, - linkFn: link.album, - }), - - html.tag('p', - {class: 'quick-info'}, - language.$('groupGalleryPage.infoLine', { - tracks: html.tag('b', - language.countTracks(tracks.length, { - unit: true, - })), - albums: html.tag('b', - language.countAlbums(group.albums.length, { - unit: true, - })), - time: html.tag('b', - language.formatDuration(totalDuration, { - unit: true, - })), - })), - - wikiInfo.enableGroupUI && - wikiInfo.enableListings && - html.tag('p', - {class: 'quick-info'}, - language.$('groupGalleryPage.anotherGroupLine', { - link: link.listing( - listingSpec.find(l => l.directory === 'groups/by-category'), - { - text: language.$('groupGalleryPage.anotherGroupLine.link'), - }), - })), - - html.tag('div', - {class: 'grid-listing'}, - getAlbumGridHTML({ - entries: sortChronologically( - group.albums - .filter(album => album.isListedInGalleries) - .map(album => ({ - item: album, - directory: album.directory, - name: album.name, - date: album.date, - })) - ).reverse(), - details: true, - })), - ], + contentFunction: { + name: 'generateGroupInfoPage', + args: [group], }, + }, - sidebarLeft: generateGroupSidebar(group, true, { - getLinkThemeString, - html, - language, - link, - wikiData, - }), + hasGalleryPage && { + type: 'page', + path: ['groupGallery', group.directory], - nav: generateGroupNav(group, true, { - generateInfoGalleryLinks, - generateNavigationLinks, - language, - link, - wikiData, - }), - }), - }; - - return [infoPage, galleryPage].filter(Boolean); -} - -// Utility functions - -function generateGroupSidebar(currentGroup, isGallery, { - getLinkThemeString, - html, - language, - link, - wikiData, -}) { - const {groupCategoryData, wikiInfo} = wikiData; - - if (!wikiInfo.enableGroupUI) { - return null; - } - - return { - content: [ - html.tag('h1', - language.$('groupSidebar.title')), - - ...groupCategoryData.map((category) => - html.tag('details', - { - open: category === currentGroup.category, - class: category === currentGroup.category && 'current', - }, - [ - html.tag('summary', - {style: getLinkThemeString(category.color)}, - html.tag('span', - language.$('groupSidebar.groupList.category', { - category: `<span class="group-name">${category.name}</span>`, - }))), - html.tag('ul', - category.groups.map((group) => { - const linkKey = ( - isGallery && !empty(group.albums) - ? 'groupGallery' - : 'groupInfo'); - - return html.tag('li', - { - class: group === currentGroup && 'current', - style: getLinkThemeString(group.color), - }, - language.$('groupSidebar.groupList.item', { - group: link[linkKey](group), - })); - })), - ])), - ], - }; -} - -function generateGroupNav(currentGroup, isGallery, { - generateInfoGalleryLinks, - generateNavigationLinks, - link, - language, - wikiData, -}) { - const {groupData, wikiInfo} = wikiData; - - if (!wikiInfo.enableGroupUI) { - return {simple: true}; - } - - const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; - - const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, { - linkKeyGallery: 'groupGallery', - linkKeyInfo: 'groupInfo', - }); - - const previousNextLinks = generateNavigationLinks(currentGroup, { - data: groupData, - linkKey, - }); - - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiInfo.enableListings && { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title'), - }, - { - html: language.$('groupPage.nav.group', { - group: link[linkKey](currentGroup, {class: 'current'}), - }), - }, - { - divider: false, - html: previousNextLinks - ? `(${infoGalleryLinks}; ${previousNextLinks})` - : `(${previousNextLinks})`, + contentFunction: { + name: 'generateGroupGalleryPage', + args: [group], }, - ], - }; + }, + ]; } diff --git a/src/page/index.js b/src/page/index.js index f580cbea..e07c1355 100644 --- a/src/page/index.js +++ b/src/page/index.js @@ -2,52 +2,19 @@ // other modules here! It's not the page spec for the homepage - see // homepage.js for that. // -// Each module published in this list should follow a particular format, -// including any of the following exports: +// (TODO: The docs here from initial draft were totally outdated. +// We don't have docs for the new setup yet. +// Write those!!) // -// condition({wikiData}) -// Returns a boolean indicating whether to process targets/writes (true) or -// skip this page spec altogether (false). This is usually used for -// selectively toggling pages according to site feature flags, though it may -// also be used to e.g. skip out if no targets would be found (preventing -// writeTargetless from generating an empty index page). -// -// targets({wikiData}) -// Gets the objects which this page's write() function should be called on. -// Usually this will simply mean returning the appropriate thingData array, -// but it may also apply filter/map/etc if useful. -// -// write(thing, {wikiData}) -// Provides descriptors for any page and data writes associated with the -// given thing (which will be a value from the targets() array). This -// includes page (HTML) writes, data (JSON) writes, etc. Notably, this -// function does not perform any file operations itself; it only describes -// the operations which will be processed elsewhere, once for each -// translation language. The write function also immediately transforms -// any data which will be reused across writes of the same page, so that -// this data is effectively cached (rather than recalculated for each -// language/write). -// -// writeTargetless({wikiData}) -// Provides descriptors for page/data/etc writes which will be used -// without concern for targets. This is usually used for writing index pages -// which should be generated just once (rather than corresponding to -// targets). -// -// As these modules are effectively the HTML templates for all site layout, -// common patterns may also be exported alongside the special exports above. -// These functions should be referenced only from adjacent modules, as they -// pertain only to site page generation. export * as album from './album.js'; -export * as albumCommentary from './album-commentary.js'; export * as artist from './artist.js'; export * as artistAlias from './artist-alias.js'; -export * as flash from './flash.js'; +// export * as flash from './flash.js'; export * as group from './group.js'; -export * as homepage from './homepage.js'; +// export * as homepage from './homepage.js'; export * as listing from './listing.js'; -export * as news from './news.js'; +// export * as news from './news.js'; export * as static from './static.js'; -export * as tag from './tag.js'; +// export * as tag from './tag.js'; export * as track from './track.js'; diff --git a/src/page/listing.js b/src/page/listing.js index 73c30827..1db7aa7b 100644 --- a/src/page/listing.js +++ b/src/page/listing.js @@ -14,6 +14,29 @@ import {getTotalDuration} from '../util/wiki-data.js'; export const description = `wiki-wide listing pages & index`; +export function targets({wikiData}) { + return ( + wikiData.listingSpec + .filter(listing => listing.contentFunction) + .filter(listing => + !listing.featureFlag || + wikiData.wikiInfo[listing.featureFlag])); +} + +export function pathsForTarget(listing) { + return [ + { + type: 'page', + path: ['listing', listing.directory], + contentFunction: { + name: listing.contentFunction, + args: [listing], + }, + }, + ]; +} + +/* export function condition({wikiData}) { return wikiData.wikiInfo.enableListings; } @@ -274,3 +297,4 @@ function generateLinkIndexForListings(currentListing, forSidebar, { genUL(listings)), ])); } +*/ diff --git a/src/page/static.js b/src/page/static.js index 8572db4e..82330dec 100644 --- a/src/page/static.js +++ b/src/page/static.js @@ -8,26 +8,16 @@ export function targets({wikiData}) { return wikiData.staticPageData; } -export function write(staticPage) { - const page = { - type: 'page', - path: ['staticPage', staticPage.directory], - page: ({ - transformMultiline, - }) => ({ - title: staticPage.name, - stylesheet: staticPage.stylesheet, +export function pathsForTarget(staticPage) { + return [ + { + type: 'page', + path: ['staticPage', staticPage.directory], - main: { - classes: ['long-content'], - headingMode: 'sticky', - - content: transformMultiline(staticPage.content), + contentFunction: { + name: 'generateStaticPage', + args: [staticPage], }, - - nav: {simple: true}, - }), - }; - - return [page]; + }, + ]; } diff --git a/src/page/track.js b/src/page/track.js index b6b03f35..e75b6958 100644 --- a/src/page/track.js +++ b/src/page/track.js @@ -1,553 +1,21 @@ // Track page specification. -import { - generateAlbumChronologyLinks, - generateAlbumNavLinks, - generateAlbumSecondaryNav, - generateAlbumSidebar, - generateAlbumAdditionalFilesList as unbound_generateAlbumAdditionalFilesList, -} from './album.js'; - -import { - bindOpts, - empty, -} from '../util/sugar.js'; - -import { - getTrackCover, - getAlbumListTag, - sortFlashesChronologically, -} from '../util/wiki-data.js'; - export const description = `per-track info pages`; export function targets({wikiData}) { return wikiData.trackData; } -export function write(track, {wikiData}) { - const {wikiInfo} = wikiData; - - const { - album, - contributorContribs, - referencedByTracks, - referencedTracks, - sampledByTracks, - sampledTracks, - otherReleases, - } = track; - - const listTag = getAlbumListTag(album); - - let flashesThatFeature; - if (wikiInfo.enableFlashesAndGames) { - flashesThatFeature = sortFlashesChronologically( - [track, ...otherReleases].flatMap((track) => - track.featuredInFlashes.map((flash) => ({ - flash, - as: track, - directory: flash.directory, - name: flash.name, - date: flash.date, - })) - ) - ); - } - - const unbound_getTrackItem = (track, { - getArtistString, - html, - language, - link, - }) => - html.tag('li', - language.$('trackList.item.withArtists', { - track: link.track(track), - by: html.tag('span', - {class: 'by'}, - language.$('trackList.item.withArtists.by', { - artists: getArtistString(track.artistContribs), - })), - })); - - const hasCommentary = - track.commentary || otherReleases.some((t) => t.commentary); +export function pathsForTarget(track) { + return [ + { + type: 'page', + path: ['track', track.directory], - const hasAdditionalFiles = !empty(track.additionalFiles); - const hasSheetMusicFiles = !empty(track.sheetMusicFiles); - const hasMidiProjectFiles = !empty(track.midiProjectFiles); - const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length; - - const generateCommentary = ({language, link, transformMultiline}) => - transformMultiline([ - track.commentary, - ...otherReleases.map((track) => - track.commentary - ?.split('\n') - .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>')) - .flatMap(line => [ - line, - language.$('releaseInfo.artistCommentary.seeOriginalRelease', { - original: link.track(track), - }), - ]) - .join('\n') - ), - ].filter(Boolean).join('\n')); - - const data = { - type: 'data', - path: ['track', track.directory], - data: ({ - serializeContribs, - serializeCover, - serializeGroupsForTrack, - serializeLink, - }) => ({ - name: track.name, - directory: track.directory, - dates: { - released: track.date, - originallyReleased: track.originalDate, - coverArtAdded: track.coverArtDate, + contentFunction: { + name: 'generateTrackInfoPage', + args: [track], }, - duration: track.duration, - color: track.color, - cover: serializeCover(track, getTrackCover), - artistsContribs: serializeContribs(track.artistContribs), - contributorContribs: serializeContribs(track.contributorContribs), - coverArtistContribs: serializeContribs(track.coverArtistContribs || []), - album: serializeLink(track.album), - groups: serializeGroupsForTrack(track), - references: track.references.map(serializeLink), - referencedBy: track.referencedBy.map(serializeLink), - alsoReleasedAs: otherReleases.map((track) => ({ - track: serializeLink(track), - album: serializeLink(track.album), - })), - }), - }; - - const getSocialEmbedDescription = ({ - getArtistString: _getArtistString, - language, - }) => { - const hasArtists = !empty(track.artistContribs); - const hasCoverArtists = !empty(track.coverArtistContribs); - const getArtistString = (contribs) => - _getArtistString(contribs, { - // We don't want to put actual HTML tags in social embeds (sadly - // they don't get parsed and displayed, generally speaking), so - // override the link argument so that artist "links" just show - // their names. - link: {artist: (artist) => artist.name}, - }); - if (!hasArtists && !hasCoverArtists) return ''; - return language.formatString( - 'trackPage.socialEmbed.body' + - [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] - .filter(Boolean) - .join(''), - Object.fromEntries( - [ - hasArtists && ['artists', getArtistString(track.artistContribs)], - hasCoverArtists && [ - 'coverArtists', - getArtistString(track.coverArtistContribs), - ], - ].filter(Boolean) - ) - ); - }; - - const page = { - type: 'page', - path: ['track', track.directory], - page: ({ - absoluteTo, - fancifyURL, - generateAdditionalFilesList, - generateAdditionalFilesShortcut, - generateChronologyLinks, - generateContentHeading, - generateNavigationLinks, - generateTrackListDividedByGroups, - getAlbumStylesheet, - getArtistString, - getLinkThemeString, - getSizeOfAdditionalFile, - getThemeString, - getTrackCover, - html, - link, - language, - transformLyrics, - transformMultiline, - to, - urls, - }) => { - const getTrackItem = bindOpts(unbound_getTrackItem, { - getArtistString, - html, - language, - link, - }); - - const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, { - [bindOpts.bindIndex]: 2, - generateAdditionalFilesList, - getSizeOfAdditionalFile, - link, - urls, - }); - - return { - title: language.$('trackPage.title', {track: track.name}), - stylesheet: getAlbumStylesheet(album, {to}), - - themeColor: track.color, - theme: - getThemeString(track.color, { - additionalVariables: [ - `--album-directory: ${album.directory}`, - `--track-directory: ${track.directory}`, - ] - }), - - socialEmbed: { - heading: language.$('trackPage.socialEmbed.heading', { - album: track.album.name, - }), - headingLink: absoluteTo('localized.album', album.directory), - title: language.$('trackPage.socialEmbed.title', { - track: track.name, - }), - description: getSocialEmbedDescription({getArtistString, language}), - image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), - color: track.color, - }, - - // disabled for now! shifting banner position per height of page is disorienting - /* - banner: !empty(album.bannerArtistContribs) && { - classes: ['dim'], - dimensions: album.bannerDimensions, - path: ['media.albumBanner', album.directory, album.bannerFileExtension], - alt: language.$('misc.alt.albumBanner'), - position: 'bottom' - }, - */ - - cover: { - src: getTrackCover(track), - alt: language.$('misc.alt.trackCover'), - artTags: track.artTags, - }, - - main: { - headingMode: 'sticky', - - content: [ - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - !empty(track.artistContribs) && - language.$('releaseInfo.by', { - artists: getArtistString(track.artistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - !empty(track.coverArtistContribs) && - language.$('releaseInfo.coverArtBy', { - artists: getArtistString(track.coverArtistContribs, { - showContrib: true, - showIcons: true, - }), - }), - - track.date && - language.$('releaseInfo.released', { - date: language.formatDate(track.date), - }), - - track.hasCoverArt && - track.coverArtDate && - +track.coverArtDate !== +track.date && - language.$('releaseInfo.artReleased', { - date: language.formatDate(track.coverArtDate), - }), - - track.duration && - language.$('releaseInfo.duration', { - duration: language.formatDuration( - track.duration - ), - }), - ]), - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - hasSheetMusicFiles && - language.$('releaseInfo.sheetMusicFiles.shortcut', { - link: html.tag('a', - {href: '#sheet-music-files'}, - language.$('releaseInfo.sheetMusicFiles.shortcut.link')), - }), - - hasMidiProjectFiles && - language.$('releaseInfo.midiProjectFiles.shortcut', { - link: html.tag('a', - {href: '#midi-project-files'}, - language.$('releaseInfo.midiProjectFiles.shortcut.link')), - }), - - hasAdditionalFiles && - generateAdditionalFilesShortcut(track.additionalFiles), - ]), - - html.tag('p', - (empty(track.urls) - ? language.$('releaseInfo.listenOn.noLinks') - : language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList( - track.urls.map(url => fancifyURL(url, {language}))), - }))), - - ...html.fragment( - !empty(otherReleases) && [ - generateContentHeading({ - id: 'also-released-as', - title: language.$('releaseInfo.alsoReleasedAs'), - }), - - html.tag('ul', otherReleases.map(track => - html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', { - track: link.track(track), - album: link.album(track.album), - })))), - ]), - - ...html.fragment( - !empty(contributorContribs) && [ - generateContentHeading({ - id: 'contributors', - title: language.$('releaseInfo.contributors'), - }), - - html.tag('ul', contributorContribs.map(contrib => - html.tag('li', getArtistString([contrib], { - showContrib: true, - showIcons: true, - })))), - ]), - - ...html.fragment( - !empty(referencedTracks) && [ - generateContentHeading({ - id: 'references', - title: - language.$('releaseInfo.tracksReferenced', { - track: html.tag('i', track.name), - }), - }), - - html.tag('ul', referencedTracks.map(getTrackItem)), - ]), - - ...html.fragment( - !empty(referencedByTracks) && [ - generateContentHeading({ - id: 'referenced-by', - title: - language.$('releaseInfo.tracksThatReference', { - track: html.tag('i', track.name), - }), - }), - - generateTrackListDividedByGroups(referencedByTracks, { - getTrackItem, - wikiData, - }), - ]), - - ...html.fragment( - !empty(sampledTracks) && [ - generateContentHeading({ - id: 'samples', - title: - language.$('releaseInfo.tracksSampled', { - track: html.tag('i', track.name), - }), - }), - - html.tag('ul', sampledTracks.map(getTrackItem)), - ]), - - ...html.fragment( - !empty(sampledByTracks) && [ - generateContentHeading({ - id: 'sampled-by', - title: - language.$('releaseInfo.tracksThatSample', { - track: html.tag('i', track.name), - }) - }), - - html.tag('ul', sampledByTracks.map(getTrackItem)), - ]), - - ...html.fragment( - wikiInfo.enableFlashesAndGames && - !empty(flashesThatFeature) && [ - generateContentHeading({ - id: 'featured-in', - title: - language.$('releaseInfo.flashesThatFeature', { - track: html.tag('i', track.name), - }), - }), - - html.tag('ul', flashesThatFeature.map(({flash, as}) => - html.tag('li', - {class: as !== track && 'rerelease'}, - (as === track - ? language.$('releaseInfo.flashesThatFeature.item', { - flash: link.flash(flash), - }) - : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { - flash: link.flash(flash), - track: link.track(as), - }))))), - ]), - - ...html.fragment( - track.lyrics && [ - generateContentHeading({ - id: 'lyrics', - title: language.$('releaseInfo.lyrics'), - }), - - html.tag('blockquote', transformLyrics(track.lyrics)), - ]), - - ...html.fragment( - hasSheetMusicFiles && [ - generateContentHeading({ - id: 'sheet-music-files', - title: language.$('releaseInfo.sheetMusicFiles.heading'), - }), - - generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, { - fileSize: false, - }), - ]), - - ...html.fragment( - hasMidiProjectFiles && [ - generateContentHeading({ - id: 'midi-project-files', - title: language.$('releaseInfo.midiProjectFiles.heading'), - }), - - generateAlbumAdditionalFilesList(album, track.midiProjectFiles), - ]), - - ...html.fragment( - hasAdditionalFiles && [ - generateContentHeading({ - id: 'additional-files', - title: language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: language.countAdditionalFiles(numAdditionalFiles, { - unit: true, - }), - }) - }), - - generateAlbumAdditionalFilesList(album, track.additionalFiles), - ]), - - ...html.fragment( - hasCommentary && [ - generateContentHeading({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary'), - }), - - html.tag('blockquote', generateCommentary({ - link, - language, - transformMultiline, - })), - ]), - ], - }, - - sidebarLeft: generateAlbumSidebar(album, track, { - fancifyURL, - getLinkThemeString, - html, - language, - link, - transformMultiline, - wikiData, - }), - - nav: { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - { - path: ['localized.album', album.directory], - title: album.name, - }, - listTag === 'ol' && - { - html: language.$('trackPage.nav.track.withNumber', { - number: album.tracks.indexOf(track) + 1, - track: link.track(track, {class: 'current', to}), - }), - }, - listTag === 'ul' && - { - html: language.$('trackPage.nav.track', { - track: link.track(track, {class: 'current', to}), - }), - }, - ].filter(Boolean), - - content: generateAlbumChronologyLinks(album, track, { - generateChronologyLinks, - html, - }), - - bottomRowContent: - album.tracks.length > 1 && - generateAlbumNavLinks(album, track, { - generateNavigationLinks, - html, - language, - }), - }, - - secondaryNav: generateAlbumSecondaryNav(album, track, { - getLinkThemeString, - html, - language, - link, - }), - }; }, - }; - - return [data, page]; + ]; } diff --git a/src/static/client.js b/src/static/client.js index efae8501..2f0b6aee 100644 --- a/src/static/client.js +++ b/src/static/client.js @@ -216,6 +216,7 @@ fetch(rebase('data.json', 'rebaseShared')) // Data & info card --------------------------------------- +/* const NORMAL_HOVER_INFO_DELAY = 750; const FAST_HOVER_INFO_DELAY = 250; const END_FAST_HOVER_DELAY = 500; @@ -444,6 +445,7 @@ function addInfoCardLinkHandlers(type) { if (localStorage.tryInfoCards) { addInfoCardLinkHandlers('track'); } +*/ // Custom hash links -------------------------------------- @@ -559,6 +561,7 @@ function prepareStickyHeadings() { } of stickyHeadingInfo) { const coverRevealImage = contentCover?.querySelector('.reveal'); if (coverRevealImage) { + stickyCover.classList.add('content-sticky-heading-cover-needs-reveal'); coverRevealImage.addEventListener('hsmusic-reveal', () => { stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); }); @@ -643,11 +646,17 @@ updateStickyHeading(); // Image overlay ------------------------------------------ function addImageOverlayClickHandlers() { + const container = document.getElementById('image-overlay-container'); + + if (!container) { + console.warn(`#image-overlay-container missing, image overlay module disabled.`); + return; + } + for (const img of document.querySelectorAll('.image-link')) { img.addEventListener('click', handleImageLinkClicked); } - const container = document.getElementById('image-overlay-container'); const actionContainer = document.getElementById('image-overlay-action-container'); container.addEventListener('click', handleContainerClicked); @@ -861,3 +870,37 @@ function loadImage(imageUrl, onprogress) { xhr.send(); }); } + +// Group contributions table ------------------------------ + +const groupContributionsTableInfo = + Array.from(document.querySelectorAll('#content dl')) + .filter(dl => dl.querySelector('a.group-contributions-sort-button')) + .map(dl => ({ + sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), + sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), + sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), + sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), + })); + +function sortGroupContributionsTableBy(info, sort) { + const [showThese, hideThese] = + (sort === 'count' + ? [info.sortingByCountElements, info.sortingByDurationElements] + : [info.sortingByDurationElements, info.sortingByCountElements]); + + for (const element of showThese) element.classList.add('visible'); + for (const element of hideThese) element.classList.remove('visible'); +} + +for (const info of groupContributionsTableInfo) { + info.sortingByCountLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'duration'); + }); + + info.sortingByDurationLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'count'); + }); +} diff --git a/src/static/site3.css b/src/static/site4.css index 3ebe782d..6a23ff40 100644 --- a/src/static/site3.css +++ b/src/static/site4.css @@ -433,11 +433,15 @@ a:hover { text-decoration: underline; } -.nav-main-links > span { +a.current { + font-weight: 800; +} + +.nav-main-links > span > span { white-space: nowrap; } -.nav-main-links > span > a.current { +.nav-main-links > span.current > span.nav-link-content > a { font-weight: 800; } @@ -447,7 +451,7 @@ a:hover { font-weight: 800; } -.nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before { +.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before { content: "\0020/\0020"; } @@ -643,12 +647,29 @@ p code { font-weight: 800; } -blockquote { +#content blockquote { margin-left: 40px; max-width: 600px; margin-right: 0; } +#content blockquote blockquote { + margin-left: 10px; + padding-left: 10px; + margin-right: 20px; + border-left: dotted 1px; + padding-top: 6px; + padding-bottom: 6px; +} + +#content blockquote blockquote > :first-child { + margin-top: 0; +} + +#content blockquote blockquote > :last-child { + margin-bottom: 0; +} + main.long-content .main-content-container, main.long-content > h1 { padding-left: 12%; @@ -718,6 +739,25 @@ li > ul { margin-top: 5px; } +.group-contributions-table { + display: inline-block; +} + +.group-contributions-table .group-contributions-row { + display: flex; + justify-content: space-between; +} + +.group-contributions-table .group-contributions-metrics { + margin-left: 1.5ch; + white-space: nowrap; +} + +.group-contributions-sorted-by-count:not(.visible), +.group-contributions-sorted-by-duration:not(.visible) { + display: none; +} + /* Images */ .image-container { @@ -752,6 +792,11 @@ li > ul { text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); } +.image-inner-area { + width: 100%; + height: 100%; +} + img { object-fit: cover; } @@ -832,11 +877,6 @@ img { margin-bottom: auto; } -.grid-item span { - overflow-wrap: break-word; - hyphens: auto; -} - .grid-item:hover { text-decoration: none; } @@ -847,6 +887,8 @@ img { .grid-item > span { display: block; + overflow-wrap: break-word; + hyphens: auto; } .grid-item > span:not(:first-child) { @@ -857,6 +899,11 @@ img { margin-top: 6px; } +.grid-item > span:not(:first-of-type) { + font-size: 0.9em; + opacity: 0.8; +} + .grid-item:hover > span:first-of-type { text-decoration: underline; } @@ -1358,6 +1405,24 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r contain: paint; } +/* Sticky sidebar */ + +.sidebar-column.sidebar.sticky-column, +.sidebar-column.sidebar.sticky-last, +.sidebar-multiple.sticky-last > .sidebar:last-child, +.sidebar-multiple.sticky-column { + position: sticky; + top: 10px; +} + +.sidebar-multiple.sticky-last { + align-self: stretch; +} + +.sidebar-multiple.sticky-column { + align-self: flex-start; +} + /* Image overlay */ #image-overlay-container { diff --git a/src/strings-default.json b/src/strings-default.json index a075f445..a6614931 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -19,10 +19,16 @@ "count.albums.withUnit.zero": "", "count.albums.withUnit.one": "{ALBUMS} album", "count.albums.withUnit.two": "", - "count.albums.withUnit.two": "", "count.albums.withUnit.few": "", "count.albums.withUnit.many": "", "count.albums.withUnit.other": "{ALBUMS} albums", + "count.artworks": "{ARTWORKS}", + "count.artworks.withUnit.zero": "", + "count.artworks.withUnit.one": "{ARTWORKS} artwork", + "count.artworks.withUnit.two": "", + "count.artworks.withUnit.few": "", + "count.artworks.withUnit.many": "", + "count.artworks.withUnit.other": "{ARTWORKS} artworks", "count.commentaryEntries": "{ENTRIES}", "count.commentaryEntries.withUnit.zero": "", "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", @@ -44,6 +50,13 @@ "count.coverArts.withUnit.few": "", "count.coverArts.withUnit.many": "", "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", + "count.flashes": "{FLASHES}", + "count.flashes.withUnit.zero": "", + "count.flashes.withUnit.one": "{FLASHES} flashes & games", + "count.flashes.withUnit.two": "", + "count.flashes.withUnit.few": "", + "count.flashes.withUnit.many": "", + "count.flashes.withUnit.other": "{FLASHES} flashes & games", "count.timesReferenced": "{TIMES_REFERENCED}", "count.timesReferenced.withUnit.zero": "", "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", @@ -96,14 +109,19 @@ "releaseInfo.viewCommentary.link": "commentary page", "releaseInfo.viewGallery": "View {LINK}!", "releaseInfo.viewGallery.link": "gallery page", + "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!", + "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page", + "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page", "releaseInfo.viewOriginalFile": "View {LINK}.", "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).", "releaseInfo.viewOriginalFile.link": "original file", "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)", "releaseInfo.listenOn": "Listen on {LINKS}.", - "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.", + "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.", "releaseInfo.visitOn": "Visit on {LINKS}.", "releaseInfo.playOn": "Play on {LINKS}.", + "releaseInfo.readCommentary": "Read {LINK}.", + "releaseInfo.readCommentary.link": "artist commentary", "releaseInfo.alsoReleasedAs": "Also released as:", "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", "releaseInfo.contributors": "Contributors:", @@ -120,8 +138,8 @@ "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", "releaseInfo.artTags": "Tags:", "releaseInfo.artTags.inline": "Tags: {TAGS}", - "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}", - "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:", + "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}", + "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files", "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:", "releaseInfo.additionalFiles.entry": "{TITLE}", "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}", @@ -133,10 +151,10 @@ "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.", "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files", "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:", - "releaseInfo.note": "Note:", + "releaseInfo.note": "Context notes:", "trackList.section.withDuration": "{SECTION} ({DURATION}):", - "trackList.group": "{GROUP}:", - "trackList.group.other": "Other", + "trackList.group": "From {GROUP}:", + "trackList.group.fromOther": "From somewhere else:", "trackList.item.withDuration": "({DURATION}) {TRACK}", "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", "trackList.item.withArtists": "{TRACK} {BY}", @@ -214,6 +232,7 @@ "misc.contentWarnings": "cw: {WARNINGS}", "misc.contentWarnings.reveal": "click to show", "misc.albumGrid.details": "({TRACKS}, {TIME})", + "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})", "misc.albumGrid.noCoverArt": "{ALBUM}", "misc.albumGalleryGrid.noCoverArt": "{NAME}", "misc.uiLanguage": "UI Language: {LANGUAGES}", @@ -255,6 +274,7 @@ "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})", "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", "artistPage.creditList.flashAct": "{ACT}", + "artistPage.creditList.flashAct.withDate": "{ACT} ({DATE})", "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", "artistPage.creditList.entry.track": "{TRACK}", "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", @@ -270,7 +290,17 @@ "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}", "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", - "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})", + "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})", + "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})", + "artistPage.groupContributions.title.music": "Contributed music to groups:", + "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:", + "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})", + "artistPage.groupContributions.title.sorting.count": "Sorting by count.", + "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.", + "artistPage.groupContributions.item.countAccent": "({COUNT})", + "artistPage.groupContributions.item.durationAccent": "({DURATION})", + "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})", + "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})", "artistPage.trackList.title": "Tracks", "artistPage.artList.title": "Artworks", "artistPage.flashList.title": "Flashes & Games", @@ -329,8 +359,8 @@ "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki", "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.date": "{DATE}", - "listingPage.listAlbums.byDateAdded.album": "{ALBUM}", + "listingPage.listAlbums.byDateAdded.chunk.title": "{DATE}", + "listingPage.listAlbums.byDateAdded.chunk.item": "{ALBUM}", "listingPage.listArtists.byName.title": "Artists - by Name", "listingPage.listArtists.byName.title.short": "...by Name", "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", @@ -345,16 +375,20 @@ "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", - "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})", + "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})", + "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})", + "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}", + "listingPage.listArtists.byLatest.dateless.title": "These artists' contributions aren't dated:", + "listingPage.listArtists.byLatest.dateless.item": "{ARTIST}", "listingPage.listGroups.byName.title": "Groups - by Name", "listingPage.listGroups.byName.title.short": "...by Name", "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", "listingPage.listGroups.byName.item.gallery": "Gallery", "listingPage.listGroups.byCategory.title": "Groups - by Category", "listingPage.listGroups.byCategory.title.short": "...by Category", - "listingPage.listGroups.byCategory.category": "{CATEGORY}", - "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byCategory.group.gallery": "Gallery", + "listingPage.listGroups.byCategory.chunk.title": "{CATEGORY}", + "listingPage.listGroups.byCategory.chunk.item": "{GROUP} ({GALLERY})", + "listingPage.listGroups.byCategory.chunk.item.gallery": "Gallery", "listingPage.listGroups.byAlbums.title": "Groups - by Albums", "listingPage.listGroups.byAlbums.title.short": "...by Albums", "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", @@ -427,6 +461,7 @@ "listingPage.other.randomPages.title.short": "Random Pages", "listingPage.misc.trackContributors": "Track Contributors", "listingPage.misc.artContributors": "Art Contributors", + "listingPage.misc.flashContributors": "Flash & Game Contributors", "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", "newsIndex.title": "News", "newsIndex.entry.viewRest": "(View rest of entry!)", diff --git a/src/upd8.js b/src/upd8.js index 9f54b3bb..366dc21b 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -772,6 +772,7 @@ async function main() { developersComment, getSizeOfAdditionalFile, getSizeOfImageFile, + niceShowAggregate, }); } diff --git a/src/util/html.js b/src/util/html.js index 2db1f2eb..2468b8db 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,5 +1,10 @@ // Some really simple functions for formatting HTML content. +import {inspect} from 'util'; + +import * as commonValidators from '../data/things/validators.js'; +import {empty} from './sugar.js'; + // COMPREHENSIVE! // https://html.spec.whatwg.org/multipage/syntax.html#void-elements export const selfClosingTags = [ @@ -38,120 +43,790 @@ export const joinChildren = Symbol(); // or when there are multiple children. export const noEdgeWhitespace = Symbol(); -export function tag(tagName, ...args) { - const selfClosing = selfClosingTags.includes(tagName); +// Note: This is only guaranteed to return true for blanks (as returned by +// html.blank()) and false for Tags and Templates (regardless of contents or +// other properties). Don't depend on this to match any other values. +export function isBlank(value) { + if (isTag(value)) { + return false; + } + + if (isTemplate(value)) { + return false; + } + + if (!Array.isArray(value)) { + return false; + } + + return value.length === 0; +} + +export function isTag(value) { + return value instanceof Tag; +} + +export function isTemplate(value) { + return value instanceof Template; +} + +export function isHTML(value) { + if (typeof value === 'string') { + return true; + } + + if (value === null || value === undefined || value === false) { + return true; + } + + if (isBlank(value) || isTag(value) || isTemplate(value)) { + return true; + } + + if (Array.isArray(value)) { + if (value.every(isHTML)) { + return true; + } + } + + return false; +} + +export function isAttributes(value) { + if (typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + if (value === null) { + return false; + } + + if (isTag(value) || isTemplate(value)) { + return false; + } + + // TODO: Validate attribute values (just the general shape) + + return true; +} + +export const validators = { + // TODO: Move above implementations here and detail errors + + isBlank(value) { + if (!isBlank(value)) { + throw new TypeError(`Expected html.blank()`); + } + + return true; + }, + + isTag(value) { + if (!isTag(value)) { + throw new TypeError(`Expected HTML tag`); + } + + return true; + }, + + isTemplate(value) { + if (!isTemplate(value)) { + throw new TypeError(`Expected HTML template`); + } + + return true; + }, + + isHTML(value) { + if (!isHTML(value)) { + throw new TypeError(`Expected HTML content`); + } + + return true; + }, + + isAttributes(value) { + if (!isAttributes(value)) { + throw new TypeError(`Expected HTML attributes`); + } + + return true; + }, +}; - let openTag; +export function blank() { + return []; +} + +export function tag(tagName, ...args) { let content; - let attrs; + let attributes; - if (typeof args[0] === 'object' && !Array.isArray(args[0])) { - attrs = args[0]; + if ( + typeof args[0] === 'object' && + !(Array.isArray(args[0]) || + args[0] instanceof Tag || + args[0] instanceof Template) + ) { + attributes = args[0]; content = args[1]; } else { content = args[0]; } - if (selfClosing && content) { - throw new Error(`Tag <${tagName}> is self-closing but got content!`); + return new Tag(tagName, attributes, content); +} + +export function tags(content) { + return new Tag(null, null, content); +} + +export class Tag { + #tagName = ''; + #content = null; + #attributes = null; + + constructor(tagName, attributes, content) { + this.tagName = tagName; + this.attributes = attributes; + this.content = content; + } + + clone() { + return new Tag(this.tagName, this.attributes, this.content); + } + + set tagName(value) { + if (value === undefined || value === null) { + this.tagName = ''; + return; + } + + if (typeof value !== 'string') { + throw new Error(`Expected tagName to be a string`); + } + + if (selfClosingTags.includes(value) && this.content.length) { + throw new Error(`Tag <${value}> is self-closing but this tag has content`); + } + + this.#tagName = value; + } + + get tagName() { + return this.#tagName; + } + + set attributes(attributes) { + if (attributes instanceof Attributes) { + this.#attributes = attributes; + } else { + this.#attributes = new Attributes(attributes); + } + } + + get attributes() { + if (this.#attributes === null) { + this.attributes = {}; + } + + return this.#attributes; + } + + set content(value) { + if ( + this.selfClosing && + !(value === null || + value === undefined || + !Boolean(value) || + Array.isArray(value) && value.filter(Boolean).length === 0) + ) { + throw new Error(`Tag <${this.tagName}> is self-closing but got content`); + } + + let contentArray; + + if (Array.isArray(value)) { + contentArray = value; + } else { + contentArray = [value]; + } + + this.#content = contentArray + .flat(Infinity) + .filter(Boolean); + + this.#content.toString = () => this.#stringifyContent(); + } + + get content() { + if (this.#content === null) { + this.#content = []; + } + + return this.#content; + } + + get selfClosing() { + if (this.tagName) { + return selfClosingTags.includes(this.tagName); + } else { + return false; + } + } + + #setAttributeFlag(attribute, value) { + if (value) { + this.attributes.set(attribute, true); + } else { + this.attributes.remove(attribute); + } + } + + #getAttributeFlag(attribute) { + return !!this.attributes.get(attribute); } - if (Array.isArray(content)) { - if (content.some(item => Array.isArray(item))) { - throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`); + #setAttributeString(attribute, value) { + // Note: This function accepts and records the empty string ('') + // distinctly from null/undefined. + + if (value === undefined || value === null) { + this.attributes.remove(attribute); + return undefined; + } else { + this.attributes.set(attribute, String(value)); + } + } + + #getAttributeString(attribute) { + const value = this.attributes.get(attribute); + + if (value === undefined || value === null) { + return undefined; + } else { + return String(value); + } + } + + set onlyIfContent(value) { + this.#setAttributeFlag(onlyIfContent, value); + } + + get onlyIfContent() { + return this.#getAttributeFlag(onlyIfContent); + } + + set joinChildren(value) { + this.#setAttributeString(joinChildren, value); + } + + get joinChildren() { + return this.#getAttributeString(joinChildren); + } + + set noEdgeWhitespace(value) { + this.#setAttributeFlag(noEdgeWhitespace, value); + } + + get noEdgeWhitespace() { + return this.#getAttributeFlag(noEdgeWhitespace); + } + + toString() { + const attributesString = this.attributes.toString(); + const contentString = this.content.toString(); + + if (this.onlyIfContent && !contentString) { + return ''; + } + + if (!this.tagName) { + return contentString; + } + + const openTag = (attributesString + ? `<${this.tagName} ${attributesString}>` + : `<${this.tagName}>`); + + if (this.selfClosing) { + return openTag; + } + + const closeTag = `</${this.tagName}>`; + + if (!this.content.length) { + return openTag + closeTag; } - const joiner = attrs?.[joinChildren]; - content = content.filter(Boolean).join( - (joiner === '' + if (!contentString.includes('\n')) { + return openTag + contentString + closeTag; + } + + const parts = [ + openTag, + contentString + .split('\n') + .map((line, i) => + (i === 0 && this.noEdgeWhitespace + ? line + : ' ' + line)) + .join('\n'), + closeTag, + ]; + + return parts.join( + (this.noEdgeWhitespace ? '' - : (joiner - ? `\n${joiner}\n` - : '\n'))); + : '\n')); } - if (attrs?.[onlyIfContent] && !content) { - return ''; + #stringifyContent() { + if (this.selfClosing) { + return ''; + } + + const joiner = + (this.joinChildren === undefined + ? '\n' + : (this.joinChildren === '' + ? '' + : `\n${this.joinChildren}\n`)); + + return this.content + .map(item => item.toString()) + .filter(Boolean) + .join(joiner); } - if (attrs) { - const attrString = attributes(attrs); - if (attrString) { - openTag = `${tagName} ${attrString}`; + [inspect.custom]() { + if (this.tagName) { + if (empty(this.content)) { + return `Tag <${this.tagName} />`; + } else { + return `Tag <${this.tagName}> (${this.content.length} items)`; + } + } else { + if (empty(this.content)) { + return `Tag (no name)`; + } else { + return `Tag (no name, ${this.content.length} items)`; + } } } +} + +export class Attributes { + #attributes = Object.create(null); + + constructor(attributes) { + this.attributes = attributes; + } + + set attributes(value) { + if (value === undefined || value === null) { + this.#attributes = {}; + return; + } + + if (typeof value !== 'object') { + throw new Error(`Expected attributes to be an object`); + } + + this.#attributes = Object.create(null); + Object.assign(this.#attributes, value); + } - if (!openTag) { - openTag = tagName; + get attributes() { + return this.#attributes; } - if (content) { - if (content.includes('\n')) { - return [ - `<${openTag}>`, - content - .split('\n') - .map((line, i) => - (i === 0 && attrs?.[noEdgeWhitespace] - ? line - : ' ' + line)) - .join('\n'), - `</${tagName}>`, - ].join( - (attrs?.[noEdgeWhitespace] - ? '' - : '\n')); + set(attribute, value) { + if (value === null || value === undefined) { + this.remove(attribute); } else { - return `<${openTag}>${content}</${tagName}>`; + this.#attributes[attribute] = value; } - } else if (selfClosing) { - return `<${openTag}>`; - } else { - return `<${openTag}></${tagName}>`; + return value; + } + + get(attribute) { + return this.#attributes[attribute]; + } + + remove(attribute) { + return delete this.#attributes[attribute]; + } + + toString() { + return Object.entries(this.attributes) + .map(([key, val]) => { + if (typeof val === 'undefined' || val === null) + return [key, val, false]; + else if (typeof val === 'string') + return [key, val, true]; + else if (typeof val === 'boolean') + return [key, val, val]; + else if (typeof val === 'number') + return [key, val.toString(), true]; + else if (Array.isArray(val)) + return [key, val.filter(Boolean).join(' '), val.length > 0]; + else + throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); + }) + .filter(([_key, _val, keep]) => keep) + .map(([key, val]) => + typeof val === 'boolean' + ? `${key}` + : `${key}="${this.#escapeAttributeValue(val)}"` + ) + .join(' '); + } + + #escapeAttributeValue(value) { + return value + .replaceAll('"', '"') + .replaceAll("'", '''); } } -export function escapeAttributeValue(value) { - return value.replaceAll('"', '"').replaceAll("'", '''); +export function template(description) { + return new Template(description); } -export function attributes(attribs) { - return Object.entries(attribs) - .map(([key, val]) => { - if (typeof val === 'undefined' || val === null) - return [key, val, false]; - else if (typeof val === 'string') - return [key, val, true]; - else if (typeof val === 'boolean') - return [key, val, val]; - else if (typeof val === 'number') - return [key, val.toString(), true]; - else if (Array.isArray(val)) - return [key, val.filter(Boolean).join(' '), val.length > 0]; - else - throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); - }) - .filter(([_key, _val, keep]) => keep) - .map(([key, val]) => - typeof val === 'boolean' - ? `${key}` - : `${key}="${escapeAttributeValue(val)}"` - ) - .join(' '); +export class Template { + #description = {}; + #slotValues = {}; + + constructor(description) { + if (!description[Stationery.validated]) { + Template.validateDescription(description); + } + + this.#description = description; + } + + clone() { + const clone = new Template(this.#description); + clone.setSlots(this.#slotValues); + return clone; + } + + static validateDescription(description) { + if (typeof description !== 'object') { + throw new TypeError(`Expected object, got ${typeof description}`); + } + + if (description === null) { + throw new TypeError(`Expected object, got null`); + } + + const topErrors = []; + + if (!('content' in description)) { + topErrors.push(new TypeError(`Expected description.content`)); + } else if (typeof description.content !== 'function') { + topErrors.push(new TypeError(`Expected description.content to be function`)); + } + + if ('annotation' in description) { + if (typeof description.annotation !== 'string') { + topErrors.push(new TypeError(`Expected annotation to be string`)); + } + } + + if ('slots' in description) validateSlots: { + if (typeof description.slots !== 'object') { + topErrors.push(new TypeError(`Expected description.slots to be object`)); + break validateSlots; + } + + try { + this.validateSlotsDescription(description.slots); + } catch (slotError) { + topErrors.push(slotError); + } + } + + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (typeof description.annotation === 'string' + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } + + return true; + } + + static validateSlotsDescription(slots) { + const slotErrors = []; + + for (const [slotName, slotDescription] of Object.entries(slots)) { + if (typeof slotDescription !== 'object' || slotDescription === null) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); + continue; + } + + if ('default' in slotDescription) validateDefault: { + if ( + slotDescription.default === undefined || + slotDescription.default === null + ) { + slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); + break validateDefault; + } + + try { + Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); + } catch (error) { + error.message = `(${slotName}) Error validating slot default value: ${error.message}`; + slotErrors.push(error); + } + } + + if ('validate' in slotDescription && 'type' in slotDescription) { + slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); + } else if (!('validate' in slotDescription || 'type' in slotDescription)) { + slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); + } else if ('validate' in slotDescription) { + if (typeof slotDescription.validate !== 'function') { + slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); + } + } else if ('type' in slotDescription) { + const acceptableSlotTypes = [ + 'string', + 'number', + 'bigint', + 'boolean', + 'symbol', + 'html', + ]; + + if (slotDescription.type === 'function') { + slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); + } else if (slotDescription.type === 'object') { + slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); + } else if (!acceptableSlotTypes.includes(slotDescription.type)) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); + } + } + } + + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, `Errors in slot descriptions`); + } + + return true; + } + + slot(slotName, value) { + this.setSlot(slotName, value); + return this; + } + + slots(slotNamesToValues) { + this.setSlots(slotNamesToValues); + return this; + } + + setSlot(slotName, value) { + const description = this.#getSlotDescriptionOrError(slotName); + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}` + : `Error validating template slot "${slotName}" value: ${error.message}`); + throw error; + } + + this.#slotValues[slotName] = value; + } + + setSlots(slotNamesToValues) { + if ( + typeof slotNamesToValues !== 'object' || + Array.isArray(slotNamesToValues) || + slotNamesToValues === null + ) { + throw new TypeError(`Expected object mapping of slot names to values`); + } + + const slotErrors = []; + + for (const [slotName, value] of Object.entries(slotNamesToValues)) { + const description = this.#getSlotDescriptionNoError(slotName); + if (!description) { + slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`)); + continue; + } + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = `(${slotName}) ${error.message}`; + slotErrors.push(error); + } + } + + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slots` + : `Error validating template slots`)); + } + + Object.assign(this.#slotValues, slotNamesToValues); + } + + static validateSlotValueAgainstDescription(value, description) { + if (value === undefined) { + throw new TypeError(`Specify value as null or don't specify at all`); + } + + // Null is always an acceptable slot value. + if (value !== null) { + if ('validate' in description) { + description.validate({ + ...commonValidators, + ...validators, + })(value); + } + + if ('type' in description) { + const {type} = description; + if (type === 'html') { + if (!isHTML(value)) { + throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`); + } + } else { + if (typeof value !== type) { + throw new TypeError(`Slot expects ${type}, got ${typeof value}`); + } + } + } + } + + return true; + } + + getSlotValue(slotName) { + const description = this.#getSlotDescriptionOrError(slotName); + const providedValue = this.#slotValues[slotName] ?? null; + + if (description.type === 'html') { + if (!providedValue) { + return blank(); + } + + if (providedValue instanceof Tag || providedValue instanceof Template) { + return providedValue.clone(); + } + + return providedValue; + } + + if (providedValue !== null) { + return providedValue; + } + + if ('default' in description) { + return description.default; + } + + return null; + } + + getSlotDescription(slotName) { + return this.#getSlotDescriptionOrError(slotName); + } + + #getSlotDescriptionNoError(slotName) { + if (this.#description.slots) { + if (Object.hasOwn(this.#description.slots, slotName)) { + return this.#description.slots[slotName]; + } + } + + return null; + } + + #getSlotDescriptionOrError(slotName) { + const description = this.#getSlotDescriptionNoError(slotName); + + if (!description) { + throw new TypeError( + (this.description.annotation + ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot` + : `Template doesn't have a "${slotName}" slot`)); + } + + return description; + } + + set content(_value) { + throw new Error(`Template content can't be changed after constructed`); + } + + get content() { + const slots = {}; + + for (const slotName of Object.keys(this.description.slots ?? {})) { + slots[slotName] = this.getSlotValue(slotName); + } + + return this.description.content(slots); + } + + set description(_value) { + throw new Error(`Template description can't be changed after constructed`); + } + + get description() { + return this.#description; + } + + toString() { + return this.content.toString(); + } + + [inspect.custom]() { + const {annotation} = this.description; + if (annotation) { + return `Template "${annotation}"`; + } else { + return `Template (no annotation)`; + } + } +} + +export function stationery(description) { + return new Stationery(description); } -// Ensures the passed value is an array of elements, for usage in [...spread] -// syntax. This may be used when it's not guaranteed whether the return value of -// an external function is one child or an array, or in combination with -// conditionals, e.g. fragment(cond && [x, y, z]). -export function fragment(childOrChildren) { - if (!childOrChildren) { - return []; +export class Stationery { + #templateDescription = null; + + static validated = Symbol('Stationery.validated'); + + constructor(templateDescription) { + Template.validateDescription(templateDescription); + templateDescription[Stationery.validated] = true; + this.#templateDescription = templateDescription; } - if (Array.isArray(childOrChildren)) { - return childOrChildren; + template() { + return new Template(this.#templateDescription); } - return [childOrChildren]; + [inspect.custom]() { + const {annotation} = this.#templateDescription; + if (annotation) { + return `Stationery "${annotation}"`; + } else { + return `Stationery (no annotation)`; + } + } } diff --git a/src/util/link.js b/src/util/link.js index 62106345..a9f79c8b 100644 --- a/src/util/link.js +++ b/src/util/link.js @@ -24,23 +24,29 @@ export function unbound_getLinkThemeString(color, { const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/; -const linkHelper = - (hrefFn, { - color = true, - attr = null, - } = {}) => - (thing, { +function linkHelper({ + path: pathOption, + + expectThing = true, + color: colorOption = true, + + attr: attrOption = null, + data: dataOption = null, + text: textOption = null, +}) { + const generateLink = (data, { getLinkThemeString, to, text = '', attributes = null, class: className = '', - color: color2 = true, + color = true, hash = '', preferShortName = false, }) => { - let href = hrefFn(thing, {to}); + const path = (expectThing ? pathOption(data) : pathOption()); + let href = to(...path); if (link.globalOptions.appendIndexHTML) { if (appendIndexHTMLRegex.test(href)) { @@ -52,41 +58,100 @@ const linkHelper = href += (hash.startsWith('#') ? '' : '#') + hash; } - return html.tag( - 'a', + return html.tag('a', { - ...(attr ? attr(thing) : {}), + ...(attrOption ? attrOption(data) : {}), ...(attributes ? attributes : {}), href, style: - typeof color2 === 'string' - ? getLinkThemeString(color2) - : color2 && color - ? getLinkThemeString(thing.color) + typeof color === 'string' + ? getLinkThemeString(color) + : color && colorOption + ? getLinkThemeString(data.color) : '', class: className, }, + (text || - (preferShortName - ? thing.nameShort ?? thing.name - : thing.name)) - ); + (textOption + ? textOption(data) + : (preferShortName + ? data.nameShort ?? data.name + : data.name)))); + }; + + generateLink.data = thing => { + if (!expectThing) { + throw new Error(`This kind of link doesn't need any data serialized`); + } + + const data = (dataOption ? dataOption(thing) : {}); + + if (colorOption) { + data.color = thing.color; + } + + if (!textOption) { + data.name = thing.name; + data.nameShort = thing.nameShort ?? thing.name; + } + + return data; }; -const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => - linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { - attr: (thing) => ({ - ...(attr ? attr(thing) : {}), - ...(expose ? {[expose]: thing.directory} : {}), + return generateLink; +} + +function linkDirectory(key, { + exposeDirectory = null, + prependLocalized = true, + + data = null, + attr = null, + ...conf +} = {}) { + return linkHelper({ + data: thing => ({ + ...(data ? data(thing) : {}), + directory: thing.directory, }), + + path: data => + (prependLocalized + ? ['localized.' + key, data.directory] + : [key, data.directory]), + + attr: (data) => ({ + ...(attr ? attr(data) : {}), + ...(exposeDirectory ? {[exposeDirectory]: data.directory} : {}), + }), + ...conf, }); +} -const linkPathname = (key, conf) => - linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); +function linkIndex(key, conf) { + return linkHelper({ + path: () => [key], -const linkIndex = (key, conf) => - linkHelper((_, {to}) => to('localized.' + key), conf); + expectThing: false, + ...conf, + }); +} + +function linkAdditionalFile(key, conf) { + return linkHelper({ + data: ({file, album}) => ({ + directory: album.directory, + file, + }), + + path: data => ['media.albumAdditionalFile', data.directory, data.file], + + color: false, + ...conf, + }); +} // Mapping of Thing constructor classes to the key for a link.x() function. // These represent a sensible "default" link, i.e. to the primary page for @@ -114,6 +179,7 @@ const link = { }, album: linkDirectory('album'), + albumAdditionalFile: linkAdditionalFile('albumAdditionalFile'), albumGallery: linkDirectory('albumGallery'), albumCommentary: linkDirectory('albumCommentary'), artist: linkDirectory('artist', {color: false}), @@ -130,32 +196,26 @@ const link = { newsEntry: linkDirectory('newsEntry', {color: false}), staticPage: linkDirectory('staticPage', {color: false}), tag: linkDirectory('tag'), - track: linkDirectory('track', {expose: 'data-track'}), - - // TODO: This is a bit hacky. Files are just strings (not objects), so we - // have to manually provide the album alongside the file. They also don't - // follow the usual {name: whatever} type shape, so we have to provide that - // ourselves. - _albumAdditionalFileHelper: linkHelper( - (fakeFileObject, {to}) => - to( - 'media.albumAdditionalFile', - fakeFileObject.album.directory, - fakeFileObject.name), - {color: false}), - - albumAdditionalFile: ({file, album}, {to, ...opts}) => - link._albumAdditionalFileHelper( - { - name: file, - album, - }, - {to, ...opts}), - - media: linkPathname('media.path', {color: false}), - root: linkPathname('shared.path', {color: false}), - data: linkPathname('data.path', {color: false}), - site: linkPathname('localized.path', {color: false}), + track: linkDirectory('track', {exposeDirectory: 'data-track'}), + + media: linkDirectory('media.path', { + prependLocalized: false, + color: false, + }), + + root: linkDirectory('shared.path', { + prependLocalized: false, + color: false, + }), + data: linkDirectory('data.path', { + prependLocalized: false, + color: false, + }), + + site: linkDirectory('localized.path', { + prependLocalized: false, + color: false, + }), // This is NOT an arrow functions because it should be callable for other // "this" objects - i.e, if we bind arguments in other functions on the same diff --git a/src/util/replacer.js b/src/util/replacer.js index ea957eda..50a90004 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -221,11 +221,10 @@ function parseNodes(input, i, stopAt, textOnly) { let hash; if (stop_literal === tagHash) { - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); if (!stopped) throw endOfInput(i, `reading hash`); - - if (!N) throw makeError(i, `Expected content (hash).`); + if (!N) throw makeError(i, `Expected text (hash).`); hash = N; i = stop_iParse; @@ -294,6 +293,10 @@ function parseNodes(input, i, stopAt, textOnly) { } export function parseInput(input) { + if (typeof input !== 'string') { + throw new TypeError(`Expected input to be string, got ${input}`); + } + try { return parseNodes(input, 0); } catch (errorNode) { @@ -378,7 +381,7 @@ function evaluateTag(node, opts) { (transformName && transformName(value.name, node, input)) || null; - const hash = node.data.hash && transformNodes(node.data.hash, opts); + const hash = node.data.hash && transformNode(node.data.hash, opts); const args = node.data.args && diff --git a/src/util/sugar.js b/src/util/sugar.js index 0813c1d4..da21d6d0 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -26,18 +26,24 @@ export function* splitArray(array, fn) { } } -// Null-accepting function to check if an array is empty. Accepts null (and -// treats as empty) as a shorthand for "hey, check if this property is an array -// with/without stuff in it" for objects where properties that are PRESENT but -// don't currently have a VALUE are null (instead of undefined). -export function empty(arrayOrNull) { - if (arrayOrNull === null) { +// Null-accepting function to check if an array or set is empty. Accepts null +// (which is treated as empty) as a shorthand for "hey, check if this property +// is an array with/without stuff in it" for objects where properties that are +// PRESENT but don't currently have a VALUE are null (rather than undefined). +export function empty(value) { + if (value === null) { return true; - } else if (Array.isArray(arrayOrNull)) { - return arrayOrNull.length === 0; - } else { - throw new Error(`Expected array or null`); } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (value instanceof Set) { + return value.size === 0; + } + + throw new Error(`Expected array, set, or null`); } // Repeats all the items of an array a number of times. @@ -67,6 +73,76 @@ export function accumulateSum(array, fn = x => x) { 0); } +// Stitches together the items of separate arrays into one array of objects +// whose keys are the corresponding items from each array at that index. +// This is mostly useful for iterating over multiple arrays at once! +export function stitchArrays(keyToArray) { + const errors = []; + + for (const [key, value] of Object.entries(keyToArray)) { + if (value === null) continue; + if (Array.isArray(value)) continue; + errors.push(new TypeError(`(${key}) Expected array or null, got ${value}`)); + } + + if (!empty(errors)) { + throw new AggregateError(errors, `Expected arrays or null`); + } + + const keys = Object.keys(keyToArray); + const arrays = Object.values(keyToArray).filter(val => Array.isArray(val)); + const length = Math.max(...arrays.map(({length}) => length)); + const results = []; + + for (let i = 0; i < length; i++) { + const object = {}; + for (const key of keys) { + object[key] = + (Array.isArray(keyToArray[key]) + ? keyToArray[key][i] + : null); + } + results.push(object); + } + + return results; +} + +// Turns this: +// +// [ +// [123, 'orange', null], +// [456, 'apple', true], +// [789, 'banana', false], +// [1000, 'pear', undefined], +// ] +// +// Into this: +// +// [ +// [123, 456, 789, 1000], +// ['orange', 'apple', 'banana', 'pear'], +// [null, true, false, undefined], +// ] +// +// And back again, if you call it again on its results. +export function transposeArrays(arrays) { + if (empty(arrays)) { + return []; + } + + const length = arrays[0].length; + const results = new Array(length).fill(null).map(() => []); + + for (const array of arrays) { + for (let i = 0; i < length; i++) { + results[i].push(array[i]); + } + } + + return results; +} + export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); @@ -82,6 +158,24 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); +export function setIntersection(set1, set2) { + const intersection = new Set(); + for (const item of set1) { + if (set2.has(item)) { + intersection.add(item); + } + } + return intersection; +} + +export function filterProperties(obj, properties) { + const set = new Set(properties); + return Object.fromEntries( + Object + .entries(obj) + .filter(([key]) => set.has(key))); +} + export function queue(array, max = 50) { if (max === 0) { return array.map((fn) => fn()); @@ -146,10 +240,20 @@ export function bindOpts(fn, bind) { ]); }; - Object.defineProperty(bound, 'name', { - value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`, + annotateFunction(bound, { + name: fn, + trait: 'options-bound', }); + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) { + if (key === 'length') continue; + if (key === 'name') continue; + if (key === 'arguments') continue; + if (key === 'caller') continue; + if (key === 'prototype') continue; + Object.defineProperty(bound, key, descriptor); + } + return bound; } @@ -216,6 +320,10 @@ export function openAggregate({ ); }; + aggregate.push = (error) => { + errors.push(error); + }; + aggregate.call = (fn, ...args) => { return aggregate.wrap(fn)(...args); }; @@ -421,6 +529,7 @@ export function _withAggregate(mode, aggregateOpts, fn) { export function showAggregate(topError, { pathToFileURL = f => f, showTraces = true, + print = true, } = {}) { const recursive = (error, {level}) => { let header = showTraces @@ -465,7 +574,13 @@ export function showAggregate(topError, { } }; - console.error(recursive(topError, {level: 0})); + const message = recursive(topError, {level: 0}); + + if (print) { + console.error(message); + } else { + return message; + } } export function decorateErrorWithIndex(fn) { @@ -478,3 +593,74 @@ export function decorateErrorWithIndex(fn) { } }; } + +// Delicious function annotations, such as: +// +// (*bound) soWeAreBackInTheMine +// (data *unfulfilled) generateShrekTwo +// +export function annotateFunction(fn, { + name: nameOrFunction = null, + description: newDescription, + trait: newTrait, +}) { + let name; + + if (typeof nameOrFunction === 'function') { + name = nameOrFunction.name; + } else if (typeof nameOrFunction === 'string') { + name = nameOrFunction; + } + + name ??= fn.name ?? 'anonymous'; + + const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/); + + let prefix, suffix, description, trait; + if (match) { + ({prefix, suffix, description, trait} = match.groups); + } + + prefix ??= ''; + suffix ??= name; + description ??= ''; + trait ??= ''; + + if (newDescription) { + if (description) { + description += '; ' + newDescription; + } else { + description = newDescription; + } + } + + if (newTrait) { + if (trait) { + trait += ' #' + newTrait; + } else { + trait = '#' + newTrait; + } + } + + let parenthesesPart; + + if (description && trait) { + parenthesesPart = `${description} ${trait}`; + } else if (description || trait) { + parenthesesPart = description || trait; + } else { + parenthesesPart = ''; + } + + let finalName; + + if (prefix && parenthesesPart) { + finalName = `${prefix} (${parenthesesPart}) ${suffix}`; + } else if (parenthesesPart) { + finalName = `(${parenthesesPart}) ${suffix}`; + } else { + finalName = suffix; + } + + Object.defineProperty(fn, 'name', {value: finalName}); +} diff --git a/src/util/transform-content.js b/src/util/transform-content.js index d1d0f51a..454cb374 100644 --- a/src/util/transform-content.js +++ b/src/util/transform-content.js @@ -3,7 +3,6 @@ // interfaces for converting various content found in wiki data to HTML for // display on the site. -import * as html from './html.js'; export {transformInline} from './replacer.js'; export const replacerSpec = { @@ -34,7 +33,7 @@ export const replacerSpec = { date: { find: null, value: (ref) => new Date(ref), - html: (date, {language}) => + html: (date, {html, language}) => html.tag('time', {datetime: date.toString()}, language.formatDate(date)), diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 89c621c5..a3133748 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -3,6 +3,8 @@ import { accumulateSum, empty, + stitchArrays, + unique, } from './sugar.js'; // Generic value operations @@ -70,6 +72,36 @@ export function chunkByProperties(array, properties) { })); } +export function chunkMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + const newChunk = index => arrays.map(array => [array[index]]); + const results = [newChunk(0)]; + + for (let i = 1; i < arrays[0].length; i++) { + const current = results.at(-1); + + const args = []; + for (let j = 0; j < arrays.length; j++) { + const item = arrays[j][i]; + const previous = current[j].at(-1); + args.push(item, previous); + } + + if (fn(...args)) { + results.push(newChunk(i)); + continue; + } + + for (let j = 0; j < arrays.length; j++) { + current[j].push(arrays[j][i]); + } + } + + return results; +} + // Sorting functions - all utils here are mutating, so make sure to initially // slice/filter/somehow generate a new array from input data if retaining the // initial sort matters! (Spoilers: If what you're doing involves any kind of @@ -117,6 +149,123 @@ export function normalizeName(s) { return s; } +// Sorts multiple arrays by an arbitrary function (which is the last argument). +// Paired values from each array are provided to the callback sequentially: +// +// (a_fromFirstArray, b_fromFirstArray, +// a_fromSecondArray, b_fromSecondArray, +// a_fromThirdArray, b_fromThirdArray) => +// relative positioning (negative, positive, or zero) +// +// Like native single-array sort, this is a mutating function. +export function sortMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + const length = arrays[0].length; + const symbols = new Array(length).fill(null).map(() => Symbol()); + const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index])); + + symbols.sort((a, b) => { + const indexA = indexes[a]; + const indexB = indexes[b]; + + const args = []; + for (let i = 0; i < arrays.length; i++) { + args.push(arrays[i][indexA]); + args.push(arrays[i][indexB]); + } + + return fn(...args); + }); + + for (const array of arrays) { + // Note: We're mutating this array pulling values from itself, but only all + // at once after all those values have been pulled. + array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]])); + } + + return arrays; +} + +// Filters multiple arrays by an arbitrary function (which is the last argument). +// Values from each array are provided to the callback sequentially: +// +// (value_fromFirstArray, +// value_fromSecondArray, +// value_fromThirdArray, +// index, +// [firstArray, secondArray, thirdArray]) => +// true or false +// +// Please be aware that this is a mutating function, unlike native single-array +// filter. The mutated arrays are returned. Also attached under `.removed` are +// corresponding arrays of items filtered out. +export function filterMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + const removed = new Array(arrays.length).fill(null).map(() => []); + + for (let i = arrays[0].length - 1; i >= 0; i--) { + const args = arrays.map(array => array[i]); + args.push(i, arrays); + + if (!fn(...args)) { + for (let j = 0; j < arrays.length; j++) { + const item = arrays[j][i]; + arrays[j].splice(i, 1); + removed[j].unshift(item); + } + } + } + + Object.assign(arrays, {removed}); + return arrays; +} + +// Reduces multiple arrays with an arbitrary function (which is the last +// argument). Note that this reduces into multiple accumulators, one for +// each input array, not just a single value. That's reflected in both the +// callback parameters: +// +// (accumulator1, +// accumulator2, +// value_fromFirstArray, +// value_fromSecondArray, +// index, +// [firstArray, secondArray]) => +// [newAccumulator1, newAccumulator2] +// +// As well as the final return value of reduceMultipleArrays: +// +// [finalAccumulator1, finalAccumulator2] +// +// This is not a mutating function. +export function reduceMultipleArrays(...args) { + const [arrays, fn, initialAccumulators] = + (typeof args.at(-1) === 'function' + ? [args.slice(0, -1), args.at(-1), null] + : [args.slice(0, -2), args.at(-2), args.at(-1)]); + + if (empty(arrays[0])) { + throw new TypeError(`Reduce of empty arrays with no initial value`); + } + + let [accumulators, i] = + (initialAccumulators + ? [initialAccumulators, 0] + : [arrays.map(array => array[0]), 1]); + + for (; i < arrays[0].length; i++) { + const args = [...accumulators, ...arrays.map(array => array[i])]; + args.push(i, arrays); + accumulators = fn(...args); + } + + return accumulators; +} + // Component sort functions - these sort by one particular property, applying // unique particulars where appropriate. Usually you don't want to use these // directly, but if you're making a custom sort they can come in handy. @@ -146,65 +295,126 @@ export function normalizeName(s) { // sortByDirectory will handle the rest, given all directories are unique // except when album and track directories overlap with each other. export function sortByDirectory(data, { - getDirectory = (o) => o.directory, + getDirectory = object => object.directory, } = {}) { - return data.sort((a, b) => { - const ad = getDirectory(a); - const bd = getDirectory(b); - return compareCaseLessSensitive(ad, bd); - }); + const directories = data.map(getDirectory); + + sortMultipleArrays(data, directories, + (a, b, directoryA, directoryB) => + compareCaseLessSensitive(directoryA, directoryB)); + + return data; } export function sortByName(data, { - getName = (o) => o.name, + getName = object => object.name, } = {}) { - const nameMap = new Map(); - const normalizedNameMap = new Map(); - for (const o of data) { - const name = getName(o); - const normalizedName = normalizeName(name); - nameMap.set(o, name); - normalizedNameMap.set(o, normalizedName); - } + const names = data.map(getName); + const normalizedNames = names.map(normalizeName); + + sortMultipleArrays(data, normalizedNames, names, + ( + a, b, + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, + ) => + compareNormalizedNames( + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, + )); - return data.sort((a, b) => { - const ann = normalizedNameMap.get(a); - const bnn = normalizedNameMap.get(b); - const comparison = compareCaseLessSensitive(ann, bnn); - if (comparison !== 0) - return comparison; - - const an = nameMap.get(a); - const bn = nameMap.get(b); - return compareCaseLessSensitive(an, bn); - }); + return data; +} + +export function compareNormalizedNames( + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, +) { + const comparison = compareCaseLessSensitive(normalizedA, normalizedB); + return ( + (comparison === 0 + ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB) + : comparison)); } export function sortByDate(data, { + getDate = object => object.date, latestFirst = false, - getDate = (o) => o.date, } = {}) { - return data.sort((a, b) => { - const ad = getDate(a); - const bd = getDate(b); - - // It's possible for objects with and without dates to be mixed - // together in the same array. If that's the case, we put all items - // without dates at the end. - if (ad && bd) { - return (latestFirst ? bd - ad : ad - bd); - } else if (ad) { - return -1; - } else if (bd) { - return 1; - } else { - // If neither of the items being compared have a date, don't move - // them relative to each other. This is basically the same as - // filtering out all non-date items and then pushing them at the - // end after sorting the rest. - return 0; - } - }); + const dates = data.map(getDate); + + sortMultipleArrays(data, dates, + (a, b, dateA, dateB) => + compareDates(dateA, dateB, {latestFirst})); + + return data; +} + +export function compareDates(a, b, { + latestFirst = false, +} = {}) { + if (a && b) { + return (latestFirst ? b - a : a - b); + } + + // It's possible for objects with and without dates to be mixed + // together in the same array. If that's the case, we put all items + // without dates at the end. + if (a) return -1; + if (b) return 1; + + // If neither of the items being compared have a date, don't move + // them relative to each other. This is basically the same as + // filtering out all non-date items and then pushing them at the + // end after sorting the rest. + return 0; +} + +export function getLatestDate(dates) { + const filtered = dates.filter(Boolean); + if (empty(filtered)) return null; + + return filtered + .reduce( + (accumulator, date) => + date > accumulator ? date : accumulator, + -Infinity); +} + +export function getEarliestDate(dates) { + const filtered = dates.filter(Boolean); + if (empty(filtered)) return null; + + return filtered + .reduce( + (accumulator, date) => + date < accumulator ? date : accumulator, + Infinity); +} + +// Funky sort which takes a data set and a corresponding list of "counts", +// which are really arbitrary numbers representing some property of each data +// object defined by the caller. It sorts and mutates *both* of these, so the +// sorted data will still correspond to the same indexed count. +export function sortByCount(data, counts, { + greatestFirst = false, +} = {}) { + sortMultipleArrays(data, counts, (data1, data2, count1, count2) => + (greatestFirst + ? count2 - count1 + : count1 - count2)); + + return data; +} + +// Corresponding filter function for the above sort. By default, items whose +// corresponding count is zero will be removed. +export function filterByCount(data, counts, { + min = 1, + max = Infinity, +} = {}) { + filterMultipleArrays(data, counts, (data, count) => + count >= min && count <= max); } export function sortByPositionInParent(data, { @@ -315,6 +525,60 @@ export function sortChronologically(data, { return data; } +// This one's a little odd! Sorts an array of {entry, thing} pairs using +// the provided sortFunction, which will operate on each item's `thing`, not +// its entry (or the item as a whole). If multiple entries are associated +// with the same thing, they'll end up bunched together in the output, +// retaining their original relative positioning. +export function sortEntryThingPairs(data, sortFunction) { + const things = unique(data.map(item => item.thing)); + sortFunction(things); + + const outputArrays = []; + const thingToOutputArray = new Map(); + + for (const thing of things) { + const array = []; + thingToOutputArray.set(thing, array); + outputArrays.push(array); + } + + for (const item of data) { + thingToOutputArray.get(item.thing).push(item); + } + + data.splice(0, data.length, ...outputArrays.flat()); + + return data; +} + +/* +// Alternate draft version of sortEntryThingPairs. +// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168 + +// Maps the provided "preparation" function across a list of arbitrary values, +// building up a list of sortable values; sorts these with the provided sorting +// function; and reorders the sources to match their corresponding prepared +// values. As usual, if multiple source items correspond to the same sorting +// data, this retains the source relative positioning. +export function prepareAndSort(sources, prepareForSort, sortFunction) { + const prepared = []; + const preparedToSource = new Map(); + + for (const original of originals) { + const prep = prepareForSort(source); + prepared.push(prep); + preparedToSource.set(prep, source); + } + + sortFunction(prepared); + + sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep))); + + return sources; +} +*/ + // Highly contextual sort functions - these are only for very specific types // of Things, and have appropriately hard-coded behavior. @@ -554,3 +818,65 @@ export function getNewReleases(numReleases, {wikiData}) { .slice(0, numReleases) .map((album) => ({item: album})); } + +// Carousel layout and utilities + +// Layout constants: +// +// Carousels support fitting 4-18 items, with a few "dead" zones to watch out +// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. +// +// Carousels are limited to 1-3 rows and 4-6 columns. +// Lower edge case: 1-3 items are treated as 4 items (with blank space). +// Upper edge case: all items past 18 are dropped (treated as 18 items). +// +// This is all done through JS instead of CSS because it's just... ANNOYING... +// to write a mapping like this in CSS lol. +const carouselLayoutMap = [ + // 0-3 + null, null, null, null, + + // 4-6 + {rows: 1, columns: 4}, // 4: 1x4, drop 0 + {rows: 1, columns: 5}, // 5: 1x5, drop 0 + {rows: 1, columns: 6}, // 6: 1x6, drop 0 + + // 7-12 + {rows: 1, columns: 6}, // 7: 1x6, drop 1 + {rows: 2, columns: 4}, // 8: 2x4, drop 0 + {rows: 2, columns: 4}, // 9: 2x4, drop 1 + {rows: 2, columns: 5}, // 10: 2x5, drop 0 + {rows: 2, columns: 5}, // 11: 2x5, drop 1 + {rows: 2, columns: 6}, // 12: 2x6, drop 0 + + // 13-18 + {rows: 2, columns: 6}, // 13: 2x6, drop 1 + {rows: 2, columns: 6}, // 14: 2x6, drop 2 + {rows: 3, columns: 5}, // 15: 3x5, drop 0 + {rows: 3, columns: 5}, // 16: 3x5, drop 1 + {rows: 3, columns: 5}, // 17: 3x5, drop 2 + {rows: 3, columns: 6}, // 18: 3x6, drop 0 +]; + +const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); +const maxCarouselLayoutItems = carouselLayoutMap.length - 1; +const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; +const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; + +export function getCarouselLayoutForNumberOfItems(numItems) { + return ( + numItems < minCarouselLayoutItems ? shortestCarouselLayout : + numItems > maxCarouselLayoutItems ? longestCarouselLayout : + carouselLayoutMap[numItems]); +} + +export function filterItemsForCarousel(items) { + if (empty(items)) { + return []; + } + + return items + .filter(item => item.hasCoverArt) + .filter(item => item.artTags.every(tag => !tag.isContentWarning)) + .slice(0, maxCarouselLayoutItems + 1); +} diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js index ffaaa7a7..d6053353 100644 --- a/src/write/bind-utilities.js +++ b/src/write/bind-utilities.js @@ -5,59 +5,28 @@ import chroma from 'chroma-js'; import { - fancifyFlashURL, - fancifyURL, - getAlbumGridHTML, - getAlbumStylesheet, - getArtistString, - getCarouselHTML, - getFlashGridHTML, - getGridHTML, - getRevealStringFromArtTags, - getRevealStringFromContentWarningMessage, - getThemeString, - generateAdditionalFilesList, - generateAdditionalFilesShortcut, - generateChronologyLinks, - generateContentHeading, - generateCoverLink, - generateInfoGalleryLinks, - generateTrackListDividedByGroups, - generateNavigationLinks, - generateStickyHeadingContainer, - iconifyURL, - img, -} from '../misc-templates.js'; - -import { replacerSpec, transformInline, - transformLyrics, - transformMultiline, + // transformLyrics, + // transformMultiline, } from '../util/transform-content.js'; import * as html from '../util/html.js'; -import {bindOpts, withEntries} from '../util/sugar.js'; +import {bindOpts} from '../util/sugar.js'; import {getColors} from '../util/colors.js'; import {bindFind} from '../util/find.js'; - -import link, {getLinkThemeString} from '../util/link.js'; - -import { - getAlbumCover, - getArtistAvatar, - getFlashCover, - getTrackCover, -} from '../util/wiki-data.js'; +import {thumb} from '../util/urls.js'; export function bindUtilities({ absoluteTo, + cachebust, defaultLanguage, getSizeOfAdditionalFile, getSizeOfImageFile, language, languages, + pagePath, to, urls, wikiData, @@ -69,42 +38,22 @@ export function bindUtilities({ Object.assign(bound, { absoluteTo, + cachebust, defaultLanguage, getSizeOfAdditionalFile, getSizeOfImageFile, html, language, languages, + pagePath, + thumb, to, urls, wikiData, - }) - - bound.img = bindOpts(img, { - [bindOpts.bindIndex]: 0, - getSizeOfImageFile, - html, - to, - }); - - bound.getColors = bindOpts(getColors, { - chroma, - }); - - bound.getLinkThemeString = bindOpts(getLinkThemeString, { - getColors: bound.getColors, + wikiInfo: wikiData.wikiInfo, }); - bound.getThemeString = bindOpts(getThemeString, { - getColors: bound.getColors, - }); - - bound.link = withEntries(link, (entries) => - entries - .map(([key, fn]) => [key, bindOpts(fn, { - getLinkThemeString: bound.getLinkThemeString, - to, - })])); + bound.getColors = bindOpts(getColors, {chroma}); bound.find = bindFind(wikiData, {mode: 'warn'}); @@ -117,6 +66,7 @@ export function bindUtilities({ wikiData, }); + /* bound.transformMultiline = bindOpts(transformMultiline, { img: bound.img, to, @@ -127,81 +77,14 @@ export function bindUtilities({ transformInline: bound.transformInline, transformMultiline: bound.transformMultiline, }); + */ - bound.iconifyURL = bindOpts(iconifyURL, { - html, - language, - to, - }); - - bound.fancifyURL = bindOpts(fancifyURL, { - html, - language, - }); - - bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { - [bindOpts.bindIndex]: 2, - html, - language, - - fancifyURL: bound.fancifyURL, - }); - - bound.getRevealStringFromContentWarningMessage = bindOpts(getRevealStringFromContentWarningMessage, { - html, - language, - }); - - bound.getRevealStringFromArtTags = bindOpts(getRevealStringFromArtTags, { - language, - - getRevealStringFromContentWarningMessage: bound.getRevealStringFromContentWarningMessage, - }); - - bound.getArtistString = bindOpts(getArtistString, { - html, - link: bound.link, - language, - - iconifyURL: bound.iconifyURL, - }); - - bound.getAlbumCover = bindOpts(getAlbumCover, { - to, - }); - - bound.getTrackCover = bindOpts(getTrackCover, { - to, - }); - - bound.getFlashCover = bindOpts(getFlashCover, { - to, - }); - - bound.getArtistAvatar = bindOpts(getArtistAvatar, { - to, - }); - - bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, { - html, - language, - }); - - bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, { - html, - language, - }); - + /* bound.generateNavigationLinks = bindOpts(generateNavigationLinks, { link: bound.link, language, }); - bound.generateContentHeading = bindOpts(generateContentHeading, { - [bindOpts.bindIndex]: 0, - html, - }); - bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, { [bindOpts.bindIndex]: 0, html, @@ -217,30 +100,12 @@ export function bindUtilities({ generateNavigationLinks: bound.generateNavigationLinks, }); - bound.generateCoverLink = bindOpts(generateCoverLink, { - [bindOpts.bindIndex]: 0, - html, - img: bound.img, - link: bound.link, - language, - to, - wikiData, - - getRevealStringFromArtTags: bound.getRevealStringFromArtTags, - }); - bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, { [bindOpts.bindIndex]: 2, link: bound.link, language, }); - bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, { - html, - language, - wikiData, - }); - bound.getGridHTML = bindOpts(getGridHTML, { [bindOpts.bindIndex]: 0, img: bound.img, @@ -271,11 +136,8 @@ export function bindUtilities({ [bindOpts.bindIndex]: 0, img: bound.img, html, - }) - - bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { - to, }); + */ return bound; } diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 6dfa7d71..d7c33d87 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -11,7 +11,7 @@ import {serializeThings} from '../../data/serialize.js'; import * as pageSpecs from '../../page/index.js'; import {logInfo, logWarn, progressCallAll} from '../../util/cli.js'; - +import {empty} from '../../util/sugar.js'; import { getPagePathname, getPagePathnameAcrossLanguages, @@ -20,11 +20,21 @@ import { } from '../../util/urls.js'; import { - generateDocumentHTML, generateGlobalWikiDataJSON, generateRedirectHTML, } from '../page-template.js'; +import { + watchContentDependencies, +} from '../../content/dependencies/index.js'; + +import { + fillRelationsLayoutFromSlotResults, + flattenRelationsTree, + getRelationsTree, + getNeededContentDependencyNames, +} from '../../content-function.js'; + const defaultHost = '0.0.0.0'; const defaultPort = 8002; @@ -64,20 +74,39 @@ export async function go({ developersComment, getSizeOfAdditionalFile, getSizeOfImageFile, + niceShowAggregate, }) { + const showError = (error) => { + if (error instanceof AggregateError && niceShowAggregate) { + niceShowAggregate(error); + } else { + console.error(error); + } + }; + const host = cliOptions['host'] ?? defaultHost; const port = parseInt(cliOptions['port'] ?? defaultPort); + const contentDependenciesWatcher = await watchContentDependencies(); + const {contentDependencies: allContentDependencies} = contentDependenciesWatcher; + + contentDependenciesWatcher.on('error', () => {}); + await new Promise(resolve => contentDependenciesWatcher.once('ready', resolve)); + let targetSpecPairs = getPageSpecsWithTargets({wikiData}); const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`, - targetSpecPairs.map(({ + targetSpecPairs.flatMap(({ pageSpec, target, targetless, - }) => () => - targetless - ? pageSpec.writeTargetless({wikiData}) - : pageSpec.write(target, {wikiData}))).flat(); + }) => () => { + if (targetless) { + const result = pageSpec.pathsTargetless({wikiData}); + return Array.isArray(result) ? result : [result]; + } else { + return pageSpec.pathsForTarget(target); + } + })).flat(); logInfo`Will be serving a total of ${pages.length} pages.`; @@ -143,7 +172,7 @@ export async function go({ response.writeHead(500, contentTypeJSON); response.end({error: `Internal error serializing wiki JSON`}); console.error(`${requestHead} [500] /data.json`); - console.error(error); + showError(error); } return; } @@ -186,7 +215,7 @@ export async function go({ response.writeHead(500, contentTypePlain); response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } return; } @@ -239,7 +268,7 @@ export async function go({ response.writeHead(500, contentTypePlain); response.end(`Failed during file-to-response pipeline`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } return; } @@ -305,8 +334,6 @@ export async function go({ return; } - response.writeHead(200, contentTypeHTML); - const localizedPathnames = getPagePathnameAcrossLanguages({ defaultLanguage, languages, @@ -314,37 +341,135 @@ export async function go({ urls, }); + const {name, args = []} = page.contentFunction; + const bound = bindUtilities({ absoluteTo, + cachebust, defaultLanguage, getSizeOfAdditionalFile, getSizeOfImageFile, language, languages, + pagePath: servePath, to, urls, wikiData, }); - const pageInfo = page.page(bound); - - const pageHTML = generateDocumentHTML(pageInfo, { + const allExtraDependencies = { ...bound, - cachebust, - developersComment, - localizedPathnames, - oEmbedJSONHref: null, // No oEmbed support for live dev server - pagePath: servePath, - pathname, - }); + + appendIndexHTML: false, + }; + + // NOTE: ALL THIS STUFF IS PASTED, REVIEW AND INTEGRATE SOON(TM) + + const treeInfo = getRelationsTree(allContentDependencies, name, wikiData, ...args); + 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(', ')})`); + } + + const slotResults = {}; + + function runContentFunction({name, args, relations: layout}) { + const contentFunction = fulfilledContentDependencies[name]; + + if (!contentFunction) { + throw new Error(`Content function ${name} unfulfilled or not listed`); + } + + const generateArgs = []; + + if (contentFunction.data) { + generateArgs.push(contentFunction.data(...args)); + } + + if (layout) { + generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout)); + } + + return contentFunction(...generateArgs); + } + + for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) { + slotResults[slot] = runContentFunction(flatRelationSlots[slot]); + } + + const topLevelResult = runContentFunction(root); + + // END PASTE + + const pageHTML = topLevelResult.toString(); console.log(`${requestHead} [200] ${pathname}`); + response.writeHead(200, contentTypeHTML); response.end(pageHTML); } catch (error) { response.writeHead(500, contentTypePlain); response.end(`Error generating page, view server log for details\n`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } }); @@ -360,7 +485,7 @@ export async function go({ }, 10_000); } else { console.error(`Server error detected (code: ${error.code})`); - console.error(error); + showError(error); } }); @@ -387,7 +512,7 @@ function getPageSpecsWithTargets({ ? pageSpec.targets({wikiData}) .map(target => ({pageSpec, target})) : [], - Object.hasOwn(pageSpec, 'writeTargetless') && + Object.hasOwn(pageSpec, 'pathsTargetless') && {pageSpec, targetless: true}, ]) .filter(Boolean); diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 8e02342c..2fb82b84 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -1,14 +1,18 @@ import * as path from 'path'; import {bindUtilities} from '../bind-utilities.js'; -import {validateWrites} from '../validate-writes.js'; +// import {validateWrites} from '../validate-writes.js'; import { - generateDocumentHTML, - generateGlobalWikiDataJSON, - generateOEmbedJSON, - generateRedirectHTML, -} from '../page-template.js'; + quickLoadContentDependencies, +} from '../../content/dependencies/index.js'; + +import { + fillRelationsLayoutFromSlotResults, + flattenRelationsTree, + getRelationsTree, + getNeededContentDependencyNames, +} from '../../content-function.js'; import {serializeThings} from '../../data/serialize.js'; @@ -143,10 +147,12 @@ export async function go({ outputPath, urls, wikiData, + /* wikiDataJSON: generateGlobalWikiDataJSON({ serializeThings, wikiData, }) + */ }); const buildSteps = writeAll @@ -158,64 +164,47 @@ export async function go({ { let error = false; - const buildStepsWithTargets = buildSteps + // TODO: Port this to aggregate error + writes = buildSteps .map(([flag, pageSpec]) => { - // Condition not met: skip this build step altogether. if (pageSpec.condition && !pageSpec.condition({wikiData})) { return null; } - // May still call writeTargetless if present. - if (!pageSpec.targets) { - return {flag, pageSpec, targets: []}; - } - - if (!pageSpec.write) { - logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`; - error = true; - return null; - } + const paths = []; - const targets = pageSpec.targets({wikiData}); - if (!Array.isArray(targets)) { - logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`; - error = true; - return null; + if (pageSpec.pathsTargetless) { + const result = pageSpec.pathsTargetless({wikiData}); + if (Array.isArray(result)) { + paths.push(...result); + } else { + paths.push(result); + } } - return {flag, pageSpec, targets}; - }) - .filter(Boolean); + if (pageSpec.targets) { + if (!pageSpec.pathsForTarget) { + logError`${flag + '.targets'} is specified, but ${flag + '.pathsForTarget'} is missing!`; + error = true; + return null; + } - if (error) { - return false; - } + const targets = pageSpec.targets({wikiData}); - writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => { - const writesFns = targets.map(target => () => { - const writes = pageSpec.write(target, {wikiData})?.slice() || []; - const valid = validateWrites(writes, { - functionName: flag + '.write', - urlSpec, - }); - error ||=! valid; - return valid ? writes : []; - }); + if (!Array.isArray(targets)) { + logError`${flag + '.targets'} was called, but it didn't return an array! (${targets})`; + error = true; + return null; + } - if (pageSpec.writeTargetless) { - writesFns.push(() => { - const writes = pageSpec.writeTargetless({wikiData}); - const valid = validateWrites(writes, { - functionName: flag + '.writeTargetless', - urlSpec, - }); - error ||=! valid; - return valid ? writes : []; - }); - } + paths.push(...targets.flatMap(target => pageSpec.pathsForTarget(target))); + // TODO: Validate each pathsForTargets entry + } - return writesFns; - })).flat(); + return paths; + }) + .filter(Boolean) + .flat(); if (error) { return false; @@ -267,6 +256,8 @@ export async function go({ )); */ + const allContentDependencies = await quickLoadContentDependencies(); + const perLanguageFn = async (language, i, entries) => { const baseDirectory = language === defaultLanguage ? '' : language.code; @@ -303,16 +294,19 @@ export async function go({ const bound = bindUtilities({ absoluteTo, + cachebust, defaultLanguage, getSizeOfAdditionalFile, getSizeOfImageFile, language, languages, + pagePath, to, urls, wikiData, }); + /* const pageInfo = page.page(bound); const oEmbedJSON = generateOEmbedJSON(pageInfo, { @@ -327,20 +321,111 @@ export async function go({ urls .from('shared.root') .to('shared.path', pathname + 'oembed.json'); + */ - const pageHTML = generateDocumentHTML(pageInfo, { + const allExtraDependencies = { ...bound, - cachebust, - developersComment, - localizedPathnames, - oEmbedJSONHref, - pagePath, - pathname, - }); + appendIndexHTML: false, + }; + + const {name, args = []} = page.contentFunction; + const treeInfo = getRelationsTree(allContentDependencies, name, wikiData, ...args); + 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(', ')})`); + } + + const slotResults = {}; + + function runContentFunction({name, args, relations: layout}) { + const contentFunction = fulfilledContentDependencies[name]; + + if (!contentFunction) { + throw new Error(`Content function ${name} unfulfilled or not listed`); + } + + const generateArgs = []; + + if (contentFunction.data) { + generateArgs.push(contentFunction.data(...args)); + } + + if (layout) { + generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout)); + } + + return contentFunction(...generateArgs); + } + + for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) { + slotResults[slot] = runContentFunction(flatRelationSlots[slot]); + } + + const topLevelResult = runContentFunction(root); + const pageHTML = topLevelResult.toString(); return writePage({ html: pageHTML, - oEmbedJSON, + // oEmbedJSON, outputDirectory: path.join(outputPath, getPagePathname({ baseDirectory, device: true, @@ -497,6 +582,7 @@ async function writeSharedFilesAndPages({ const {groupData, wikiInfo} = wikiData; return progressPromiseAll(`Writing files & pages shared across languages.`, [ + /* groupData?.some((group) => group.directory === 'fandom') && redirect( 'Fandom - Gallery', @@ -520,6 +606,7 @@ async function writeSharedFilesAndPages({ 'localized.commentaryIndex', '' ), + */ wikiDataJSON && writeFile( diff --git a/src/write/page-template.js b/src/write/page-template.js index fcd8759c..d3d7b098 100644 --- a/src/write/page-template.js +++ b/src/write/page-template.js @@ -3,11 +3,6 @@ import chroma from 'chroma-js'; import * as html from '../util/html.js'; import {getColors} from '../util/colors.js'; -import { - getFooterLocalizationLinks, - getRevealStringFromContentWarningMessage, -} from '../misc-templates.js'; - export function generateDevelopersCommentHTML({ buildTime, commit, @@ -153,40 +148,7 @@ export function generateDocumentHTML(pageInfo, { const collapseSidebars = sidebarLeft.collapse !== false && sidebarRight.collapse !== false; - const mainHTML = - html.tag('main', { - id: 'content', - class: main.classes, - }, [ - ...html.fragment( - !title ? - null - : main.headingMode === 'sticky' ? - generateStickyHeadingContainer({ - coverSrc: cover.src, - coverAlt: cover.alt, - coverArtTags: cover.artTags, - title, - }) - : main.headingMode === 'static' ? - html.tag('h1', title) - : null), - - ...html.fragment( - cover.src && - generateCoverLink({ - src: cover.src, - alt: cover.alt, - tags: cover.artTags, - })), - html.tag('div', - { - [html.onlyIfContent]: true, - class: 'main-content-container', - }, - main.content), - ]); const footerHTML = html.tag('footer', @@ -378,31 +340,6 @@ export function generateDocumentHTML(pageInfo, { height: banner.dimensions[1] || 200, })); - const layoutHTML = [ - navHTML, - banner.position === 'top' && bannerHTML, - secondaryNavHTML, - html.tag('div', - { - class: [ - 'layout-columns', - !collapseSidebars && 'vertical-when-thin', - (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', - (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', - !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', - sidebarLeftHTML && 'has-sidebar-left', - sidebarRightHTML && 'has-sidebar-right', - ], - }, - [ - sidebarLeftHTML, - mainHTML, - sidebarRightHTML, - ]), - banner.position === 'bottom' && bannerHTML, - footerHTML, - ].filter(Boolean).join('\n'); - const processSkippers = skipperList => skipperList .filter(Boolean) @@ -612,92 +549,7 @@ export function generateDocumentHTML(pageInfo, { }), ].filter(Boolean).join('\n'); - return `<!DOCTYPE html>\n` + html.tag('html', - { - lang: language.intlCode, - 'data-language-code': language.code, - 'data-url-key': 'localized.' + pagePath[0], - ...Object.fromEntries( - pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])), - 'data-rebase-localized': to('localized.root'), - 'data-rebase-shared': to('shared.root'), - 'data-rebase-media': to('media.root'), - 'data-rebase-data': to('data.root'), - }, - [ - developersComment, - - html.tag('head', [ - html.tag('title', - showWikiNameInTitle - ? language.formatString('misc.pageTitle.withWikiName', { - title, - wikiName: wikiInfo.nameShort, - }) - : language.formatString('misc.pageTitle', {title})), - - html.tag('meta', {charset: 'utf-8'}), - html.tag('meta', { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }), - - ...( - Object.entries(meta) - .filter(([key, value]) => value) - .map(([key, value]) => html.tag('meta', {[key]: value}))), - - canonical && - html.tag('link', { - rel: 'canonical', - href: canonical, - }), - - ...( - localizedCanonical - .map(({lang, href}) => html.tag('link', { - rel: 'alternate', - hreflang: lang, - href, - }))), - - socialEmbedHTML, - - html.tag('link', { - rel: 'stylesheet', - href: to('shared.staticFile', 'site3.css', cachebust), - }), - - html.tag('style', - {[html.onlyIfContent]: true}, - [ - theme, - stylesheet, - ]), - - html.tag('script', { - src: to('shared.staticFile', 'lazy-loading.js', cachebust), - }), - ]), - - html.tag('body', - {style: body.style || ''}, - [ - html.tag('div', {id: 'page-container'}, [ - mainHTML && - skippersHTML, - layoutHTML, - ]), - - infoCardHTML, - imageOverlayHTML, - - html.tag('script', { - type: 'module', - src: to('shared.staticFile', 'client.js', cachebust), - }), - ]), - ]); + return `<!DOCTYPE html>\n` } export function generateOEmbedJSON(pageInfo, {language, wikiData}) { diff --git a/src/write/validate-writes.js b/src/write/validate-writes.js index 5d61d0e7..52c7dfab 100644 --- a/src/write/validate-writes.js +++ b/src/write/validate-writes.js @@ -1,3 +1,5 @@ +// TODO: All this is for an outdated spec + should use aggregate errors + import {logError} from '../util/cli.js'; function validateWritePath(path, urlGroup) { |