diff options
Diffstat (limited to 'src')
80 files changed, 7291 insertions, 3324 deletions
diff --git a/src/content-function.js b/src/content-function.js new file mode 100644 index 00000000..3536d5a8 --- /dev/null +++ b/src/content-function.js @@ -0,0 +1,478 @@ +import {annotateFunction, empty} from './util/sugar.js'; + +export default function contentFunction({ + contentDependencies = [], + extraDependencies = [], + + sprawl, + relations, + data, + generate, +}) { + return expectDependencies({ + sprawl, + relations, + data, + generate, + + expectedContentDependencyKeys: contentDependencies, + expectedExtraDependencyKeys: extraDependencies, + fulfilledDependencies: {}, + }); +} + +contentFunction.identifyingSymbol = Symbol(`Is a content function?`); + +export function expectDependencies({ + sprawl, + relations, + data, + generate, + + expectedContentDependencyKeys, + expectedExtraDependencyKeys, + fulfilledDependencies, +}) { + if (!generate) { + throw new Error(`Expected generate function`); + } + + const hasSprawlFunction = !!sprawl; + const hasRelationsFunction = !!relations; + const hasDataFunction = !!data; + + if (hasSprawlFunction && !expectedExtraDependencyKeys.includes('wikiData')) { + throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`); + } + + const fulfilledDependencyKeys = Object.keys(fulfilledDependencies); + + const invalidatingDependencyKeys = Object.entries(fulfilledDependencies) + .filter(([key, value]) => value?.fulfilled === false) + .map(([key]) => key); + + const missingContentDependencyKeys = expectedContentDependencyKeys + .filter(key => !fulfilledDependencyKeys.includes(key)); + + const missingExtraDependencyKeys = expectedExtraDependencyKeys + .filter(key => !fulfilledDependencyKeys.includes(key)); + + let wrappedGenerate; + + if (!empty(invalidatingDependencyKeys)) { + wrappedGenerate = function() { + throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${invalidatingDependencyKeys.join(', ')}`); + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'}); + wrappedGenerate.fulfilled = false; + } else if (empty(missingContentDependencyKeys) && empty(missingExtraDependencyKeys)) { + wrappedGenerate = function(arg1, arg2) { + 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, fulfilledDependencies); + } else if (hasDataFunction || hasRelationsFunction) { + return generate(arg1, fulfilledDependencies); + } else { + return generate(fulfilledDependencies); + } + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'}); + wrappedGenerate.fulfilled = true; + + wrappedGenerate.fulfill = function() { + throw new Error(`All dependencies already fulfilled`); + }; + } else { + wrappedGenerate = function() { + throw new Error(`Dependencies still needed: ${missingContentDependencyKeys.concat(missingExtraDependencyKeys).join(', ')}`); + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); + wrappedGenerate.fulfilled = false; + } + + wrappedGenerate[contentFunction.identifyingSymbol] = true; + + if (hasSprawlFunction) { + wrappedGenerate.sprawl = sprawl; + } + + if (hasRelationsFunction) { + wrappedGenerate.relations = relations; + } + + if (hasDataFunction) { + wrappedGenerate.data = data; + } + + wrappedGenerate.fulfill ??= function fulfill(dependencies) { + return expectDependencies({ + sprawl, + relations, + data, + generate, + + expectedContentDependencyKeys, + expectedExtraDependencyKeys, + + fulfilledDependencies: fulfillDependencies({ + name: generate.name, + dependencies, + + expectedContentDependencyKeys, + expectedExtraDependencyKeys, + fulfilledDependencies, + }), + }); + }; + + Object.assign(wrappedGenerate, { + contentDependencies: expectedContentDependencyKeys, + extraDependencies: expectedExtraDependencyKeys, + }); + + return wrappedGenerate; +} + +export function fulfillDependencies({ + name, + dependencies, + expectedContentDependencyKeys, + expectedExtraDependencyKeys, + fulfilledDependencies, +}) { + const newFulfilledDependencies = {...fulfilledDependencies}; + const fulfilledDependencyKeys = Object.keys(fulfilledDependencies); + + const errors = []; + let bail = false; + + for (let [key, value] of Object.entries(dependencies)) { + if (fulfilledDependencyKeys.includes(key)) { + errors.push(new Error(`Dependency ${key} is already fulfilled`)); + bail = true; + continue; + } + + const isContentKey = expectedContentDependencyKeys.includes(key); + const isExtraKey = expectedExtraDependencyKeys.includes(key); + + if (!isContentKey && !isExtraKey) { + errors.push(new Error(`Dependency ${key} is not expected`)); + bail = true; + continue; + } + + if (value === undefined) { + errors.push(new Error(`Dependency ${key} was provided undefined`)); + bail = true; + continue; + } + + if (isContentKey && !value?.[contentFunction.identifyingSymbol]) { + errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`)); + bail = true; + continue; + } + + if (isExtraKey && value?.[contentFunction.identifyingSymbol]) { + errors.push(new Error(`Extra dependency ${key} is a content function`)); + bail = true; + continue; + } + + if (!bail) { + newFulfilledDependencies[key] = value; + } + } + + if (!empty(errors)) { + throw new AggregateError(errors, `Errors fulfilling dependencies for ${name}`); + } + + return newFulfilledDependencies; +} + +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}`); + } + + if (!contentFunction.relations) { + return null; + } + + const listedDependencies = new Set(contentFunction.contentDependencies); + + // TODO: Evaluating a sprawl might belong somewhere better than here, lol... + const sprawl = + (contentFunction.sprawl + ? contentFunction.sprawl(wikiData, ...args) + : null) + + 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 = + (sprawl + ? contentFunction.relations(relationFunction, sprawl, ...args) + : contentFunction.relations(relationFunction, ...args)) + + const relationsTree = Object.fromEntries( + Object.getOwnPropertySymbols(relationSlots) + .map(symbol => [symbol, relationSlots[symbol]]) + .map(([symbol, {name, args}]) => [ + symbol, + recursive(name, ...args), + ])); + + return { + layout: relationsLayout, + slots: relationSlots, + tree: relationsTree, + }; + } + + const relationsTree = recursive(contentFunctionName, ...args); + + return { + root: { + name: contentFunctionName, + args, + relations: relationsTree?.layout, + }, + + relationIdentifier, + relationsTree, + }; +} + +export function flattenRelationsTree({ + root, + relationIdentifier, + relationsTree, +}) { + const flatRelationSlots = {}; + + function recursive({layout, slots, tree}) { + for (const slot of Object.getOwnPropertySymbols(slots)) { + if (tree[slot]) { + recursive(tree[slot]); + } + + flatRelationSlots[slot] = { + name: slots[slot].name, + args: slots[slot].args, + relations: tree[slot]?.layout ?? null, + }; + } + } + + if (relationsTree) { + recursive(relationsTree); + } + + return { + 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`); + } + + 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 = [], + 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, + 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: flatRelations}) { + const contentFunction = fulfilledContentDependencies[name]; + + if (!contentFunction) { + throw new Error(`Content function ${name} unfulfilled or not listed`); + } + + const sprawl = + contentFunction.sprawl?.(allExtraDependencies.wikiData, ...args); + + const relations = + fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, flatRelations); + + const data = + (sprawl + ? contentFunction.data?.(sprawl, ...args) + : contentFunction.data?.(...args)); + + const generateArgs = [data, relations].filter(Boolean); + + return contentFunction(...generateArgs); + } + + for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) { + slotResults[slot] = runContentFunction(flatRelationSlots[slot]); + } + + const topLevelResult = runContentFunction(root); + + if (postprocess !== null) { + return postprocess(topLevelResult); + } else { + return topLevelResult; + } +} diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js new file mode 100644 index 00000000..eb9fc8b0 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,121 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'html', + 'language', + ], + + data(additionalFiles, {fileSize = true} = {}) { + return { + // Additional files are already a serializable format. + additionalFiles, + showFileSizes: fileSize, + }; + }, + + generate(data, { + html, + language, + }) { + const fileKeys = data.additionalFiles.flatMap(({files}) => files); + const validateFileMapping = (v, validateValue) => { + return value => { + v.isObject(value); + + // It's OK to skip values for files, but if keys are provided for files + // which don't exist, that's an error. + + const unexpectedKeys = + Object.keys(value).filter(key => !fileKeys.includes(key)) + + if (!empty(unexpectedKeys)) { + throw new TypeError(`Unexpected file keys: ${unexpectedKeys.join(', ')}`); + } + + 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`); + } + }; + }; + + return html.template({ + annotation: 'generateAdditionalFilesList', + + slots: { + fileLinks: { + validate: v => validateFileMapping(v, v.isHTML), + }, + + fileSizes: { + validate: v => validateFileMapping(v, v.isWholeNumber), + }, + }, + + content(slots) { + if (!slots.fileSizes) { + return html.blank(); + } + + const filesWithLinks = new Set( + Object.entries(slots.fileLinks) + .filter(([key, value]) => value) + .map(([key]) => key)); + + if (filesWithLinks.size === 0) { + 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..7dfe07b3 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesShortcut.js @@ -0,0 +1,33 @@ +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..5fd4e05b --- /dev/null +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -0,0 +1,57 @@ +export default { + contentDependencies: [ + 'generateAdditionalFilesList', + 'linkAlbumAdditionalFile', + ], + + extraDependencies: [ + 'getSizeOfAdditionalFile', + 'urls', + ], + + data(album, additionalFiles, {fileSize = true} = {}) { + return { + albumDirectory: album.directory, + fileLocations: additionalFiles.flatMap(({files}) => files), + showFileSizes: fileSize, + }; + }, + + relations(relation, album, additionalFiles, {fileSize = true} = {}) { + return { + additionalFilesList: + relation('generateAdditionalFilesList', additionalFiles, { + fileSize, + }), + + additionalFileLinks: + Object.fromEntries( + additionalFiles + .flatMap(({files}) => files) + .map(file => [ + file, + relation('linkAlbumAdditionalFile', album, file), + ])), + }; + }, + + generate(data, relations, { + getSizeOfAdditionalFile, + urls, + }) { + return relations.additionalFilesList + .slots({ + fileLinks: relations.additionalFileLinks, + fileSizes: + Object.fromEntries(data.fileLocations.map(file => [ + file, + (data.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, file)) + : 0), + ])), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 00000000..749dd2af --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,99 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateAlbumInfoPageContent', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkTrack', + ], + + extraDependencies: ['language'], + + relations(relation, album) { + return { + layout: relation('generatePageLayout'), + + 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, + ]), + }), + + albumNavAccent: relation('generateAlbumNavAccent', album, null), + chronologyLinks: relation('generateChronologyLinks'), + + content: relation('generateAlbumInfoPageContent', album), + sidebar: relation('generateAlbumSidebar', album, null), + socialEmbed: relation('generateAlbumSocialEmbed', album), + albumStyleRules: relation('generateAlbumStyleRules', album), + colorStyleRules: relation('generateColorStyleRules', album.color), + }; + }, + + data(album) { + return { + name: album.name, + }; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: language.$('albumPage.title', {album: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: relations.content.cover, + mainContent: relations.content.main.content, + + 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, + }, + ], + }), + + ...relations.sidebar, + + // socialEmbed: relations.socialEmbed, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js new file mode 100644 index 00000000..5d2817ee --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPageContent.js @@ -0,0 +1,307 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumTrackList', + 'generateContentHeading', + 'generateCoverArtwork', + 'linkAlbumCommentary', + 'linkAlbumGallery', + 'linkContribution', + 'linkExternal', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + const sections = relations.sections = {}; + + const contributionLinksRelation = contribs => + contribs.map(contrib => + relation('linkContribution', contrib.who, contrib.what)); + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + if (!empty(album.artistContribs)) { + releaseInfo.artistContributionLinks = + contributionLinksRelation(album.artistContribs); + } + + if (album.hasCoverArt) { + relations.cover = + relation('generateCoverArtwork', album.artTags); + releaseInfo.coverArtistContributionLinks = + contributionLinksRelation(album.coverArtistContribs); + } else { + relations.cover = null; + } + + if (album.hasWallpaperArt) { + releaseInfo.wallpaperArtistContributionLinks = + contributionLinksRelation(album.wallpaperArtistContribs); + } + + if (album.hasBannerArt) { + releaseInfo.bannerArtistContributionLinks = + contributionLinksRelation(album.bannerArtistContribs); + } + + // Section: Listen on + + if (!empty(album.urls)) { + const listen = sections.listen = {}; + + listen.externalLinks = + album.urls.map(url => + relation('linkExternal', url, {type: '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.date = album.date; + data.duration = accumulateSum(album.tracks, track => track.duration); + data.durationApproximate = album.tracks.length > 1; + + data.hasCoverArt = album.hasCoverArt; + + if (album.hasCoverArt) { + data.coverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + } + + if (!empty(album.additionalFiles)) { + data.numAdditionalFiles = album.additionalFiles.length; + } + + data.dateAddedToWiki = album.dateAddedToWiki; + + return data; + }, + + generate(data, relations, { + html, + language, + }) { + const content = {}; + + const {sections: sec} = relations; + + const formatContributions = + (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => + contributionLinks && + language.$(stringKey, { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({showContribution, showIcons}))), + }); + + if (data.hasCoverArt) { + content.cover = relations.cover + .slots({ + path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension], + alt: language.$('misc.alt.trackCover') + }); + } else { + content.cover = null; + } + + content.main = { + headingMode: 'sticky', + content: html.tags([ + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), + formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), + formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks), + formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks), + + 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, + }), + }), + ]), + + sec.listen && + html.tag('p', + language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + })), + + 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')), + ], + ]), + }; + + return content; + }, +}; + +/* + banner: !empty(album.bannerArtistContribs) && { + dimensions: album.bannerDimensions, + path: [ + 'media.albumBanner', + album.directory, + album.bannerFileExtension, + ], + alt: language.$('misc.alt.albumBanner'), + position: 'top', + }, + + secondaryNav: generateAlbumSecondaryNav(album, null, { + getLinkThemeString, + html, + language, + link, + }), +*/ diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js new file mode 100644 index 00000000..9d1d87c3 --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,120 @@ +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, + }; + }, + + generate(data, relations, {html, language}) { + return html.template({ + annotation: `generateAlbumNavAccent`, + + slots: { + showTrackNavigation: {type: 'boolean', default: false}, + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery', 'commentary'), + }, + }, + + content(slots) { + 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/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 00000000..bf6b091a --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,76 @@ +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('isAlbumPage', true)) + .map(content => ({content})); + + return { + leftSidebarMultiple: [ + ...groupBoxes, + trackListBox, + ], + }; + } + + const conjoinedGroupBox = { + content: + relations.groupBoxes + .flatMap((content, i, {length}) => [ + content, + 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..1c27af9e --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,88 @@ +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)); + + 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 (group.descriptionShort) { + relations.description = + relation('transformContent', group.descriptionShort); + } + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + + return relations; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: `generateAlbumSidebarGroupBox`, + + slots: { + isAlbumPage: {type: 'boolean', default: false}, + }, + + content(slots) { + return html.tags([ + html.tag('h1', + language.$('albumSidebar.groupBox.title', { + group: relations.groupLink, + })), + + slots.isAlbumPage && + relations.description + ?.slot('mode', 'multiline'), + + !empty(relations.externalLinks) && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(relations.externalLinks), + })), + + slots.isAlbumPage && + relations.nextAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.next', { + album: relations.nextAlbumLink, + })), + + slots.isAlbumPage && + 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..656bd997 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,85 @@ +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..5fa67b26 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,50 @@ +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..c9547836 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,61 @@ +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..ce174953 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,126 @@ +import {accumulateSum, empty} 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', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, album) { + const relations = {}; + + const displayMode = getDisplayMode(album); + + if (displayMode === 'trackSections') { + relations.itemsByTrackSection = + album.trackSections.map(section => + section.tracks.map(track => + relation('generateAlbumTrackListItem', track, album))); + } + + if (displayMode === 'tracks') { + relations.itemsByTrack = + album.tracks.map(track => + relation('generateAlbumTrackListItem', track, album)); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.hasTrackNumbers = album.hasTrackNumbers; + + if (displayTrackSections && !empty(album.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; + }); + } + + return data; + }, + + generate(data, relations, { + html, + language, + }) { + const listTag = (data.hasTrackNumbers ? 'ol' : 'ul'); + + if (relations.itemsByTrackSection) { + return html.tag('dl', + {class: 'album-group-list'}, + data.trackSectionInfo.map((info, index) => [ + html.tag('dt', + {class: 'content-heading', tabindex: '0'}, + 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} : {}, + relations.itemsByTrackSection[index])), + ])); + } + + if (relations.itemsByTrack) { + return html.tag(listTag, relations.itemsByTrack); + } + + return html.blank(); + } +}; diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js new file mode 100644 index 00000000..fe46153d --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,75 @@ +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(({who, what}) => + relation('linkContribution', who, what, { + showContribution: false, + showIcons: false, + })); + + relations.trackLink = + relation('linkTrack', track); + + return relations; + }, + + data(track, album) { + const data = {}; + + data.color = track.color; + data.duration = track.duration ?? 0; + + 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, + }) { + const stringOpts = { + duration: language.formatDuration(data.duration), + track: relations.trackLink, + }; + + let style; + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + return html.tag('li', + {style}, + (!data.showArtists + ? language.$('trackList.item.withDuration', stringOpts) + : language.$('trackList.item.withDuration.withArtists', { + ...stringOpts, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: language.formatConjunctionList(relations.contributionLinks), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js new file mode 100644 index 00000000..a91eebf2 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,818 @@ +import {empty, filterProperties, unique} from '../../util/sugar.js'; + +import { + chunkByProperties, + getTotalDuration, + sortAlbumsTracksChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkArtistGallery', + 'linkExternal', + 'linkGroup', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, artist) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + const processContribs = (...contribArrays) => { + const properties = {}; + + const ownContribs = + contribArrays + .map(contribs => contribs.find(({who}) => who === artist)) + .filter(Boolean); + + const contributionDescriptions = + ownContribs + .map(({what}) => what) + .filter(Boolean); + + if (!empty(contributionDescriptions)) { + properties.contributionDescriptions = contributionDescriptions; + } + + const otherArtistContribs = + contribArrays + .map(contribs => contribs.filter(({who}) => who !== artist)) + .flat(); + + if (!empty(otherArtistContribs)) { + properties.otherArtistLinks = + otherArtistContribs + .map(({who}) => relation('linkArtist', who)); + } + + return properties; + }; + + const sortContributionEntries = (entries, sortFunction) => { + const things = unique(entries.map(({thing}) => 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 entry of entries) { + thingToOutputArray.get(entry.thing).push(entry); + } + + entries.splice(0, entries.length, ...outputArrays.flat()); + }; + + const getGroupInfo = (entries) => { + const allGroups = new Set(); + const groupToDuration = new Map(); + const groupToCount = new Map(); + + for (const entry of entries) { + for (const group of entry.album.groups) { + allGroups.add(group); + groupToCount.set(group, (groupToCount.get(group) ?? 0) + 1); + groupToDuration.set(group, (groupToDuration.get(group) ?? 0) + entry.duration ?? 0); + } + } + + const groupInfo = + Array.from(allGroups) + .map(group => ({ + groupLink: relation('linkGroup', group), + duration: groupToDuration.get(group) ?? 0, + count: groupToCount.get(group), + })); + + groupInfo.sort((a, b) => b.count - a.count); + groupInfo.sort((a, b) => b.duration - a.duration); + + return groupInfo; + }; + + 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)); + } + + const trackContributionEntries = [ + ...artist.tracksAsArtist.map(track => ({ + date: track.date, + thing: track, + album: track.album, + duration: track.duration, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.artistContribs), + })), + + ...artist.tracksAsContributor.map(track => ({ + date: track.date, + thing: track, + album: track.album, + duration: track.duration, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.contributorContribs), + })), + ]; + + sortContributionEntries(trackContributionEntries, sortAlbumsTracksChronologically); + + const trackContributionChunks = + chunkByProperties(trackContributionEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + duration: getTotalDuration(chunk), + entries: chunk + .map(entry => + filterProperties(entry, [ + 'contributionDescriptions', + 'duration', + 'otherArtistLinks', + 'rerelease', + 'trackLink', + ])), + })); + + const trackGroupInfo = getGroupInfo(trackContributionEntries, 'duration'); + + if (!empty(trackContributionChunks)) { + const tracks = sections.tracks = {}; + tracks.heading = relation('generateContentHeading'); + tracks.chunks = trackContributionChunks; + + if (!empty(trackGroupInfo)) { + tracks.groupInfo = trackGroupInfo; + } + } + + // TODO: Add and integrate wallpaper and banner date fields (#90) + const artContributionEntries = [ + ...artist.albumsAsCoverArtist.map(album => ({ + kind: 'albumCover', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.coverArtistContribs), + })), + + ...artist.albumsAsWallpaperArtist.map(album => ({ + kind: 'albumWallpaper', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.wallpaperArtistContribs), + })), + + ...artist.albumsAsBannerArtist.map(album => ({ + kind: 'albumBanner', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.bannerArtistContribs), + })), + + ...artist.tracksAsCoverArtist.map(track => ({ + kind: 'trackCover', + date: track.coverArtDate, + thing: track, + album: track.album, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.coverArtistContribs), + })), + ]; + + sortContributionEntries(artContributionEntries, sortAlbumsTracksChronologically); + + const artContributionChunks = + chunkByProperties(artContributionEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + entries: + chunk.map(entry => + filterProperties(entry, [ + 'contributionDescriptions', + 'kind', + 'otherArtistLinks', + 'rerelease', + 'trackLink', + ])), + })); + + const artGroupInfo = getGroupInfo(artContributionEntries, 'count'); + + if (!empty(artContributionChunks)) { + const artworks = sections.artworks = {}; + artworks.heading = relation('generateContentHeading'); + artworks.chunks = artContributionChunks; + + if ( + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist) + ) { + artworks.artistGalleryLink = + relation('linkArtistGallery', artist); + } + + if (!empty(artGroupInfo)) { + artworks.groupInfo = artGroupInfo; + } + } + + // Commentary doesn't use the detailed contribution system where multiple + // artists are collaboratively credited for the same piece, so there isn't + // really anything special to do for processing or presenting it. + + const commentaryEntries = [ + ...artist.albumsAsCommentator.map(album => ({ + kind: 'albumCommentary', + date: album.date, + thing: album, + album: album, + })), + + ...artist.tracksAsCommentator.map(track => ({ + kind: 'trackCommentary', + date: track.date, + thing: track, + album: track.album, + trackLink: relation('linkTrack', track), + })), + ]; + + sortContributionEntries(commentaryEntries, sortAlbumsTracksChronologically); + + // We still pass through (and chunk by) date here, even though it doesn't + // actually get displayed on the album page. See issue #193. + const commentaryChunks = + chunkByProperties(commentaryEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + entries: + chunk.map(entry => + filterProperties(entry, [ + 'kind', + 'trackLink', + ])), + })); + + if (!empty(commentaryChunks)) { + const commentary = sections.commentary = {}; + commentary.heading = relation('generateContentHeading'); + commentary.chunks = commentaryChunks; + } + + return relations; + }, + + data(artist) { + const data = {}; + + data.name = artist.name; + + const allTracks = unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]); + data.totalTrackCount = allTracks.length; + data.totalDuration = getTotalDuration(allTracks, {originalReleasesOnly: true}); + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + const addAccentsToEntry = ({ + rerelease, + entry, + otherArtistLinks, + contributionDescriptions, + }) => { + if (rerelease) { + return language.$('artistPage.creditList.entry.rerelease', {entry}); + } + + const options = {entry}; + const parts = ['artistPage.creditList.entry']; + + if (otherArtistLinks) { + parts.push('withArtists'); + options.artists = language.formatConjunctionList(otherArtistLinks); + } + + if (contributionDescriptions) { + parts.push('withContribution'); + options.contribution = language.formatUnitList(contributionDescriptions); + } + + if (parts.length === 1) { + return entry; + } + + return language.formatString(parts.join('.'), options); + }; + + const addAccentsToAlbumLink = ({ + albumLink, + date, + duration, + entries, + }) => { + const options = {album: albumLink}; + const parts = ['artistPage.creditList.album']; + + if (date) { + parts.push('withDate'); + options.date = language.formatDate(new Date(date)); + } + + if (duration) { + parts.push('withDuration'); + options.duration = language.formatDuration(duration, { + approximate: entries.length > 1, + }); + } + + return language.formatString(parts.join('.'), options); + }; + + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + mainClasses: ['long-content'], + 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.groupInfo && + html.tag('p', + language.$('artistPage.musicGroupsLine', { + groups: + language.formatUnitList( + sec.tracks.groupInfo.map(({groupLink, count, duration}) => + (duration + ? language.$('artistPage.groupsLine.item.withDuration', { + group: groupLink, + duration: language.formatDuration(duration, {approximate: count > 1}), + }) + : language.$('artistPage.groupsLine.item.withCount', { + group: groupLink, + count: language.countContributions(count), + })))), + })), + + html.tag('dl', + sec.tracks.chunks.map(({albumLink, date, duration, entries}) => [ + html.tag('dt', + addAccentsToAlbumLink({albumLink, date, duration, entries})), + + html.tag('dd', + html.tag('ul', + entries + .map(({trackLink, duration, ...properties}) => ({ + entry: + (duration + ? language.$('artistPage.creditList.entry.track.withDuration', { + track: trackLink, + duration: language.formatDuration(duration), + }) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + ...properties, + })) + .map(addAccentsToEntry) + .map(entry => html.tag('li', entry)))), + ])), + ], + + 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.groupInfo && + html.tag('p', + language.$('artistPage.artGroupsLine', { + groups: + language.formatUnitList( + sec.artworks.groupInfo.map(({groupLink, count}) => + language.$('artistPage.groupsLine.item.withCount', { + group: groupLink, + count: + language.countContributions(count), + }))), + })), + + html.tag('dl', + sec.artworks.chunks.map(({albumLink, date, entries}) => [ + html.tag('dt', + addAccentsToAlbumLink({albumLink, date, entries})), + + html.tag('dd', + html.tag('ul', + entries + .map(({kind, trackLink, ...properties}) => ({ + entry: + (kind === 'trackCover' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.' + { + albumWallpaper: 'wallpaperArt', + albumBanner: 'bannerArt', + albumCover: 'coverArt', + }[kind]))), + ...properties, + })) + .map(addAccentsToEntry) + .map(entry => html.tag('li', entry)))), + ])), + ], + + sec.commentary && [ + sec.commentary.heading + .slots({ + tag: 'h2', + id: 'commentary', + title: language.$('artistPage.commentaryList.title'), + }), + + html.tag('dl', + sec.commentary.chunks.map(({albumLink, entries}) => [ + html.tag('dt', + language.$('artistPage.creditList.album', { + album: albumLink, + })), + + html.tag('dd', + html.tag('ul', + entries + .map(({kind, trackLink}) => + (kind === 'trackCommentary' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.commentary')))) + .map(entry => html.tag('li', entry)))), + ])), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + }) + .content, + }); + }, +}; + +/* + +export function write(artist, {wikiData}) { + const {groupData, wikiInfo} = wikiData; + + 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 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', + + content: [ + ...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)))), + ])), + ]), + ], + }, + }; + }, + }; + + const artThingsGallery = sortAlbumsTracksChronologically( + [ + ...(artist.albumsAsCoverArtist ?? []), + ...(artist.tracksAsCoverArtist ?? []), + ], + {latestFirst: true, getDate: (o) => o.coverArtDate}); + + 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, + }), + })), + + 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), + })), + ], + }, + }), + }; + + return [data, infoPage, galleryPage].filter(Boolean); +} + +*/ diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js new file mode 100644 index 00000000..f283b30d --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,105 @@ +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, + }; + }, + + generate(data, relations, {html, language}) { + return html.template({ + annotation: `generateArtistNav`, + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + content(slots) { + 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/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js new file mode 100644 index 00000000..a61b5e6f --- /dev/null +++ b/src/content/dependencies/generateChronologyLinks.js @@ -0,0 +1,88 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + generate({html, language}) { + return html.template({ + annotation: `generateChronologyLinks`, + + 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, + })), + })), + } + }, + + content(slots) { + 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..44600935 --- /dev/null +++ b/src/content/dependencies/generateColorStyleRules.js @@ -0,0 +1,41 @@ +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); + + 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}`, + ]; + + return [ + `:root {`, + ...variables.map((line) => ` ${line};`), + `}`, + ].join('\n'); + }, +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 00000000..1666ef4b --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,27 @@ +export default { + extraDependencies: [ + 'html', + ], + + generate({html}) { + return html.template({ + annotation: 'generateContentHeading', + + slots: { + title: {type: 'html'}, + id: {type: 'string'}, + tag: {type: 'string', default: 'p'}, + }, + + content(slots) { + 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..a7a7f859 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,83 @@ +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; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: 'generateCoverArtwork', + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + + displayMode: { + validate: v => v.is('primary', 'thumbnail'), + default: 'primary', + }, + }, + + content(slots) { + switch (slots.displayMode) { + 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, + }); + + case 'default': + return html.blank(); + } + }, + }); + }, +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 00000000..01b5b209 --- /dev/null +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -0,0 +1,56 @@ +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'), + })); + }, +}; + +/* +function unbound_getFooterLocalizationLinks({ + html, + defaultLanguage, + language, + languages, + pagePath, + to, +}) { +} +*/ diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 00000000..55f5b940 --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,506 @@ +import {empty, openAggregate} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'cachebust', + 'html', + 'language', + 'to', + 'transformMultiline', + '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; + }, + + generate(data, relations, { + cachebust, + html, + language, + to, + }) { + const sidebarSlots = side => ({ + // 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}, + }); + + return html.template({ + annotation: 'generatePageLayout', + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + coverNeedsReveal: {type: 'boolean'}, + + 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'), + + // 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; + }) + }, + + footerContent: {type: 'html'}, + }, + + content(slots) { + let titleHTML = null; + + if (!html.isBlank(slots.title)) { + switch (slots.headingMode) { + case 'sticky': + titleHTML = + relations.stickyHeadingContainer.slots({ + title: slots.title, + cover: slots.cover, + needsReveal: slots.coverNeedsReveal, + }); + 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 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 documentHTML = 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', + showWikiNameInTitle + ? language.formatString('misc.pageTitle.withWikiName', { + title, + wikiName: data.wikiName, + }) + : 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, + }))), + + */ + + // 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}`), + }), + ]), + ]) + ]); + + return documentHTML; + }, + }); + }, +}; diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js new file mode 100644 index 00000000..42b2c42b --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLinks.js @@ -0,0 +1,36 @@ +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'], + + generate({html, language}) { + return html.template({ + annotation: `generatePreviousNextLinks`, + + slots: { + previousLink: {type: 'html'}, + nextLink: {type: 'html'}, + }, + + content(slots) { + return [ + !html.isBlank(slots.previousLink) && + slots.previousLink.slots({ + tooltip: true, + attributes: {id: 'previous-button'}, + content: language.$('misc.nav.previous'), + }), + + !html.isBlank(slots.nextLink) && + slots.nextLink?.slots({ + tooltip: true, + attributes: {id: 'next-button'}, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean); + }, + }); + }, +}; 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..fb6d8307 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,45 @@ +export default { + extraDependencies: ['html'], + + generate({html}) { + return html.template({ + annotation: `generateStickyHeadingContainer`, + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + needsReveal: {type: 'boolean', default: false}, + }, + + content(slots) { + 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.needsReveal && + 'content-sticky-heading-cover-needs-reveal', + ]}, + slots.cover.slot('displayMode', 'thumbnail'))) + ]), + + html.tag('div', {class: 'content-sticky-subheading-row'}, + html.tag('h2', {class: 'content-sticky-subheading'})), + ]); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 00000000..ee68f534 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,132 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateTrackInfoPageContent', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkTrack', + ], + + extraDependencies: ['language'], + + relations(relation, track) { + return { + layout: relation('generatePageLayout'), + + 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, + ]), + }), + + 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, + }), + }), + + albumLink: relation('linkAlbum', track.album), + trackLink: relation('linkTrack', track), + albumNavAccent: relation('generateAlbumNavAccent', track.album, track), + chronologyLinks: relation('generateChronologyLinks'), + + content: relation('generateTrackInfoPageContent', track), + sidebar: relation('generateAlbumSidebar', track.album, track), + albumStyleRules: relation('generateAlbumStyleRules', track.album), + colorStyleRules: relation('generateColorStyleRules', track.color), + }; + }, + + data(track) { + return { + name: track.name, + + hasTrackNumbers: track.album.hasTrackNumbers, + trackNumber: track.album.tracks.indexOf(track) + 1, + }; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: language.$('trackPage.title', {track: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: relations.content.cover, + coverNeedsReveal: relations.content.coverNeedsReveal, + mainContent: relations.content.main.content, + + 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, + }); + }, +} diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js new file mode 100644 index 00000000..c33c2f62 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageContent.js @@ -0,0 +1,671 @@ +import {empty} from '../../util/sugar.js'; +import {sortFlashesChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateContentHeading', + 'generateCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'linkAlbum', + 'linkContribution', + 'linkExternal', + 'linkFlash', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + relations(relation, sprawl, track) { + const {album} = track; + + const relations = {}; + const sections = relations.sections = {}; + + const contributionLinksRelation = contribs => + contribs + .slice(0, 4) + .map(contrib => + relation('linkContribution', contrib.who, contrib.what)); + + const additionalFilesSection = additionalFiles => ({ + heading: relation('generateContentHeading'), + list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), + }); + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + releaseInfo.artistContributionLinks = + contributionLinksRelation(track.artistContribs); + + if (track.hasUniqueCoverArt) { + relations.cover = + relation('generateCoverArtwork', track.artTags); + releaseInfo.coverArtistContributionLinks = + contributionLinksRelation(track.coverArtistContribs); + } else if (album.hasCoverArt) { + relations.cover = + relation('generateCoverArtwork', album.artTags); + } else { + relations.cover = null; + } + + // Section: Listen on + + const listen = sections.listen = {}; + + if (!empty(track.urls)) { + listen.externalLinks = + track.urls.map(url => + relation('linkExternal', url)); + } + + // 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 = + contributionLinksRelation(track.contributorContribs); + } + + // 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) { + const data = {}; + + const {album} = track; + + data.name = track.name; + data.date = track.date; + data.duration = track.duration; + + data.hasUniqueCoverArt = track.hasUniqueCoverArt; + data.hasAlbumCoverArt = album.hasCoverArt; + + if (track.hasUniqueCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.trackCoverArtDirectory = track.directory; + data.coverArtFileExtension = track.coverArtFileExtension; + data.coverNeedsReveal = track.artTags.some(t => t.isContentWarning); + + if (track.coverArtDate && +track.coverArtDate !== +track.date) { + data.coverArtDate = track.coverArtDate; + } + } else if (track.album.hasCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + data.coverNeedsReveal = album.artTags.some(t => t.isContentWarning); + } + + if (!empty(track.additionalFiles)) { + data.numAdditionalFiles = track.additionalFiles.length; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const content = {}; + + const {sections: sec} = relations; + + const formatContributions = + (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => + contributionLinks && + language.$(stringKey, { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({showContribution, showIcons}))), + }); + + if (data.hasUniqueCoverArt) { + content.cover = relations.cover + .slots({ + path: [ + 'media.trackCover', + data.albumCoverArtDirectory, + data.trackCoverArtDirectory, + data.coverArtFileExtension, + ], + }); + content.coverNeedsReveal = data.coverNeedsReveal; + } else if (data.hasAlbumCoverArt) { + content.cover = relations.cover + .slots({ + path: [ + 'media.albumCover', + data.albumCoverArtDirectory, + data.coverArtFileExtension, + ], + }); + content.coverNeedsReveal = data.coverNeedsReveal; + } else { + content.cover = null; + content.coverNeedsReveal = null; + } + + content.main = { + headingMode: 'sticky', + + content: html.tags([ + html.tag('p', { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, [ + formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), + formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), + + 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', + (sec.listen.externalLinks + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + + 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')), + ], + ]), + }; + + return content; + }, +}; + +/* + 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, + }, + 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..e2e9f48d --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,55 @@ +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.who, contrib.what)), + })), + }; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: `generateTrackList`, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + content(slots) { + 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/image.js b/src/content/dependencies/image.js new file mode 100644 index 00000000..f9cb00bf --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,207 @@ +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; + }, + + generate(data, { + getSizeOfImageFile, + html, + language, + thumb, + to, + }) { + return html.template({ + annotation: 'image', + + 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'}, + alt: {type: 'string'}, + width: {type: 'number'}, + height: {type: 'number'}, + + missingSourceContent: {type: 'html'}, + }, + + content(slots) { + 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; + + 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, + 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', + ], + + 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..1210d78e --- /dev/null +++ b/src/content/dependencies/index.js @@ -0,0 +1,235 @@ +import chokidar from 'chokidar'; +import EventEmitter from 'events'; +import * as path from 'path'; +import {ESLint} from 'eslint'; +import {fileURLToPath} from 'url'; + +import contentFunction 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 initialScanComplete = false; + let allDependenciesFulfilled = false; + + Object.assign(events, { + contentDependencies, + close, + }); + + const eslint = new ESLint(); + + // Watch adjacent files + const metaPath = fileURLToPath(import.meta.url); + const metaDirname = path.dirname(metaPath); + 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); + }); + + watcher.on('ready', () => { + initialScanComplete = true; + checkReadyConditions(); + }); + + if (mock) { + const errors = []; + for (const [functionName, spec] of Object.entries(mock)) { + 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`); + } + checkReadyConditions(); + } + + return events; + + async function close() { + return watcher.close(); + } + + function checkReadyConditions() { + if (emittedReady) { + return; + } + + if (!initialScanComplete) { + return; + } + + checkAllDependenciesFulfilled(); + + if (!allDependenciesFulfilled) { + return; + } + + events.emit('ready'); + emittedReady = true; + } + + function checkAllDependenciesFulfilled() { + allDependenciesFulfilled = !Object.values(contentDependencies).includes(null); + } + + function getFunctionName(filePath) { + const shortPath = path.basename(filePath); + const functionName = shortPath.slice(0, -path.extname(shortPath).length); + return functionName; + } + + function isMocked(functionName) { + return !!mock && Object.keys(mock).includes(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; + } + + let fn; + try { + fn = processFunctionSpec(functionName, spec); + } catch (caughtError) { + error = caughtError; + break main; + } + + if (logging && initialScanComplete) { + 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 { + 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}); + } + + let fn; + try { + fn = contentFunction(spec); + } catch (error) { + error.message = `Error loading spec: ${error.message}`; + throw error; + } + + return fn; + } +} + +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..27c0ba9c --- /dev/null +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -0,0 +1,26 @@ +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..1d0e2d6a --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,74 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkExternalAsIcon', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, artist) { + const relations = {}; + + relations.artistLink = relation('linkArtist', artist); + + relations.artistIcons = + (artist.urls ?? []).map(url => + relation('linkExternalAsIcon', url)); + + return relations; + }, + + data(artist, contribution) { + return {contribution}; + }, + + generate(data, relations, { + html, + language, + }) { + return html.template({ + annotation: 'linkContribution', + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + content(slots) { + const hasContributionPart = !!(slots.showContribution && data.contribution); + const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons)); + + const externalLinks = hasExternalPart && + html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'icons'}, + language.formatUnitList(relations.artistIcons)); + + return ( + (hasContributionPart + ? (hasExternalPart + ? language.$('misc.artistLink.withContribution.withExternalLinks', { + artist: relations.artistLink, + contrib: data.contribution, + links: externalLinks, + }) + : language.$('misc.artistLink.withContribution', { + artist: relations.artistLink, + contrib: data.contribution, + })) + : (hasExternalPart + ? language.$('misc.artistLink.withExternalLinks', { + artist: relations.artistLink, + links: externalLinks, + }) + : language.$('misc.artistLink', { + artist: relations.artistLink, + })))); + }, + }); + }, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 00000000..08191a21 --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,93 @@ +// 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, { + type = 'generic', + } = {}) { + const types = ['generic', 'album']; + if (!types.includes(type)) { + throw new TypeError(`Expected type to be one of ${types}`); + } + + return { + url, + type, + }; + }, + + generate(data, {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') + ? data.type === '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..6496d026 --- /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.staticFile', `icons.svg#icon-${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/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..f27d93ac --- /dev/null +++ b/src/content/dependencies/linkListing.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, listing) => + ({link: relation('linkThing', 'localized.listing', listing)}), + + 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/linkTemplate.js b/src/content/dependencies/linkTemplate.js new file mode 100644 index 00000000..9109ab50 --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,73 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'appendIndexHTML', + 'getColors', + 'html', + 'to', + ], + + generate({ + appendIndexHTML, + getColors, + html, + to, + }) { + return html.template({ + annotation: 'linkTemplate', + + 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'}, + }, + + content(slots) { + 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..1e648ee6 --- /dev/null +++ b/src/content/dependencies/linkThing.js @@ -0,0 +1,91 @@ +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, + }; + }, + + generate(data, relations, {html}) { + const path = [data.pathKey, data.directory]; + + return html.template({ + annotation: 'linkThing', + + slots: { + content: relations.linkTemplate.getSlotDescription('content'), + 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, + }, + + attributes: relations.linkTemplate.getSlotDescription('attributes'), + hash: relations.linkTemplate.getSlotDescription('hash'), + }, + + content(slots) { + 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, + 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/transformContent.js b/src/content/dependencies/transformContent.js new file mode 100644 index 00000000..bf4233fd --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,325 @@ +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]); + } + }), + }; + }, + + generate(data, relations, {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); + }); + + return html.template({ + annotation: `transformContent`, + + slots: { + mode: { + validate: v => v.is('inline', 'multiline', 'lyrics'), + default: 'multiline', + }, + }, + + content(slots) { + // 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. + .replace(/(?<!^ *-.*)\n+/gm, '\n\n') + // Expand line breaks which are at the end of a list. + .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..bae2c8c5 --- /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 { + other.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..98ab9bc7 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -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 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/misc-templates.js b/src/misc-templates.js index 8f3f0166..dfff4d88 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.staticFile', `icons.svg#icon-${id}`), - }), - ])); -} - // Grids function unbound_getGridHTML({ @@ -636,123 +97,6 @@ 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: @@ -851,228 +195,11 @@ function unbound_getCarouselHTML({ })))))); } -// 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.js b/src/page/album.js index 9ee57c09..111cab82 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 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.js b/src/page/artist.js index 4ef44d32..ad365161 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -2,682 +2,22 @@ // // 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'; - export const description = `per-artist info & artwork gallery pages`; 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', - - content: [ - ...html.fragment( - contextNotes && [ - html.tag('p', - language.$('releaseInfo.note')), - - 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, - }), - }; - }, - }; - - 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, - }), - })), - - 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), - })), - ], - }, - - nav: generateNavForArtist(artist, true, hasGallery, { - generateInfoGalleryLinks, - link, - language, - wikiData, - }), - }), - }; - - return [data, infoPage, galleryPage].filter(Boolean); -} - -// Utility functions - -function generateNavForArtist(artist, isGallery, hasGallery, { - generateInfoGalleryLinks, - language, - link, - wikiData, -}) { - const {wikiInfo} = wikiData; +export function pathsForTarget(artist) { + return [ + { + type: 'page', + path: ['artist', artist.directory], - const infoGalleryLinks = - hasGallery && - generateInfoGalleryLinks(artist, isGallery, { - link, - language, - linkKeyGallery: 'artistGallery', - linkKeyInfo: 'artist', - }); - - return { - linkContainerClasses: ['nav-links-hierarchy'], - links: [ - {toHome: true}, - wikiInfo.enableListings && { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title'), - }, - { - html: language.$('artistPage.nav.artist', { - artist: link.artist(artist, {class: 'current'}), - }), + contentFunction: { + name: 'generateArtistInfoPage', + args: [artist], }, - hasGallery && { - divider: false, - html: `(${infoGalleryLinks})`, - }, - ], - }; + }, + ]; } diff --git a/src/page/index.js b/src/page/index.js index f580cbea..77ebfb6f 100644 --- a/src/page/index.js +++ b/src/page/index.js @@ -2,52 +2,20 @@ // 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 albumCommentary from './album-commentary.js'; export * as artist from './artist.js'; -export * as artistAlias from './artist-alias.js'; -export * as flash from './flash.js'; -export * as group from './group.js'; -export * as homepage from './homepage.js'; -export * as listing from './listing.js'; -export * as news from './news.js'; +// export * as artistAlias from './artist-alias.js'; +// export * as flash from './flash.js'; +// export * as group from './group.js'; +// export * as homepage from './homepage.js'; +// export * as listing from './listing.js'; +// export * as news from './news.js'; export * as static from './static.js'; -export * as tag from './tag.js'; +// export * as tag from './tag.js'; export * as track from './track.js'; 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..35ef82ee 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 -------------------------------------- diff --git a/src/static/site3.css b/src/static/site4.css index 3ebe782d..28a4924d 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"; } @@ -1358,6 +1362,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..345f20ff 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -96,14 +96,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 +125,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 +138,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}", @@ -270,7 +275,8 @@ "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.trackList.title": "Tracks", "artistPage.artList.title": "Artworks", "artistPage.flashList.title": "Flashes & Games", 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 1c55fb8c..b5930d06 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,5 +1,8 @@ // Some really simple functions for formatting HTML content. +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,128 +41,731 @@ 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) - let openTag; + 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; + }, +}; + +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); } - 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?`); + 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); + } + + #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 ''; } - const joiner = attrs?.[joinChildren]; - content = content.filter(Boolean).join( - (joiner === '' + 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; + } + + 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); } +} + +export class Attributes { + #attributes = Object.create(null); - if (attrs) { - const attrString = attributes(attrs); - if (attrString) { - openTag = `${tagName} ${attrString}`; + 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]) => { + switch (key) { + case 'href': + return [key, encodeURI(val)]; + default: + return [key, val]; + } + }) + .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]) => { - switch (key) { - case 'href': - return [key, encodeURI(val)]; - default: - return [key, val]; +export class Template { + #description = {}; + #slotValues = {}; + + constructor(description) { + 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`)); } - }) - .map(([key, val]) => - typeof val === 'boolean' - ? `${key}` - : `${key}="${escapeAttributeValue(val)}"` - ) - .join(' '); -} + } + + if ('slots' in description) validateSlots: { + if (typeof description.slots !== 'object') { + topErrors.push(new TypeError(`Expected description.slots to be object`)); + break validateSlots; + } + + const slotErrors = []; -// 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 []; + for (const [slotName, slotDescription] of Object.entries(description.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)) { + topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + } + } + + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (typeof description.annotation === 'string' + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } + + return true; } - if (Array.isArray(childOrChildren)) { - return childOrChildren; + slot(slotName, value) { + this.setSlot(slotName, value); + return this; } - return [childOrChildren]; + 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(); + } } 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..6ab70bc6 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -82,6 +82,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); +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 +154,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 +234,10 @@ export function openAggregate({ ); }; + aggregate.push = (error) => { + errors.push(error); + }; + aggregate.call = (fn, ...args) => { return aggregate.wrap(fn)(...args); }; @@ -421,6 +443,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 +488,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 +507,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/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..10b40cf0 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,35 @@ 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(); + ? [pageSpec.writeTargetless({wikiData})] + : pageSpec.pathsForTarget(target))).flat(); logInfo`Will be serving a total of ${pages.length} pages.`; @@ -143,7 +168,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 +211,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 +264,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 +330,6 @@ export async function go({ return; } - response.writeHead(200, contentTypeHTML); - const localizedPathnames = getPagePathnameAcrossLanguages({ defaultLanguage, languages, @@ -314,37 +337,139 @@ 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, + transformMultiline: text => `<p>${text}</p>`, + }; + + // 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: flatRelations}) { + const contentFunction = fulfilledContentDependencies[name]; + + if (!contentFunction) { + throw new Error(`Content function ${name} unfulfilled or not listed`); + } + + const sprawl = + contentFunction.sprawl?.(allExtraDependencies.wikiData, ...args); + + const relations = + fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, flatRelations); + + const data = + (sprawl + ? contentFunction.data?.(sprawl, ...args) + : contentFunction.data?.(...args)); + + const generateArgs = [data, relations].filter(Boolean); + + 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); } }); diff --git a/src/write/page-template.js b/src/write/page-template.js index 8a3b44e8..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}) { |