diff options
Diffstat (limited to 'src/content')
104 files changed, 9060 insertions, 0 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js new file mode 100644 index 00000000..d280a633 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,97 @@ +import {empty} from '../../util/sugar.js'; + +function validateFileMapping(v, validateValue) { + return value => { + v.isObject(value); + + const valueErrors = []; + for (const [fileKey, fileValue] of Object.entries(value)) { + if (fileValue === null) { + continue; + } + + try { + validateValue(fileValue); + } catch (error) { + error.message = `(${fileKey}) ` + error.message; + valueErrors.push(error); + } + } + + if (!empty(valueErrors)) { + throw new AggregateError(valueErrors, `Errors validating values`); + } + }; +} + +export default { + extraDependencies: ['html', 'language'], + + data(additionalFiles) { + return { + // Additional files are already a serializable format. + additionalFiles, + }; + }, + + slots: { + fileLinks: { + validate: v => validateFileMapping(v, v.isHTML), + }, + + fileSizes: { + validate: v => validateFileMapping(v, v.isWholeNumber), + }, + }, + + generate(data, slots, {html, language}) { + if (!slots.fileLinks) { + return html.blank(); + } + + const filesWithLinks = new Set( + Object.entries(slots.fileLinks) + .filter(([key, value]) => value) + .map(([key]) => key)); + + if (empty(filesWithLinks)) { + return html.blank(); + } + + const filteredFileGroups = data.additionalFiles + .map(({title, description, files}) => ({ + title, + description, + files: files.filter(f => filesWithLinks.has(f)), + })) + .filter(({files}) => !empty(files)); + + if (empty(filteredFileGroups)) { + return html.blank(); + } + + return html.tag('dl', + filteredFileGroups.flatMap(({title, description, files}) => [ + html.tag('dt', + (description + ? language.$('releaseInfo.additionalFiles.entry.withDescription', { + title, + description, + }) + : language.$('releaseInfo.additionalFiles.entry', {title}))), + + html.tag('dd', + html.tag('ul', + files.map(file => + html.tag('li', + (slots.fileSizes?.[file] + ? language.$('releaseInfo.additionalFiles.file.withSize', { + file: slots.fileLinks[file], + size: language.formatFileSize(slots.fileSizes[file]), + }) + : language.$('releaseInfo.additionalFiles.file', { + file: slots.fileLinks[file], + })))))), + ])); + }, +}; diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js new file mode 100644 index 00000000..17280da5 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesShortcut.js @@ -0,0 +1,27 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + data(additionalFiles) { + return { + titles: additionalFiles.map(fileGroup => fileGroup.title), + }; + }, + + generate(data, {html, language}) { + if (empty(data.titles)) { + return html.blank(); + } + + return language.$('releaseInfo.additionalFiles.shortcut', { + anchorLink: + html.tag('a', + {href: '#additional-files'}, + language.$('releaseInfo.additionalFiles.shortcut.anchorLink')), + + titles: + language.formatUnitList(data.titles), + }); + }, +} diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js new file mode 100644 index 00000000..23f32bf5 --- /dev/null +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -0,0 +1,59 @@ +export default { + contentDependencies: [ + 'generateAdditionalFilesList', + 'linkAlbumAdditionalFile', + ], + + extraDependencies: [ + 'getSizeOfAdditionalFile', + 'html', + 'urls', + ], + + data(album, additionalFiles) { + return { + albumDirectory: album.directory, + fileLocations: additionalFiles.flatMap(({files}) => files), + }; + }, + + relations(relation, album, additionalFiles) { + return { + additionalFilesList: + relation('generateAdditionalFilesList', additionalFiles), + + additionalFileLinks: + Object.fromEntries( + additionalFiles + .flatMap(({files}) => files) + .map(file => [ + file, + relation('linkAlbumAdditionalFile', album, file), + ])), + }; + }, + + slots: { + showFileSizes: {type: 'boolean', default: true}, + }, + + generate(data, relations, slots, { + getSizeOfAdditionalFile, + urls, + }) { + return relations.additionalFilesList + .slots({ + fileLinks: relations.additionalFileLinks, + fileSizes: + Object.fromEntries(data.fileLocations.map(file => [ + file, + (slots.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, file)) + : 0), + ])), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js new file mode 100644 index 00000000..3cc141bc --- /dev/null +++ b/src/content/dependencies/generateAlbumBanner.js @@ -0,0 +1,37 @@ +export default { + contentDependencies: ['generateBanner'], + extraDependencies: ['html', 'language'], + + relations(relation, album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + banner: relation('generateBanner'), + }; + }, + + data(album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + path: ['media.albumBanner', album.directory, album.bannerFileExtension], + dimensions: album.bannerDimensions, + }; + }, + + generate(data, relations, {html, language}) { + if (!relations.banner) { + return html.blank(); + } + + return relations.banner.slots({ + path: data.path, + dimensions: data.dimensions, + alt: language.$('misc.alt.albumBanner'), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js new file mode 100644 index 00000000..ea31292c --- /dev/null +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -0,0 +1,166 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumNavAccent', + 'generateAlbumStyleRules', + 'generateColorStyleRules', + 'generateColorStyleVariables', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + if (album.commentary) { + relations.albumCommentaryContent = + relation('transformContent', album.commentary); + } + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + relations.trackCommentaryHeadings = + tracksWithCommentary + .map(() => relation('generateContentHeading')); + + relations.trackCommentaryLinks = + tracksWithCommentary + .map(track => relation('linkTrack', track)); + + relations.trackCommentaryContent = + tracksWithCommentary + .map(track => relation('transformContent', track.commentary)); + + relations.trackCommentaryColorVariables = + tracksWithCommentary + .map(track => + (track.color === album.color + ? null + : relation('generateColorStyleVariables', track.color))); + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + const thingsWithCommentary = + (album.commentary + ? [album, ...tracksWithCommentary] + : tracksWithCommentary); + + data.entryCount = thingsWithCommentary.length; + + data.wordCount = + thingsWithCommentary + .map(({commentary}) => commentary) + .join(' ') + .split(' ') + .length; + + data.trackCommentaryDirectories = + tracksWithCommentary + .map(track => track.directory); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('albumCommentaryPage.title', { + album: data.name, + }), + + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', + language.$('albumCommentaryPage.infoLine', { + words: + html.tag('b', + language.formatWordCount(data.wordCount, {unit: true})), + + entries: + html.tag('b', + language.countCommentaryEntries(data.entryCount, {unit: true})), + })), + + relations.albumCommentaryContent && [ + html.tag('h3', + {class: ['content-heading']}, + language.$('albumCommentaryPage.entry.title.albumCommentary')), + + html.tag('blockquote', + relations.albumCommentaryContent), + ], + + stitchArrays({ + heading: relations.trackCommentaryHeadings, + link: relations.trackCommentaryLinks, + directory: data.trackCommentaryDirectories, + content: relations.trackCommentaryContent, + colorVariables: relations.trackCommentaryColorVariables, + }).map(({heading, link, directory, content, colorVariables}) => [ + heading.slots({ + tag: 'h3', + id: directory, + title: link, + }), + html.tag('blockquote', {style: colorVariables}, content), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: false, + showExtraLinks: true, + currentExtra: 'commentary', + }), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js new file mode 100644 index 00000000..f7e86303 --- /dev/null +++ b/src/content/dependencies/generateAlbumCoverArtwork.js @@ -0,0 +1,23 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations(relation, album) { + return { + coverArtwork: + relation('generateCoverArtwork', album.artTags), + }; + }, + + data(album) { + return { + path: ['media.albumCover', album.directory, album.coverArtFileExtension], + }; + }, + + generate(data, relations) { + return relations.coverArtwork + .slots({ + path: data.path, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryInfoLine.js b/src/content/dependencies/generateAlbumGalleryInfoLine.js new file mode 100644 index 00000000..d4bd4d75 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryInfoLine.js @@ -0,0 +1,38 @@ +import {getTotalDuration} from '../../util/wiki-data.js'; + +export default { + extraDependencies: ['html', 'language'], + + data(album) { + return { + name: album.name, + date: album.date, + duration: getTotalDuration(album.tracks), + numTracks: album.tracks.length, + }; + }, + + generate(data, {html, language}) { + const parts = ['albumGalleryPage.infoLine']; + const options = {}; + + options.tracks = + html.tag('b', + language.countTracks(data.numTracks, {unit: true})); + + options.duration = + html.tag('b', + language.formatDuration(data.duration, {unit: true})); + + if (data.date) { + parts.push('withDate'); + options.date = + html.tag('b', + language.formatDate(data.date)); + } + + return ( + html.tag('p', {class: 'quick-info'}, + language.formatString(parts.join('.'), options))); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js new file mode 100644 index 00000000..b39b4c80 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -0,0 +1,137 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryInfoLine', + 'generateAlbumNavAccent', + 'generateAlbumStyleRules', + 'generateColorStyleRules', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.infoLine = + relation('generateAlbumGalleryInfoLine', album); + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = + album.tracks.map(track => + relation('linkTrack', track)); + + relations.images = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? relation('image', track.artTags) + : relation('image'))); + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + data.names = + album.tracks.map(track => track.name); + + data.coverArtists = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? track.coverArtistContribs.map(({who: artist}) => artist.name) + : null)); + + data.paths = + album.tracks.map(track => + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : null)); + + return data; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: + language.$('albumGalleryPage.title', { + album: data.name, + }), + + headingMode: 'static', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.infoLine, + + relations.coverGrid + .slots({ + links: relations.links, + names: data.names, + images: + stitchArrays({ + image: relations.images, + path: data.paths, + name: data.names, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', {name}), + })), + info: + data.coverArtists.map(names => + (names === null + ? null + : language.$('misc.albumGrid.details.coverArtists', { + artists: language.formatUnitList(names), + }))), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: false, + showExtraLinks: true, + currentExtra: 'gallery', + }), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 00000000..8fbb81f9 --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,286 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumBanner', + 'generateAlbumCoverArtwork', + 'generateAlbumNavAccent', + 'generateAlbumReleaseInfo', + 'generateAlbumSecondaryNav', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateAlbumTrackList', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkAlbumCommentary', + 'linkAlbumGallery', + 'linkArtist', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.socialEmbed = + relation('generateAlbumSocialEmbed', album); + + relations.coverArtistChronologyContributions = + getChronologyRelations(album, { + contributions: album.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ]), + }); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.secondaryNav = + relation('generateAlbumSecondaryNav', album); + + relations.sidebar = + relation('generateAlbumSidebar', album, null); + + if (album.hasCoverArt) { + relations.cover = + relation('generateAlbumCoverArtwork', album); + } + + if (album.hasBannerArt) { + relations.banner = + relation('generateAlbumBanner', album); + } + + // Section: Release info + + relations.releaseInfo = + relation('generateAlbumReleaseInfo', album); + + // Section: Extra links + + const extra = sections.extra = {}; + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + extra.galleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + extra.commentaryLink = + relation('linkAlbumCommentary', album); + } + + if (!empty(album.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', album.additionalFiles); + } + + // Section: Track list + + relations.trackList = + relation('generateAlbumTrackList', album); + + // Section: Additional files + + if (!empty(album.additionalFiles)) { + const additionalFiles = sections.additionalFiles = {}; + + additionalFiles.heading = + relation('generateContentHeading'); + + additionalFiles.additionalFilesList = + relation('generateAlbumAdditionalFilesList', album, album.additionalFiles); + } + + // Section: Artist commentary + + if (album.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', album.commentary); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + + if (!empty(album.additionalFiles)) { + data.numAdditionalFiles = album.additionalFiles.length; + } + + data.dateAddedToWiki = album.dateAddedToWiki; + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('albumPage.title', {album: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: + relations.cover + ?.slots({ + alt: language.$('misc.alt.albumCover'), + }) + ?? null, + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + sec.extra.additionalFilesShortcut, + + sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewGalleryOrCommentary', { + gallery: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')), + commentary: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')), + }), + + sec.extra.galleryLink && !sec.extra.commentaryLink && + language.$('releaseInfo.viewGallery', { + link: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGallery.link')), + }), + + !sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewCommentary', { + link: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewCommentary.link')), + }), + ]), + + relations.trackList, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + data.dateAddedToWiki && + language.$('releaseInfo.addedToWiki', { + date: language.formatDate(data.dateAddedToWiki), + }), + ]), + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.additionalFilesList, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + auto: 'current', + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: true, + }), + }, + ], + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + banner: relations.banner ?? null, + bannerPosition: 'top', + + secondaryNav: relations.secondaryNav, + + ...relations.sidebar, + + // socialEmbed: relations.socialEmbed, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js new file mode 100644 index 00000000..0237fdec --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,114 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkTrack', + 'linkAlbumCommentary', + 'linkAlbumGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, track) { + const relations = {}; + + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.previousTrackLink = null; + relations.nextTrackLink = null; + + if (track) { + const index = album.tracks.indexOf(track); + + if (index > 0) { + relations.previousTrackLink = + relation('linkTrack', album.tracks[index - 1]); + } + + if (index < album.tracks.length - 1) { + relations.nextTrackLink = + relation('linkTrack', album.tracks[index + 1]); + } + } + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + relations.albumGalleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + relations.albumCommentaryLink = + relation('linkAlbumCommentary', album); + } + + return relations; + }, + + data(album, track) { + return { + hasMultipleTracks: album.tracks.length > 1, + isTrackPage: !!track, + }; + }, + + slots: { + showTrackNavigation: {type: 'boolean', default: false}, + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery', 'commentary'), + }, + }, + + generate(data, relations, slots, {html, language}) { + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.albumGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('albumPage.nav.gallery'), + }), + + relations.albumCommentaryLink?.slots({ + attributes: {class: slots.currentExtra === 'commentary' && 'current'}, + content: language.$('albumPage.nav.commentary'), + }), + ]}; + + const {content: previousNextLinks = []} = + slots.showTrackNavigation && + data.isTrackPage && + data.hasMultipleTracks && + relations.previousNextLinks.slots({ + previousLink: relations.previousTrackLink, + nextLink: relations.nextTrackLink, + }); + + const randomLink = + slots.showTrackNavigation && + data.hasMultipleTracks && + html.tag('a', + { + href: '#', + 'data-random': 'track-in-album', + id: 'random-button', + }, + (data.isTrackPage + ? language.$('trackPage.nav.random') + : language.$('albumPage.nav.randomTrack'))); + + const allLinks = [ + ...previousNextLinks, + ...extraLinks, + randomLink, + ].filter(Boolean); + + if (empty(allLinks)) { + return html.blank(); + } + + return `(${language.formatUnitList(allLinks)})`; + }, +}; diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js new file mode 100644 index 00000000..86e6dfe9 --- /dev/null +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -0,0 +1,101 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.artistContribs); + + relations.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.coverArtistContribs); + + relations.wallpaperArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); + + relations.bannerArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); + + if (!empty(album.urls)) { + relations.externalLinks = + album.urls.map(url => + relation('linkExternal', url)); + } + + return relations; + }, + + data(album) { + const data = {}; + + if (album.date) { + data.date = album.date; + } + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + + data.duration = accumulateSum(album.tracks, track => track.duration); + data.durationApproximate = album.tracks.length > 1; + + return data; + }, + + generate(data, relations, {html, language}) { + return html.tags([ + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + relations.artistContributionsLine + .slots({stringKey: 'releaseInfo.by'}), + + relations.coverArtistContributionsLine + .slots({stringKey: 'releaseInfo.coverArtBy'}), + + relations.wallpaperArtistContributionsLine + .slots({stringKey: 'releaseInfo.wallpaperArtBy'}), + + relations.bannerArtistContributionsLine + .slots({stringKey: 'releaseInfo.bannerArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: + language.formatDuration(data.duration, { + approximate: data.durationApproximate, + }), + }), + ]), + + relations.externalLinks && + html.tag('p', + language.$('releaseInfo.listenOn', { + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('mode', 'album'))), + })), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js new file mode 100644 index 00000000..6616f20e --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -0,0 +1,98 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleVariables', + 'generateSecondaryNav', + 'linkAlbum', + 'linkGroup', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.secondaryNav = + relation('generateSecondaryNav'); + + relations.groupParts = + album.groups.map(group => { + const relations = {}; + + relations.groupLink = + relation('linkGroup', group); + + relations.colorVariables = + relation('generateColorStyleVariables', group.color); + + if (album.date) { + const albums = group.albums.filter(album => album.date); + const index = albums.indexOf(album); + const previousAlbum = (index > 0) && albums[index - 1]; + const nextAlbum = (index < albums.length - 1) && albums[index + 1]; + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + } + + return relations; + }); + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + }, + + generate(relations, slots, {html, language}) { + return relations.secondaryNav.slots({ + class: 'nav-links-groups', + content: + relations.groupParts.map(({ + colorVariables, + groupLink, + previousAlbumLink, + nextAlbumLink, + }) => { + const links = [ + previousAlbumLink + ?.slots({ + color: false, + content: language.$('misc.nav.previous'), + }), + + nextAlbumLink + ?.slots({ + color: false, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean); + + return ( + (slots.mode === 'album' && !empty(links) + ? html.tag('span', {style: colorVariables}, [ + language.$('albumSidebar.groupBox.title', { + group: groupLink, + }), + `(${language.formatUnitList(links)})`, + ]) + : language.$('albumSidebar.groupBox.title', { + group: groupLink, + }))); + }), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 00000000..a84f4357 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,75 @@ +export default { + contentDependencies: [ + 'generateAlbumSidebarGroupBox', + 'generateAlbumSidebarTrackSection', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + relations(relation, album, track) { + const relations = {}; + + relations.albumLink = + relation('linkAlbum', album); + + relations.groupBoxes = + album.groups.map(group => + relation('generateAlbumSidebarGroupBox', album, group)); + + relations.trackSections = + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, track, trackSection)); + + return relations; + }, + + data(album, track) { + return {isAlbumPage: !track}; + }, + + generate(data, relations, {html}) { + const trackListBox = { + content: + html.tags([ + html.tag('h1', relations.albumLink), + relations.trackSections, + ]), + }; + + if (data.isAlbumPage) { + const groupBoxes = + relations.groupBoxes + .map(content => content.slot('mode', 'album')) + .map(content => ({content})); + + return { + leftSidebarMultiple: [ + ...groupBoxes, + trackListBox, + ], + }; + } + + const conjoinedGroupBox = { + content: + relations.groupBoxes + .flatMap((content, i, {length}) => [ + content.slot('mode', 'track'), + i < length - 1 && + html.tag('hr', { + style: `border-color: var(--primary-color); border-style: none none dotted none` + }), + ]) + .filter(Boolean), + }; + + return { + // leftSidebarStickyMode: 'column', + leftSidebarMultiple: [ + trackListBox, + conjoinedGroupBox, + ], + }; + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js new file mode 100644 index 00000000..874dcc20 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,87 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkAlbum', + 'linkExternal', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, group) { + const relations = {}; + + relations.groupLink = + relation('linkGroup', group); + + relations.externalLinks = + group.urls.map(url => + relation('linkExternal', url)); + + if (group.descriptionShort) { + relations.description = + relation('transformContent', group.descriptionShort); + } + + if (album.date) { + const albums = group.albums.filter(album => album.date); + const index = albums.indexOf(album); + const previousAlbum = (index > 0) && albums[index - 1]; + const nextAlbum = (index < albums.length - 1) && albums[index + 1]; + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + } + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'track', + }, + }, + + generate(relations, slots, {html, language}) { + return html.tags([ + html.tag('h1', + language.$('albumSidebar.groupBox.title', { + group: relations.groupLink, + })), + + slots.mode === 'album' && + relations.description + ?.slot('mode', 'multiline'), + + !empty(relations.externalLinks) && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(relations.externalLinks), + })), + + slots.mode === 'album' && + relations.nextAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.next', { + album: relations.nextAlbumLink, + })), + + slots.mode === 'album' && + relations.previousAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.previous', { + album: relations.previousAlbumLink, + })), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js new file mode 100644 index 00000000..2aca6da1 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -0,0 +1,98 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['getColors', 'html', 'language'], + + relations(relation, album, track, trackSection) { + const relations = {}; + + relations.trackLinks = + trackSection.tracks.map(track => + relation('linkTrack', track)); + + return relations; + }, + + data(album, track, trackSection) { + const data = {}; + + data.hasTrackNumbers = album.hasTrackNumbers; + data.isTrackPage = !!track; + + data.name = trackSection.name; + data.color = trackSection.color; + data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + + data.firstTrackNumber = trackSection.startIndex + 1; + data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length; + + if (track) { + const index = trackSection.tracks.indexOf(track); + if (index !== -1) { + data.includesCurrentTrack = true; + data.currentTrackIndex = index; + } + } + + return data; + }, + + generate(data, relations, {getColors, html, language}) { + const sectionName = + html.tag('span', {class: 'group-name'}, + (data.isDefaultTrackSection + ? language.$('albumSidebar.trackList.fallbackSectionName') + : data.name)); + + let style; + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + const trackListItems = + relations.trackLinks.map((trackLink, index) => + html.tag('li', + { + class: + data.includesCurrentTrack && + index === data.currentTrackIndex && + 'current', + }, + language.$('albumSidebar.trackList.item', { + track: trackLink, + }))); + + return html.tag('details', + { + class: data.includesCurrentTrack && 'current', + + open: ( + // Leave sidebar track sections collapsed on album info page, + // since there's already a view of the full track listing + // in the main content area. + data.isTrackPage && + + // Only expand the track section which includes the track + // currently being viewed by default. + data.includesCurrentTrack), + }, + [ + html.tag('summary', {style}, + html.tag('span', + (data.hasTrackNumbers + ? language.$('albumSidebar.trackList.group.withRange', { + group: sectionName, + range: `${data.firstTrackNumber}–${data.lastTrackNumber}` + }) + : language.$('albumSidebar.trackList.group', { + group: sectionName, + })))), + + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js new file mode 100644 index 00000000..079899d3 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language', 'urls'], + + relations(relation, album) { + const relations = {}; + + relations.description = + relation('generateAlbumSocialEmbedDescription', album); + + return relations; + }, + + data(album) { + const data = {}; + + data.hasHeading = !empty(album.groups); + + if (data.hasHeading) { + const firstGroup = album.groups[0]; + data.headingGroupName = firstGroup.directory; + data.headingGroupDirectory = firstGroup.directory; + } + + data.hasImage = album.hasCoverArt; + + if (data.hasImage) { + data.coverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + } + + data.albumName = album.name; + data.albumColor = album.color; + + return data; + }, + + generate(data, relations, {absoluteTo, language, urls}) { + const socialEmbed = {}; + + if (data.hasHeading) { + socialEmbed.heading = + language.$('albumPage.socialEmbed.heading', { + group: data.headingGroupName, + }); + + socialEmbed.headingLink = + absoluteTo('localized.album', data.headingGroupDirectory); + } else { + socialEmbed.heading = ''; + socialEmbed.headingLink = null; + } + + socialEmbed.title = + language.$('albumPage.socialEmbed.title', { + album: data.albumName, + }); + + socialEmbed.description = relations.description; + + if (data.hasImage) { + const imagePath = urls + .from('shared.root') + .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension); + socialEmbed.image = '/' + imagePath; + } + + socialEmbed.color = data.albumColor; + + return socialEmbed; + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js new file mode 100644 index 00000000..40f696f8 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,48 @@ +import {accumulateSum} from '../../util/sugar.js'; + +export default { + extraDependencies: ['language'], + + data(album) { + const data = {}; + + const duration = accumulateSum(album.tracks, track => track.duration); + + data.hasDuration = duration > 0; + data.hasTracks = album.tracks.length > 0; + data.hasDate = !!album.date; + data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration); + + if (!data.hasAny) + return data; + + if (data.hasDuration) + data.duration = duration; + + if (data.hasTracks) + data.tracks = album.tracks.length; + + if (data.hasDate) + data.date = album.date; + + return data; + }, + + generate(data, {language}) { + return language.formatString( + 'albumPage.socialEmbed.body' + [ + data.hasDuration && '.withDuration', + data.hasTracks && '.withTracks', + data.hasDate && '.withReleaseDate', + ].filter(Boolean).join(''), + + Object.fromEntries([ + data.hasDuration && + ['duration', language.formatDuration(data.duration)], + data.hasTracks && + ['tracks', language.countTracks(data.tracks, {unit: true})], + data.hasDate && + ['date', language.formatDate(data.date)], + ].filter(Boolean))); + }, +}; diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js new file mode 100644 index 00000000..6a894d71 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,59 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['to'], + + data(album) { + const data = {}; + + data.hasWallpaper = !empty(album.wallpaperArtistContribs); + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasWallpaper) { + data.hasWallpaperStyle = !!album.wallpaperStyle; + data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; + data.wallpaperStyle = album.wallpaperStyle; + } + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + return data; + }, + + generate(data, {to}) { + const wallpaperPart = + (data.hasWallpaper + ? [ + `body::before {`, + ` background-image: url("${to(...data.wallpaperPath)}");`, + ...(data.hasWallpaperStyle + ? data.wallpaperStyle + .split('\n') + .map(line => ` ${line}`) + : []), + `}`, + ] + : []); + + const bannerPart = + (data.hasBannerStyle + ? [ + `#banner img {`, + ...data.bannerStyle + .split('\n') + .map(line => ` ${line}`), + `}`, + ] + : []); + + return [ + ...wallpaperPart, + ...bannerPart, + ] + .filter(Boolean) + .join('\n'); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 00000000..b222799b --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,137 @@ +import {accumulateSum, empty, stitchArrays} from '../../util/sugar.js'; + +function displayTrackSections(album) { + if (empty(album.trackSections)) { + return false; + } + + if (album.trackSections.length > 1) { + return true; + } + + if (!album.trackSections[0].isDefaultTrackSection) { + return true; + } + + return false; +} + +function displayTracks(album) { + if (empty(album.tracks)) { + return false; + } + + return true; +} + +function getDisplayMode(album) { + if (displayTrackSections(album)) { + return 'trackSections'; + } else if (displayTracks(album)) { + return 'tracks'; + } else { + return 'none'; + } +} + +export default { + contentDependencies: ['generateAlbumTrackListItem', 'generateContentHeading'], + extraDependencies: ['html', 'language'], + + query(album) { + return { + displayMode: getDisplayMode(album), + }; + }, + + relations(relation, query, album) { + const relations = {}; + + switch (query.displayMode) { + case 'trackSections': + relations.trackSectionHeadings = + album.trackSections.map(() => + relation('generateContentHeading')); + + relations.itemsByTrackSection = + album.trackSections.map(section => + section.tracks.map(track => + relation('generateAlbumTrackListItem', track, album))); + + break; + + case 'tracks': + relations.itemsByTrack = + album.tracks.map(track => + relation('generateAlbumTrackListItem', track, album)); + break; + } + + return relations; + }, + + data(query, album) { + const data = {}; + + data.displayMode = query.displayMode; + data.hasTrackNumbers = album.hasTrackNumbers; + + switch (query.displayMode) { + case 'trackSections': + data.trackSectionInfo = + album.trackSections.map(section => { + const info = {}; + + info.name = section.name; + info.duration = accumulateSum(section.tracks, track => track.duration); + info.durationApproximate = section.tracks.length > 1; + + if (album.hasTrackNumbers) { + info.startIndex = section.startIndex; + } + + return info; + }); + break; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const listTag = (data.hasTrackNumbers ? 'ol' : 'ul'); + + switch (data.displayMode) { + case 'trackSections': + return html.tag('dl', {class: 'album-group-list'}, + stitchArrays({ + heading: relations.trackSectionHeadings, + items: relations.itemsByTrackSection, + info: data.trackSectionInfo, + }).map(({heading, items, info}) => [ + heading.slots({ + tag: 'dt', + title: + language.$('trackList.section.withDuration', { + section: info.name, + duration: + language.formatDuration(info.duration, { + approximate: info.durationApproximate, + }), + }), + }), + + html.tag('dd', + html.tag(listTag, + data.hasTrackNumbers ? {start: info.startIndex + 1} : {}, + items)), + ])); + + case 'tracks': + return html.tag(listTag, relations.itemsByTrack); + + default: + return html.blank(); + } + } +}; diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js new file mode 100644 index 00000000..15aecba0 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,72 @@ +import {compareArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkContribution', + 'linkTrack', + ], + + extraDependencies: ['getColors', 'html', 'language'], + + relations(relation, track) { + const relations = {}; + + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + + relations.trackLink = + relation('linkTrack', track); + + return relations; + }, + + data(track, album) { + const data = {}; + + data.duration = track.duration ?? 0; + + if (track.color !== album.color) { + data.color = track.color; + } + + data.showArtists = + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false}); + + return data; + }, + + generate(data, relations, {getColors, html, language}) { + let style; + + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + const parts = ['trackList.item.withDuration']; + const options = {}; + + options.duration = + language.formatDuration(data.duration); + + options.track = + relations.trackLink + .slot('color', false); + + if (data.showArtists) { + parts.push('withArtists'); + options.by = + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: language.formatConjunctionList(relations.contributionLinks), + })); + } + + return html.tag('li', {style}, + language.formatString(parts.join('.'), options)); + }, +}; diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js new file mode 100644 index 00000000..d1ec3efe --- /dev/null +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -0,0 +1,114 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +// TODO: Very awkward we have to duplicate this functionality in relations and data. +function getGalleryThings(artist) { + const galleryThings = [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist]; + sortAlbumsTracksChronologically(galleryThings, {latestFirst: true}); + return galleryThings; +} + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, artist) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + relations.coverGrid = + relation('generateCoverGrid'); + + const galleryThings = getGalleryThings(artist); + + relations.links = + galleryThings.map(thing => + (thing.album + ? relation('linkTrack', thing) + : relation('linkAlbum', thing))); + + relations.images = + galleryThings.map(thing => + relation('image', thing.artTags)); + + return relations; + }, + + data(artist) { + const data = {}; + + data.name = artist.name; + + const galleryThings = getGalleryThings(artist); + + data.numArtworks = galleryThings.length; + + data.names = + galleryThings.map(thing => thing.name); + + data.paths = + galleryThings.map(thing => + (thing.album + ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] + : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('artistGalleryPage.title', { + artist: data.name, + }), + + headingMode: 'static', + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', + {class: 'quick-info'}, + language.$('artistGalleryPage.infoLine', { + coverArts: language.countCoverArts(data.numArtworks, { + unit: true, + }), + })), + + relations.coverGrid + .slots({ + links: relations.links, + names: data.names, + images: + stitchArrays({ + image: relations.images, + path: data.paths, + }).map(({image, path}) => + image.slot('path', path)), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + currentExtra: 'gallery', + }) + .content, + }) + }, +} diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js new file mode 100644 index 00000000..1e7086ed --- /dev/null +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -0,0 +1,213 @@ +import { + empty, + filterProperties, + stitchArrays, + unique, +} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkGroup'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData}) { + return { + groupOrder: groupCategoryData.flatMap(category => category.groups), + } + }, + + query(sprawl, tracksAndAlbums) { + const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album); + const filteredTracks = tracksAndAlbums.filter(thing => thing.album); + + const allAlbums = unique([ + ...filteredAlbums, + ...filteredTracks.map(track => track.album), + ]); + + const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups)); + const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); + + const mapTemplate = allGroupsOrdered.map(group => [group, 0]); + const groupToCountMap = new Map(mapTemplate); + const groupToDurationMap = new Map(mapTemplate); + const groupToDurationCountMap = new Map(mapTemplate); + + for (const album of filteredAlbums) { + for (const group of album.groups) { + groupToCountMap.set(group, groupToCountMap.get(group) + 1); + } + } + + for (const track of filteredTracks) { + for (const group of track.album.groups) { + groupToCountMap.set(group, groupToCountMap.get(group) + 1); + if (track.duration) { + groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration); + groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1); + } + } + } + + const groupsSortedByCount = + allGroupsOrdered + .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a)); + + // The filter here ensures all displayed groups have at least some duration + // when sorting by duration. + const groupsSortedByDuration = + allGroupsOrdered + .filter(group => groupToDurationMap.get(group) > 0) + .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a)); + + const groupCountsSortedByCount = + groupsSortedByCount + .map(group => groupToCountMap.get(group)); + + const groupDurationsSortedByCount = + groupsSortedByCount + .map(group => groupToDurationMap.get(group)); + + const groupDurationsApproximateSortedByCount = + groupsSortedByCount + .map(group => groupToDurationCountMap.get(group) > 1); + + const groupCountsSortedByDuration = + groupsSortedByDuration + .map(group => groupToCountMap.get(group)); + + const groupDurationsSortedByDuration = + groupsSortedByDuration + .map(group => groupToDurationMap.get(group)); + + const groupDurationsApproximateSortedByDuration = + groupsSortedByDuration + .map(group => groupToDurationCountMap.get(group) > 1); + + return { + groupsSortedByCount, + groupsSortedByDuration, + + groupCountsSortedByCount, + groupDurationsSortedByCount, + groupDurationsApproximateSortedByCount, + + groupCountsSortedByDuration, + groupDurationsSortedByDuration, + groupDurationsApproximateSortedByDuration, + }; + }, + + relations(relation, query) { + return { + groupLinksSortedByCount: + query.groupsSortedByCount + .map(group => relation('linkGroup', group)), + + groupLinksSortedByDuration: + query.groupsSortedByDuration + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return filterProperties(query, [ + 'groupCountsSortedByCount', + 'groupDurationsSortedByCount', + 'groupDurationsApproximateSortedByCount', + + 'groupCountsSortedByDuration', + 'groupDurationsSortedByDuration', + 'groupDurationsApproximateSortedByDuration', + ]); + }, + + slots: { + title: {type: 'html'}, + showBothColumns: {type: 'boolean'}, + showSortButton: {type: 'boolean'}, + visible: {type: 'boolean', default: true}, + + sort: {validate: v => v.is('count', 'duration')}, + countUnit: {validate: v => v.is('tracks', 'artworks')}, + }, + + generate(data, relations, slots, {html, language}) { + if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) { + return html.blank(); + } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) { + return html.blank(); + } + + const getCounts = counts => + counts.map(count => { + switch (slots.countUnit) { + case 'tracks': return language.countTracks(count, {unit: true}); + case 'artworks': return language.countArtworks(count, {unit: true}); + } + }); + + // We aren't displaying the "~" approximate symbol here for now. + // The general notion that these sums aren't going to be 100% accurate + // is made clear by the "XYZ has contributed ~1:23:45 hours of music..." + // line that's always displayed above this table. + const getDurations = (durations, approximate) => + stitchArrays({ + duration: durations, + approximate: approximate, + }).map(({duration}) => language.formatDuration(duration)); + + const topLevelClasses = [ + 'group-contributions-sorted-by-' + slots.sort, + slots.visible && 'visible', + ]; + + return html.tags([ + html.tag('dt', {class: topLevelClasses}, + (slots.showSortButton + ? language.$('artistPage.groupContributions.title.withSortButton', { + title: slots.title, + sort: + html.tag('a', {href: '#', class: 'group-contributions-sort-button'}, + (slots.sort === 'count' + ? language.$('artistPage.groupContributions.title.sorting.count') + : language.$('artistPage.groupContributions.title.sorting.duration'))), + }) + : slots.title)), + + html.tag('dd', {class: topLevelClasses}, + html.tag('ul', {class: 'group-contributions-table', role: 'list'}, + (slots.sort === 'count' + ? stitchArrays({ + group: relations.groupLinksSortedByCount, + count: getCounts(data.groupCountsSortedByCount), + duration: getDurations(data.groupDurationsSortedByCount, data.groupDurationsApproximateSortedByCount), + }).map(({group, count, duration}) => + html.tag('li', + html.tag('div', {class: 'group-contributions-row'}, [ + group, + html.tag('span', {class: 'group-contributions-metrics'}, + // When sorting by count, duration details aren't necessarily + // available for all items. + (slots.showBothColumns && duration + ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration}) + : language.$('artistPage.groupContributions.item.countAccent', {count}))), + ]))) + : stitchArrays({ + group: relations.groupLinksSortedByDuration, + count: getCounts(data.groupCountsSortedByDuration), + duration: getDurations(data.groupDurationsSortedByDuration, data.groupDurationsApproximateSortedByCount), + }).map(({group, count, duration}) => + html.tag('li', + html.tag('div', {class: 'group-contributions-row'}, [ + group, + html.tag('span', {class: 'group-contributions-metrics'}, + // Count details are always available, since they're just the + // number of contributions directly. And duration details are + // guaranteed for every item when sorting by duration. + (slots.showBothColumns + ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count}) + : language.$('artistPage.groupContributions.item.durationAccent', {duration}))), + ])))))), + ]); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js new file mode 100644 index 00000000..7f79a609 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,308 @@ +import {empty, unique} from '../../util/sugar.js'; +import {getTotalDuration} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistGroupContributionsInfo', + 'generateArtistInfoPageArtworksChunkedList', + 'generateArtistInfoPageCommentaryChunkedList', + 'generateArtistInfoPageFlashesChunkedList', + 'generateArtistInfoPageTracksChunkedList', + 'generateArtistNavLinks', + 'generateContentHeading', + 'generateCoverArtwork', + 'generatePageLayout', + 'linkAlbum', + 'linkArtistGallery', + 'linkExternal', + 'linkGroup', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, artist) { + return { + // Even if an artist has served as both "artist" (compositional) and + // "contributor" (instruments, production, etc) on the same track, that + // track only counts as one unique contribution. + allTracks: + unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]), + + // Artworks are different, though. We intentionally duplicate album data + // objects when the artist has contributed some combination of cover art, + // wallpaper, and banner - these each count as a unique contribution. + allArtworks: [ + ...artist.albumsAsCoverArtist, + ...artist.albumsAsWallpaperArtist, + ...artist.albumsAsBannerArtist, + ...artist.tracksAsCoverArtist, + ], + + // Banners and wallpapers don't show up in the artist gallery page, only + // cover art. + hasGallery: + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist), + }; + }, + + relations(relation, query, sprawl, artist) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + if (artist.hasAvatar) { + relations.cover = + relation('generateCoverArtwork', []); + } + + if (artist.contextNotes) { + const contextNotes = sections.contextNotes = {}; + contextNotes.content = relation('transformContent', artist.contextNotes); + } + + if (!empty(artist.urls)) { + const visit = sections.visit = {}; + visit.externalLinks = + artist.urls.map(url => + relation('linkExternal', url)); + } + + if (!empty(query.allTracks)) { + const tracks = sections.tracks = {}; + tracks.heading = relation('generateContentHeading'); + tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist); + tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks); + } + + if (!empty(query.allArtworks)) { + const artworks = sections.artworks = {}; + artworks.heading = relation('generateContentHeading'); + artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist); + artworks.groupInfo = + relation('generateArtistGroupContributionsInfo', query.allArtworks); + + if (query.hasGallery) { + artworks.artistGalleryLink = + relation('linkArtistGallery', artist); + } + } + + if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) { + const flashes = sections.flashes = {}; + flashes.heading = relation('generateContentHeading'); + flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist); + } + + if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) { + const commentary = sections.commentary = {}; + commentary.heading = relation('generateContentHeading'); + commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist); + } + + return relations; + }, + + data(query, sprawl, artist) { + const data = {}; + + data.name = artist.name; + data.directory = artist.directory; + + if (artist.hasAvatar) { + data.avatarFileExtension = artist.avatarFileExtension; + } + + data.totalTrackCount = query.allTracks.length; + data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true}); + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + cover: + (relations.cover + ? relations.cover.slots({ + path: [ + 'media.artistAvatar', + data.directory, + data.avatarFileExtension, + ], + }) + : null), + + mainContent: [ + sec.contextNotes && [ + html.tag('p', language.$('releaseInfo.note')), + html.tag('blockquote', + sec.contextNotes.content), + ], + + sec.visit && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(sec.visit.externalLinks), + })), + + sec.artworks?.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) && + html.tag('p', + language.$('misc.jumpTo.withLinks', { + links: language.formatUnitList( + [ + sec.tracks && + html.tag('a', + {href: '#tracks'}, + language.$('artistPage.trackList.title')), + + sec.artworks && + html.tag('a', + {href: '#art'}, + language.$('artistPage.artList.title')), + + sec.flashes && + html.tag('a', + {href: '#flashes'}, + language.$('artistPage.flashList.title')), + + sec.commentary && + html.tag('a', + {href: '#commentary'}, + language.$('artistPage.commentaryList.title')), + ].filter(Boolean)), + })), + + sec.tracks && [ + sec.tracks.heading + .slots({ + tag: 'h2', + id: 'tracks', + title: language.$('artistPage.trackList.title'), + }), + + data.totalDuration > 0 && + html.tag('p', + language.$('artistPage.contributedDurationLine', { + artist: data.name, + duration: + language.formatDuration(data.totalDuration, { + approximate: data.totalTrackCount > 1, + unit: true, + }), + })), + + sec.tracks.list + .slots({ + groupInfo: [ + sec.tracks.groupInfo + .clone() + .slots({ + title: language.$('artistPage.groupContributions.title.music'), + showSortButton: true, + sort: 'count', + countUnit: 'tracks', + visible: true, + }), + + sec.tracks.groupInfo + .clone() + .slots({ + title: language.$('artistPage.groupContributions.title.music'), + showSortButton: true, + sort: 'duration', + countUnit: 'tracks', + visible: false, + }), + ], + }), + ], + + sec.artworks && [ + sec.artworks.heading + .slots({ + tag: 'h2', + id: 'art', + title: language.$('artistPage.artList.title'), + }), + + sec.artworks.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery.orBrowseList', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + sec.artworks.list + .slots({ + groupInfo: + sec.artworks.groupInfo + .slots({ + title: language.$('artistPage.groupContributions.title.artworks'), + showBothColumns: false, + sort: 'count', + countUnit: 'artworks', + }), + }), + ], + + sec.flashes && [ + sec.flashes.heading + .slots({ + tag: 'h2', + id: 'flashes', + title: language.$('artistPage.flashList.title'), + }), + + sec.flashes.list, + ], + + sec.commentary && [ + sec.commentary.heading + .slots({ + tag: 'h2', + id: 'commentary', + title: language.$('artistPage.commentaryList.title'), + }), + + sec.commentary.list, + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + }) + .content, + }); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js new file mode 100644 index 00000000..656121c6 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -0,0 +1,188 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + // TODO: Add and integrate wallpaper and banner date fields (#90) + // This will probably only happen once all artworks follow a standard + // shape (#70) and get their own sorting function. Read for more info: + // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961 + + const entries = [ + ...artist.albumsAsCoverArtist.map(album => ({ + thing: album, + entry: { + type: 'albumCover', + album: album, + date: album.coverArtDate, + contribs: album.coverArtistContribs, + }, + })), + + ...artist.albumsAsWallpaperArtist.map(album => ({ + thing: album, + entry: { + type: 'albumWallpaper', + album: album, + date: album.coverArtDate, + contribs: album.wallpaperArtistContribs, + }, + })), + + ...artist.albumsAsBannerArtist.map(album => ({ + thing: album, + entry: { + type: 'albumBanner', + album: album, + date: album.coverArtDate, + contribs: album.bannerArtistContribs, + }, + })), + + ...artist.tracksAsCoverArtist.map(track => ({ + thing: track, + entry: { + type: 'trackCover', + album: track.album, + date: track.coverArtDate, + track: track, + contribs: track.coverArtistContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, + things => sortAlbumsTracksChronologically(things, { + getDate: thing => thing.coverArtDate, + })); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album', 'date']); + + return {chunks}; + }, + + relations(relation, query, artist) { + return { + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemTrackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track ? relation('linkTrack', track) : null)), + + itemOtherArtistLinks: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))), + }; + }, + + data(query, artist) { + return { + chunkDates: + query.chunks.map(({date}) => date), + + itemTypes: + query.chunks.map(({chunk}) => + chunk.map(({type}) => type)), + + itemContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + }; + }, + + generate(data, relations, {html, language}) { + return relations.chunkedList.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + date: data.chunkDates, + + items: relations.items, + itemTrackLinks: relations.itemTrackLinks, + itemOtherArtistLinks: relations.itemOtherArtistLinks, + itemTypes: data.itemTypes, + itemContributions: data.itemContributions, + }).map(({ + chunk, + albumLink, + date, + + items, + itemTrackLinks, + itemOtherArtistLinks, + itemTypes, + itemContributions, + }) => + chunk.slots({ + mode: 'album', + albumLink, + date, + + items: + stitchArrays({ + item: items, + trackLink: itemTrackLinks, + otherArtistLinks: itemOtherArtistLinks, + type: itemTypes, + contribution: itemContributions, + }).map(({ + item, + trackLink, + otherArtistLinks, + type, + contribution, + }) => + item.slots({ + otherArtistLinks, + contribution, + + content: + (type === 'trackCover' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.' + { + albumWallpaper: 'wallpaperArt', + albumBanner: 'bannerArt', + albumCover: 'coverArt', + }[type]))), + })), + })), + }); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js new file mode 100644 index 00000000..eb9056cb --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -0,0 +1,81 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + mode: { + validate: v => v.is('flash', 'album'), + }, + + albumLink: {type: 'html'}, + flashActLink: {type: 'html'}, + + date: {validate: v => v.isDate}, + dateRangeStart: {validate: v => v.isDate}, + dateRangeEnd: {validate: v => v.isDate}, + + duration: {validate: v => v.isDuration}, + durationApproximate: {type: 'boolean'}, + + items: {type: 'html'}, + }, + + generate(slots, {html, language}) { + let accentedLink; + + accent: { + switch (slots.mode) { + case 'album': { + accentedLink = slots.albumLink; + + const options = {album: accentedLink}; + const parts = ['artistPage.creditList.album']; + + if (slots.date) { + parts.push('withDate'); + options.date = language.formatDate(slots.date); + } + + if (slots.duration) { + parts.push('withDuration'); + options.duration = + language.formatDuration(slots.duration, { + approximate: slots.durationApproximate, + }); + } + + accentedLink = language.formatString(parts.join('.'), options); + break; + } + + case 'flash': { + accentedLink = slots.flashActLink; + + const options = {act: accentedLink}; + const parts = ['artistPage.creditList.flashAct']; + + if ( + slots.dateRangeStart && + slots.dateRangeEnd && + slots.dateRangeStart !== slots.dateRangeEnd + ) { + parts.push('withDateRange'); + options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd); + } else if (slots.dateRangeStart || slots.date) { + parts.push('withDate'); + options.date = language.formatDate(slots.dateFirst); + } + + accentedLink = language.formatString(parts.join('.'), options); + break; + } + } + } + + return html.tags([ + html.tag('dt', accentedLink), + html.tag('dd', + html.tag('ul', + slots.items)), + ]); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js new file mode 100644 index 00000000..9004f18a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -0,0 +1,50 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + content: {type: 'html'}, + + otherArtistLinks: {validate: v => v.arrayOf(v.isHTML)}, + contribution: {type: 'string'}, + rerelease: {type: 'boolean'}, + }, + + generate(slots, {html, language}) { + let accentedContent = slots.content; + + accent: { + if (slots.rerelease) { + accentedContent = + language.$('artistPage.creditList.entry.rerelease', { + entry: accentedContent, + }); + + break accent; + } + + const parts = ['artistPage.creditList.entry']; + const options = {entry: accentedContent}; + + if (slots.otherArtistLinks) { + parts.push('withArtists'); + options.artists = language.formatConjunctionList(slots.otherArtistLinks); + } + + if (slots.contribution) { + parts.push('withContribution'); + options.contribution = slots.contribution; + } + + if (parts.length === 1) { + break accent; + } + + accentedContent = language.formatString(parts.join('.'), options); + } + + return ( + html.tag('li', + {class: slots.rerelease && 'rerelease'}, + accentedContent)); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js new file mode 100644 index 00000000..a0334cbc --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -0,0 +1,16 @@ +export default { + extraDependencies: ['html'], + + slots: { + groupInfo: {type: 'html'}, + chunks: {type: 'html'}, + }, + + generate(slots, {html}) { + return ( + html.tag('dl', [ + slots.groupInfo, + slots.chunks, + ])); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js new file mode 100644 index 00000000..b96d6813 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -0,0 +1,111 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + // TODO: Add and integrate wallpaper and banner date fields (#90) + // This will probably only happen once all artworks follow a standard + // shape (#70) and get their own sorting function. Read for more info: + // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961 + + const entries = [ + ...artist.albumsAsCommentator.map(album => ({ + thing: album, + entry: { + type: 'album', + album, + }, + })), + + ...artist.tracksAsCommentator.map(track => ({ + thing: track, + entry: { + type: 'track', + album: track.album, + track, + }, + })), + ]; + + sortEntryThingPairs(entries, sortAlbumsTracksChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album']); + + return {chunks}; + }, + + relations(relation, query) { + return { + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemTrackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track ? relation('linkTrack', track) : null)), + }; + }, + + data(query) { + return { + itemTypes: + query.chunks.map(({chunk}) => + chunk.map(({type}) => type)), + }; + }, + + generate(data, relations, {html, language}) { + return html.tag('dl', + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + + items: relations.items, + itemTrackLinks: relations.itemTrackLinks, + itemTypes: data.itemTypes, + }).map(({chunk, albumLink, items, itemTrackLinks, itemTypes}) => + chunk.slots({ + mode: 'album', + albumLink, + items: + stitchArrays({ + item: items, + trackLink: itemTrackLinks, + type: itemTypes, + }).map(({item, trackLink, type}) => + item.slots({ + content: + (type === 'album' + ? html.tag('i', + language.$('artistPage.creditList.entry.album.commentary')) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js new file mode 100644 index 00000000..2f64483a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -0,0 +1,134 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortEntryThingPairs, + sortFlashesChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'linkFlash', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const entries = [ + ...artist.flashesAsContributor.map(flash => ({ + thing: flash, + entry: { + flash, + act: flash.act, + contribs: flash.contributorContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, sortFlashesChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['act']); + + return {chunks}; + }, + + relations(relation, query) { + // Flashes and games can list multiple contributors as collaborative + // credits, but we don't display these on the artist page, since they + // usually involve many artists crediting a larger team where collaboration + // isn't as relevant (without more particular details that aren't tracked + // on the wiki). + + return { + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + actLinks: + query.chunks.map(({chunk}) => + relation('linkFlash', chunk[0].flash)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + itemFlashLinks: + query.chunks.map(({chunk}) => + chunk.map(({flash}) => relation('linkFlash', flash))), + }; + }, + + data(query, artist) { + return { + actNames: + query.chunks.map(({act}) => act.name), + + firstDates: + query.chunks.map(({chunk}) => chunk[0].flash.date ?? null), + + lastDates: + query.chunks.map(({chunk}) => chunk[chunk.length - 1].flash.date ?? null), + + itemContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + }; + }, + + generate(data, relations, {html, language}) { + return html.tag('dl', + stitchArrays({ + chunk: relations.chunks, + actLink: relations.actLinks, + actName: data.actNames, + firstDate: data.firstDates, + lastDate: data.lastDates, + + items: relations.items, + itemFlashLinks: relations.itemFlashLinks, + itemContributions: data.itemContributions, + }).map(({ + chunk, + actLink, + actName, + firstDate, + lastDate, + + items, + itemFlashLinks, + itemContributions, + }) => + chunk.slots({ + mode: 'flash', + flashActLink: actLink.slot('content', actName), + dateRangeStart: firstDate, + dateRangeEnd: lastDate, + + items: + stitchArrays({ + item: items, + flashLink: itemFlashLinks, + contribution: itemContributions, + }).map(({ + item, + flashLink, + contribution, + }) => + item.slots({ + contribution, + + content: + language.$('artistPage.creditList.entry.flash', { + flash: flashLink, + }), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js new file mode 100644 index 00000000..7667dea7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -0,0 +1,23 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkArtist'], + + relations(relation, contribs, artist) { + const otherArtistContribs = contribs.filter(({who}) => who !== artist); + + if (empty(otherArtistContribs)) { + return {}; + } + + const otherArtistLinks = + otherArtistContribs + .map(({who}) => relation('linkArtist', who)); + + return {otherArtistLinks}; + }, + + generate(relations) { + return relations.otherArtistLinks ?? null; + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js new file mode 100644 index 00000000..d6ae9ae8 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -0,0 +1,185 @@ +import {accumulateSum, stitchArrays} from '../../util/sugar.js'; + +import { + chunkByProperties, + sortAlbumsTracksChronologically, + sortEntryThingPairs, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['language'], + + query(artist) { + const entries = [ + ...artist.tracksAsArtist.map(track => ({ + thing: track, + entry: { + track, + album: track.album, + date: track.date, + contribs: track.artistContribs, + }, + })), + + ...artist.tracksAsContributor.map(track => ({ + thing: track, + entry: { + track, + date: track.date, + album: track.album, + contribs: track.contributorContribs, + }, + })), + ]; + + sortEntryThingPairs(entries, sortAlbumsTracksChronologically); + + const chunks = + chunkByProperties( + entries.map(({entry}) => entry), + ['album', 'date']); + + return {chunks}; + }, + + relations(relation, query, artist) { + return { + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + query.chunks.map(() => relation('generateArtistInfoPageChunk')), + + albumLinks: + query.chunks.map(({album}) => relation('linkAlbum', album)), + + items: + query.chunks.map(({chunk}) => + chunk.map(() => relation('generateArtistInfoPageChunkItem'))), + + trackLinks: + query.chunks.map(({chunk}) => + chunk.map(({track}) => relation('linkTrack', track))), + + trackOtherArtistLinks: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))), + }; + }, + + data(query, artist) { + return { + chunkDates: + query.chunks.map(({date}) => date), + + chunkDurations: + query.chunks.map(({chunk}) => + accumulateSum( + chunk + .filter(({track}) => track.duration && track.originalReleaseTrack === null) + .map(({track}) => track.duration))), + + chunkDurationsApproximate: + query.chunks.map(({chunk}) => + chunk + .filter(({track}) => track.duration && track.originalReleaseTrack === null) + .length > 1), + + trackDurations: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track.duration)), + + trackContributions: + query.chunks.map(({chunk}) => + chunk.map(({contribs}) => + contribs + .find(({who}) => who === artist) + .what)), + + trackRereleases: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track.originalReleaseTrack !== null)), + }; + }, + + generate(data, relations, {language}) { + return relations.chunkedList.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + albumLink: relations.albumLinks, + date: data.chunkDates, + duration: data.chunkDurations, + durationApproximate: data.chunkDurationsApproximate, + + items: relations.items, + trackLinks: relations.trackLinks, + trackOtherArtistLinks: relations.trackOtherArtistLinks, + trackDurations: data.trackDurations, + trackContributions: data.trackContributions, + trackRereleases: data.trackRereleases, + }).map(({ + chunk, + albumLink, + date, + duration, + durationApproximate, + + items, + trackLinks, + trackOtherArtistLinks, + trackDurations, + trackContributions, + trackRereleases, + }) => + chunk.slots({ + mode: 'album', + albumLink, + date, + duration, + durationApproximate, + + items: + stitchArrays({ + item: items, + trackLink: trackLinks, + otherArtistLinks: trackOtherArtistLinks, + duration: trackDurations, + contribution: trackContributions, + rerelease: trackRereleases, + }).map(({ + item, + trackLink, + otherArtistLinks, + duration, + contribution, + rerelease, + }) => + item.slots({ + otherArtistLinks, + contribution, + rerelease, + + content: + (duration + ? language.$('artistPage.creditList.entry.track.withDuration', { + track: trackLink, + duration: language.formatDuration(duration), + }) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + })), + })), + }); + }, +}; diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js new file mode 100644 index 00000000..f78b45a1 --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,100 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkArtistGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableListings: wikiInfo.enableListings, + }; + }, + + relations(relation, sprawl, artist) { + const relations = {}; + + relations.artistMainLink = + relation('linkArtist', artist); + + relations.artistInfoLink = + relation('linkArtist', artist); + + if ( + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist) + ) { + relations.artistGalleryLink = + relation('linkArtistGallery', artist); + } + + return relations; + }, + + data(sprawl) { + return { + enableListings: sprawl.enableListings, + }; + }, + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {html, language}) { + const infoLink = + relations.artistInfoLink?.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.artistGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }), + ]}; + + const mostAccentLinks = [ + ...extraLinks, + ].filter(Boolean); + + // Don't show the info accent link all on its own. + const allAccentLinks = + (empty(mostAccentLinks) + ? [] + : [infoLink, ...mostAccentLinks]); + + const accent = + (empty(allAccentLinks) + ? html.blank() + : `(${language.formatUnitList(allAccentLinks)})`); + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + accent, + html: + language.$('artistPage.nav.artist', { + artist: relations.artistMainLink, + }), + }, + ]; + }, +}; diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js new file mode 100644 index 00000000..835140a8 --- /dev/null +++ b/src/content/dependencies/generateBanner.js @@ -0,0 +1,28 @@ +export default { + extraDependencies: ['html', 'to'], + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + dimensions: { + validate: v => v.isDimensions, + }, + + alt: { + type: 'string', + }, + }, + + generate(slots, {html, to}) { + return ( + html.tag('div', {id: 'banner'}, + html.tag('img', { + src: to(...slots.path), + alt: slots.alt, + width: slots.dimensions?.[0] ?? 1100, + height: slots.dimensions?.[1] ?? 200, + }))); + }, +}; diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js new file mode 100644 index 00000000..15c0898c --- /dev/null +++ b/src/content/dependencies/generateChronologyLinks.js @@ -0,0 +1,82 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + slots: { + chronologyInfoSets: { + validate: v => + v.arrayOf( + v.validateProperties({ + headingString: v.isString, + contributions: v.arrayOf(v.validateProperties({ + index: v.isCountingNumber, + artistLink: v.isHTML, + previousLink: v.isHTML, + nextLink: v.isHTML, + })), + })), + } + }, + + generate(slots, {html, language}) { + if (empty(slots.chronologyInfoSets)) { + return html.blank(); + } + + const totalContributionCount = + accumulateSum( + slots.chronologyInfoSets, + ({contributions}) => contributions.length); + + if (totalContributionCount === 0) { + return html.blank(); + } + + if (totalContributionCount > 8) { + return html.tag('div', {class: 'chronology'}, + language.$('misc.chronology.seeArtistPages')); + } + + return html.tags( + slots.chronologyInfoSets.map(({ + headingString, + contributions, + }) => + contributions.map(({ + index, + artistLink, + previousLink, + nextLink, + }) => { + const heading = + html.tag('span', {class: 'heading'}, + language.$(headingString, { + index: language.formatIndex(index), + artist: artistLink, + })); + + const navigation = + (previousLink || nextLink) && + html.tag('span', {class: 'buttons'}, + language.formatUnitList([ + previousLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.previous'), + }), + + nextLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean))); + + return html.tag('div', {class: 'chronology'}, + (navigation + ? language.$('misc.chronology.withNavigation', {heading, navigation}) + : heading)); + }))); + }, +}; diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js new file mode 100644 index 00000000..fbc32599 --- /dev/null +++ b/src/content/dependencies/generateColorStyleRules.js @@ -0,0 +1,27 @@ +export default { + contentDependencies: [ + 'generateColorStyleVariables', + ], + + relations(relation, color) { + const relations = {}; + + if (color) { + relations.variables = + relation('generateColorStyleVariables', color); + } + + return relations; + }, + + generate(relations) { + if (!relations.variables) return ''; + + return [ + `:root {`, + // This is pretty hilariously hacky. + ...relations.variables.split(';').map(line => line + ';'), + `}`, + ].join('\n'); + }, +}; diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js new file mode 100644 index 00000000..90346d8d --- /dev/null +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -0,0 +1,33 @@ +export default { + extraDependencies: [ + 'getColors', + ], + + data(color) { + return {color}; + }, + + generate(data, {getColors}) { + if (!data.color) return []; + + const { + primary, + dark, + dim, + dimGhost, + bg, + bgBlack, + shadow, + } = getColors(data.color); + + return [ + `--primary-color: ${primary}`, + `--dark-color: ${dark}`, + `--dim-color: ${dim}`, + `--dim-ghost-color: ${dimGhost}`, + `--bg-color: ${bg}`, + `--bg-black-color: ${bgBlack}`, + `--shadow-color: ${shadow}`, + ].join('; '); + }, +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 00000000..ccaf1076 --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,19 @@ +export default { + extraDependencies: ['html'], + + slots: { + title: {type: 'html'}, + id: {type: 'string'}, + tag: {type: 'string', default: 'p'}, + }, + + generate(slots, {html}) { + return html.tag(slots.tag, + { + class: 'content-heading', + id: slots.id, + tabindex: '0', + }, + slots.title); + } +} diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js new file mode 100644 index 00000000..503bd120 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['image', 'linkArtTag'], + extraDependencies: ['html', 'language'], + + relations(relation, artTags) { + const relations = {}; + + relations.image = + relation('image', artTags); + + if (artTags) { + relations.tagLinks = + artTags + .filter(tag => !tag.isContentWarning) + .map(tag => relation('linkArtTag', tag)); + } else { + relations.tagLinks = null; + } + + return relations; + }, + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + + mode: { + validate: v => v.is('primary', 'thumbnail'), + default: 'primary', + }, + }, + + generate(relations, slots, {html, language}) { + switch (slots.mode) { + case 'primary': + return html.tag('div', {id: 'cover-art-container'}, [ + relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'medium', + id: 'cover-art', + reveal: true, + link: true, + square: true, + }), + + !empty(relations.tagLinks) && + html.tag('p', + language.$('releaseInfo.artTags.inline', { + tags: language.formatUnitList(relations.tagLinks), + })), + ]); + + case 'thumbnail': + return relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'small', + reveal: false, + link: false, + square: true, + }); + + default: + return html.blank(); + } + }, +}; diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js new file mode 100644 index 00000000..2a2503ac --- /dev/null +++ b/src/content/dependencies/generateCoverCarousel.js @@ -0,0 +1,54 @@ +import {empty, repeat, stitchArrays} from '../../util/sugar.js'; +import {getCarouselLayoutForNumberOfItems} from '../../util/wiki-data.js'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.arrayOf(v.isHTML)}, + links: {validate: v => v.arrayOf(v.isHTML)}, + + lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + const stitched = + stitchArrays({ + image: slots.images, + link: slots.links, + }); + + if (empty(stitched)) { + return; + } + + const layout = getCarouselLayoutForNumberOfItems(stitched.length); + + return html.tag('div', + { + class: 'carousel-container', + 'data-carousel-rows': layout.rows, + 'data-carousel-columns': layout.columns, + }, + repeat(3, [ + html.tag('div', + {class: 'carousel-grid', 'aria-hidden': 'true'}, + stitched.map(({image, link}, index) => + html.tag('div', {class: 'carousel-item'}, + link.slots({ + attributes: {tabindex: '-1'}, + content: + image.slots({ + thumb: 'small', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + })))), + ])); + }, +}; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js new file mode 100644 index 00000000..20130c5e --- /dev/null +++ b/src/content/dependencies/generateCoverGrid.js @@ -0,0 +1,42 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.arrayOf(v.isHTML)}, + links: {validate: v => v.arrayOf(v.isHTML)}, + names: {validate: v => v.arrayOf(v.isHTML)}, + info: {validate: v => v.arrayOf(v.isHTML)}, + + lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + return ( + html.tag('div', {class: 'grid-listing'}, + stitchArrays({ + image: slots.images, + link: slots.links, + name: slots.names, + info: slots.info, + }).map(({image, link, name, info}, index) => + link.slots({ + attributes: {class: ['grid-item', 'box']}, + content: [ + image.slots({ + thumb: 'medium', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + html.tag('span', {[html.onlyIfContent]: true}, name), + html.tag('span', {[html.onlyIfContent]: true}, info), + ], + })))); + }, +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 00000000..b4970b17 --- /dev/null +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -0,0 +1,44 @@ +export default { + extraDependencies: [ + 'defaultLanguage', + 'html', + 'language', + 'languages', + 'pagePath', + 'to', + ], + + generate({ + defaultLanguage, + html, + language, + languages, + pagePath, + to, + }) { + const links = Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language) + .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) + .map((language) => + html.tag('span', + html.tag('a', + { + href: + language === defaultLanguage + ? to( + 'localizedDefaultLanguage.' + pagePath[0], + ...pagePath.slice(1)) + : to( + 'localizedWithBaseDirectory.' + pagePath[0], + language.code, + ...pagePath.slice(1)), + }, + language.name))); + + return html.tag('div', {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', { + languages: links.join('\n'), + })); + }, +}; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js new file mode 100644 index 00000000..7b655805 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -0,0 +1,216 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +import { + filterItemsForCarousel, + getTotalDuration, + sortChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateCoverCarousel', + 'generateCoverGrid', + 'generateGroupNavLinks', + 'generateGroupSidebar', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkListing', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({listingSpec, wikiInfo}) { + const sprawl = {}; + sprawl.enableGroupUI = wikiInfo.enableGroupUI; + + if (wikiInfo.enableListings && wikiInfo.enableGroupUI) { + sprawl.groupsByCategoryListing = + listingSpec + .find(l => l.directory === 'groups/by-category'); + } + + return sprawl; + }, + + relations(relation, sprawl, group) { + const relations = {}; + + const albums = + sortChronologically(group.albums.slice(), {latestFirst: true}); + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateGroupNavLinks', group); + + if (sprawl.enableGroupUI) { + relations.sidebar = + relation('generateGroupSidebar', group); + } + + relations.colorStyleRules = + relation('generateColorStyleRules', group.color); + + if (sprawl.groupsByCategoryListing) { + relations.groupListingLink = + relation('linkListing', sprawl.groupsByCategoryListing); + } + + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(carouselAlbums)) { + relations.coverCarousel = + relation('generateCoverCarousel'); + + relations.carouselLinks = + carouselAlbums + .map(album => relation('linkAlbum', album)); + + relations.carouselImages = + carouselAlbums + .map(album => relation('image', album.artTags)); + } + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.gridLinks = + albums + .map(album => relation('linkAlbum', album)); + + relations.gridImages = + albums.map(album => + (album.hasCoverArt + ? relation('image', album.artTags) + : relation('image'))); + + return relations; + }, + + data(sprawl, group) { + const data = {}; + + data.name = group.name; + + const albums = sortChronologically(group.albums.slice(), {latestFirst: true}); + const tracks = albums.flatMap((album) => album.tracks); + + data.numAlbums = albums.length; + data.numTracks = tracks.length; + data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); + + data.gridNames = albums.map(album => album.name); + data.gridDurations = albums.map(album => getTotalDuration(album.tracks)); + data.gridNumTracks = albums.map(album => album.tracks.length); + + data.gridPaths = + albums.map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(group.featuredAlbums)) { + data.carouselPaths = + carouselAlbums.map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + } + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: language.$('groupGalleryPage.title', {group: data.name}), + headingMode: 'static', + + colorStyleRules: [relations.colorStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.coverCarousel + ?.slots({ + links: relations.carouselLinks, + images: + stitchArrays({ + image: relations.carouselImages, + path: data.carouselPaths, + }).map(({image, path}) => + image.slot('path', path)), + }), + + html.tag('p', + {class: 'quick-info'}, + language.$('groupGalleryPage.infoLine', { + tracks: html.tag('b', + language.countTracks(data.numTracks, { + unit: true, + })), + albums: html.tag('b', + language.countAlbums(data.numAlbums, { + unit: true, + })), + time: html.tag('b', + language.formatDuration(data.totalDuration, { + unit: true, + })), + })), + + relations.groupListingLink && + html.tag('p', + {class: 'quick-info'}, + language.$('groupGalleryPage.anotherGroupLine', { + link: + relations.groupListingLink + .slot('content', language.$('groupGalleryPage.anotherGroupLine.link')), + })), + + relations.coverGrid + .slots({ + links: relations.gridLinks, + names: data.gridNames, + images: + stitchArrays({ + image: relations.gridImages, + path: data.gridPaths, + name: data.gridNames, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })), + info: + stitchArrays({ + numTracks: data.gridNumTracks, + duration: data.gridDurations, + }).map(({numTracks, duration}) => + language.$('misc.albumGrid.details', { + tracks: language.countTracks(numTracks, {unit: true}), + time: language.formatDuration(duration), + })), + }), + ], + + ... + relations.sidebar + ?.slot('currentExtra', 'gallery') + ?.content, + + navLinkStyle: 'hierarchical', + navLinks: + relations.navLinks + .slot('currentExtra', 'gallery') + .content, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js new file mode 100644 index 00000000..3cffb748 --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -0,0 +1,170 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateContentHeading', + 'generateGroupNavLinks', + 'generateGroupSidebar', + 'generatePageLayout', + 'linkAlbum', + 'linkExternal', + 'linkGroupGallery', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableGroupUI: wikiInfo.enableGroupUI, + }; + }, + + relations(relation, sprawl, group) { + const relations = {}; + const sec = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateGroupNavLinks', group); + + if (sprawl.enableGroupUI) { + relations.sidebar = + relation('generateGroupSidebar', group); + } + + relations.colorStyleRules = + relation('generateColorStyleRules', group.color); + + sec.info = {}; + + if (!empty(group.urls)) { + sec.info.visitLinks = + group.urls + .map(url => relation('linkExternal', url)); + } + + if (group.description) { + sec.info.description = + relation('transformContent', group.description); + } + + if (!empty(group.albums)) { + sec.albums = {}; + + sec.albums.heading = + relation('generateContentHeading'); + + sec.albums.galleryLink = + relation('linkGroupGallery', group); + + sec.albums.entries = + group.albums.map(album => { + const links = {}; + links.albumLink = relation('linkAlbum', album); + + const otherGroup = album.groups.find(g => g !== group); + if (otherGroup) { + links.groupLink = relation('linkGroup', otherGroup); + } + + return links; + }); + } + + return relations; + }, + + data(sprawl, group) { + const data = {}; + + data.name = group.name; + + if (!empty(group.albums)) { + data.albumYears = + group.albums + .map(album => album.date?.getFullYear()); + } + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('groupInfoPage.title', {group: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + + mainContent: [ + sec.info.visitLinks && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(sec.info.visitLinks), + })), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + sec.info.description + ?.slot('mode', 'multiline')), + + sec.albums && [ + sec.albums.heading + .slots({ + tag: 'h2', + title: language.$('groupInfoPage.albumList.title'), + }), + + html.tag('p', + language.$('groupInfoPage.viewAlbumGallery', { + link: + sec.albums.galleryLink + .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')), + })), + + html.tag('ul', + sec.albums.entries.map(({albumLink, groupLink}, index) => { + // All these strings are really jank, and should probably + // be implemented with the same 'const parts = [], opts = {}' + // form used elsewhere... + const year = data.albumYears[index]; + const item = + (year + ? language.$('groupInfoPage.albumList.item', { + year, + album: albumLink, + }) + : language.$('groupInfoPage.albumList.item.withoutYear', { + album: albumLink, + })); + + return html.tag('li', + (groupLink + ? language.$('groupInfoPage.albumList.item.withAccent', { + item, + accent: + html.tag('span', {class: 'other-group-accent'}, + language.$('groupInfoPage.albumList.item.otherGroupAccent', { + group: + groupLink.slot('color', false), + })), + }) + : item)); + })), + ], + ], + + ...relations.sidebar?.content ?? {}, + + navLinkStyle: 'hierarchical', + navLinks: relations.navLinks.content, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js new file mode 100644 index 00000000..0b525363 --- /dev/null +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -0,0 +1,142 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkGroup', + 'linkGroupGallery', + 'linkGroupExtra', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData, wikiInfo}) { + return { + groupCategoryData, + enableGroupUI: wikiInfo.enableGroupUI, + enableListings: wikiInfo.enableListings, + }; + }, + + relations(relation, sprawl, group) { + if (!sprawl.enableGroupUI) { + return {}; + } + + const relations = {}; + + relations.mainLink = + relation('linkGroup', group); + + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + const groups = sprawl.groupCategoryData + .flatMap(category => category.groups); + + const index = groups.indexOf(group); + + if (index > 0) { + relations.previousLink = + relation('linkGroupExtra', groups[index - 1]); + } + + if (index < groups.length - 1) { + relations.nextLink = + relation('linkGroupExtra', groups[index + 1]); + } + + relations.infoLink = + relation('linkGroup', group); + + if (!empty(group.albums)) { + relations.galleryLink = + relation('linkGroupGallery', group); + } + + return relations; + }, + + data(sprawl) { + return { + enableGroupUI: sprawl.enableGroupUI, + enableListings: sprawl.enableListings, + }; + }, + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {language}) { + if (!data.enableGroupUI) { + return [ + {auto: 'home'}, + {auto: 'current'}, + ]; + } + + const previousNextLinks = + (relations.previousLink || relations.nextLink) && + relations.previousNextLinks.slots({ + previousLink: + relations.previousLink + ?.slot('extra', slots.currentExtra) + ?.content + ?? null, + nextLink: + relations.nextLink + ?.slot('extra', slots.currentExtra) + ?.content + ?? null, + }); + + const previousNextPart = + previousNextLinks && + language.formatUnitList( + previousNextLinks.content.filter(Boolean)); + + const infoLink = + relations.infoLink.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const extraLinks = [ + relations.galleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }), + ]; + + const extrasPart = + (empty(extraLinks) + ? '' + : language.formatUnitList([infoLink, ...extraLinks])); + + const accent = + `(${[extrasPart, previousNextPart].filter(Boolean).join('; ')})`; + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + accent, + html: + language.$('groupPage.nav.group', { + group: relations.mainLink, + }), + }, + ].filter(Boolean); + }, +}; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js new file mode 100644 index 00000000..6baf37f4 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebar.js @@ -0,0 +1,35 @@ +export default { + contentDependencies: ['generateGroupSidebarCategoryDetails'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({groupCategoryData}) { + return {groupCategoryData}; + }, + + relations(relation, sprawl, group) { + return { + categoryDetails: + sprawl.groupCategoryData.map(category => + relation('generateGroupSidebarCategoryDetails', category, group)), + }; + }, + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(relations, slots, {html, language}) { + return { + leftSidebarContent: [ + html.tag('h1', + language.$('groupSidebar.title')), + + relations.categoryDetails + .map(details => + details.slot('currentExtra', slots.currentExtra)), + ], + }; + }, +}; diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js new file mode 100644 index 00000000..ec707e39 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -0,0 +1,77 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleVariables', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, category) { + return { + colorVariables: relation('generateColorStyleVariables', category.color), + + // Which of these is used depends on the currentExtra slot, so all + // available links are included here. + groupLinks: category.groups.map(group => { + const links = {}; + links.info = relation('linkGroup', group); + + if (!empty(group.albums)) { + links.gallery = relation('linkGroupGallery', group); + } + + return links; + }), + }; + }, + + data(category, group) { + const data = {}; + + data.name = category.name; + data.isCurrentCategory = category === group.category; + + if (data.isCurrentCategory) { + data.currentGroupIndex = category.groups.indexOf(group); + } + + return data; + }, + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {html, language}) { + return html.tag('details', + { + open: data.isCurrentCategory, + class: data.isCurrentCategory && 'current', + }, + [ + html.tag('summary', + {style: relations.colorVariables}, + html.tag('span', + language.$('groupSidebar.groupList.category', { + category: + html.tag('span', {class: 'group-name'}, + data.name), + }))), + + html.tag('ul', + relations.groupLinks.map((links, index) => + html.tag('li', + {class: index === data.currentGroupIndex && 'current'}, + language.$('groupSidebar.groupList.item', { + group: + links[slots.currentExtra ?? 'info'] ?? + links.info, + })))), + ]); + }, +}; diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js new file mode 100644 index 00000000..e4a2f5c7 --- /dev/null +++ b/src/content/dependencies/generateListingIndexList.js @@ -0,0 +1,130 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: ['generateColorStyleVariables', 'linkListing'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({listingTargetSpec, wikiInfo}) { + return {listingTargetSpec, wikiInfo}; + }, + + query(sprawl) { + const query = {}; + + const targetListings = + sprawl.listingTargetSpec + .map(({listings}) => + listings + .filter(listing => + !listing.featureFlag || + sprawl.wikiInfo[listing.featureFlag])); + + query.wikiColor = sprawl.wikiInfo.color; + + query.targets = + sprawl.listingTargetSpec + .filter((target, index) => !empty(targetListings[index])); + + query.targetListings = + targetListings + .filter(listings => !empty(listings)) + + return query; + }, + + relations(relation, query) { + return { + wikiColorVariables: relation('generateColorStyleVariables', query.wikiColor), + + listingLinks: + query.targetListings + .map(listings => + listings.map(listing => relation('linkListing', listing))), + }; + }, + + data(query, sprawl, currentListing) { + const data = {}; + + data.targetStringsKeys = + query.targets + .map(({stringsKey}) => stringsKey); + + data.listingStringsKeys = + query.targetListings + .map(listings => + listings.map(({stringsKey}) => stringsKey)); + + if (currentListing) { + data.currentTargetIndex = + query.targets + .indexOf(currentListing.target); + + data.currentListingIndex = + query.targetListings + .find(listings => listings.includes(currentListing)) + .indexOf(currentListing); + } + + return data; + }, + + slots: { + mode: {validate: v => v.is('content', 'sidebar')}, + }, + + generate(data, relations, slots, {html, language}) { + const listingLinkLists = + stitchArrays({ + listingLinks: relations.listingLinks, + listingStringsKeys: data.listingStringsKeys, + }).map(({listingLinks, listingStringsKeys}, targetIndex) => + html.tag('ul', + stitchArrays({ + listingLink: listingLinks, + listingStringsKey: listingStringsKeys, + }).map(({listingLink, listingStringsKey}, listingIndex) => + html.tag('li', + {class: + targetIndex === data.currentTargetIndex && + listingIndex === data.currentListingIndex && + 'current'}, + listingLink + .slot('content', language.$(`listingPage.${listingStringsKey}.title.short`)))))); + + const targetTitles = + data.targetStringsKeys + .map(stringsKey => language.$(`listingPage.target.${stringsKey}`)); + + switch (slots.mode) { + case 'sidebar': + return html.tags( + stitchArrays({ + targetTitle: targetTitles, + listingLinkList: listingLinkLists, + }).map(({targetTitle, listingLinkList}, targetIndex) => + html.tag('details', + { + open: targetIndex === data.currentTargetIndex, + class: targetIndex === data.currentTargetIndex && 'current', + }, + [ + html.tag('summary', {style: relations.wikiColorVariables}, + html.tag('span', {class: 'group-name'}, targetTitle)), + + listingLinkList, + ]))); + + case 'content': + return ( + html.tag('dl', + stitchArrays({ + targetTitle: targetTitles, + listingLinkList: listingLinkLists, + }).map(({targetTitle, listingLinkList}) => [ + html.tag('dt', {class: ['content-heading']}, targetTitle), + html.tag('dd', listingLinkList), + ]))); + } + }, +}; diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js new file mode 100644 index 00000000..cab80a7f --- /dev/null +++ b/src/content/dependencies/generateListingPage.js @@ -0,0 +1,142 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateListingSidebar', + 'generatePageLayout', + 'linkListing', + 'linkListingIndex', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + relations(relation, listing) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generateListingSidebar', listing); + + relations.listingsIndexLink = + relation('linkListingIndex'); + + relations.chunkHeading = + relation('generateContentHeading'); + + if (listing.target.listings.length > 1) { + relations.sameTargetListingLinks = + listing.target.listings + .map(listing => relation('linkListing', listing)); + } + + if (!empty(listing.seeAlso)) { + relations.seeAlsoLinks = + listing.seeAlso + .map(listing => relation('linkListing', listing)); + } + + return relations; + }, + + data(listing) { + return { + stringsKey: listing.stringsKey, + + targetStringsKey: listing.target.stringsKey, + + sameTargetListingStringsKeys: + listing.target.listings + .map(listing => listing.stringsKey), + + sameTargetListingsCurrentIndex: + listing.target.listings + .indexOf(listing), + }; + }, + + slots: { + type: {validate: v => v.is('rows', 'chunks', 'custom')}, + + rows: {validate: v => v.arrayOf(v.isObject)}, + + chunkTitles: {validate: v => v.arrayOf(v.isObject)}, + chunkRows: {validate: v => v.arrayOf(v.isObject)}, + + content: {type: 'html'}, + }, + + generate(data, relations, slots, {html, language}) { + return relations.layout.slots({ + title: language.$(`listingPage.${data.stringsKey}.title`), + headingMode: 'sticky', + + mainContent: [ + relations.sameTargetListingLinks && + html.tag('p', + language.$('listingPage.listingsFor', { + target: language.$(`listingPage.target.${data.targetStringsKey}`), + listings: + language.formatUnitList( + stitchArrays({ + link: relations.sameTargetListingLinks, + stringsKey: data.sameTargetListingStringsKeys, + }).map(({link, stringsKey}, index) => + html.tag('span', + {class: index === data.sameTargetListingsCurrentIndex && 'current'}, + link.slots({ + attributes: {class: 'nowrap'}, + content: language.$(`listingPage.${stringsKey}.title.short`), + })))), + })), + + relations.seeAlsoLinks && + html.tag('p', + language.$('listingPage.seeAlso', { + listings: language.formatUnitList(relations.seeAlsoLinks), + })), + + slots.type === 'rows' && + html.tag('ul', + slots.rows.map(row => + html.tag('li', + language.$(`listingPage.${data.stringsKey}.item`, row)))), + + slots.type === 'chunks' && + html.tag('dl', + stitchArrays({ + title: slots.chunkTitles, + rows: slots.chunkRows, + }).map(({title, rows}) => [ + relations.chunkHeading + .clone() + .slots({ + tag: 'dt', + title: + language.$(`listingPage.${data.stringsKey}.chunk.title`, title), + }), + + html.tag('dd', + html.tag('ul', + rows.map(row => + html.tag('li', + language.$(`listingPage.${data.stringsKey}.chunk.item`, row))))), + ])), + + slots.type === 'custom' && + slots.content, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.listingsIndexLink}, + {auto: 'current'}, + ], + + ...relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js new file mode 100644 index 00000000..fe2a08fa --- /dev/null +++ b/src/content/dependencies/generateListingSidebar.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['generateListingIndexList', 'linkListingIndex'], + extraDependencies: ['html'], + + relations(relation, currentListing) { + return { + listingIndexLink: relation('linkListingIndex'), + listingIndexList: relation('generateListingIndexList', currentListing), + }; + }, + + generate(relations, {html}) { + return { + leftSidebarContent: [ + html.tag('h1', relations.listingIndexLink), + relations.listingIndexList.slot('mode', 'sidebar'), + ], + }; + }, +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 00000000..794b430b --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,546 @@ +import {empty, openAggregate} from '../../util/sugar.js'; + +function sidebarSlots(side) { + return { + // Content is a flat HTML array. It'll generate one sidebar section + // if specified. + [side + 'Content']: {type: 'html'}, + + // Multiple is an array of {content: (HTML)} objects. Each of these + // will generate one sidebar section. + [side + 'Multiple']: { + validate: v => + v.arrayOf( + v.validateProperties({ + content: v.isHTML, + })), + }, + + // Sticky mode controls which sidebar section(s), if any, follow the + // scroll position, "sticking" to the top of the browser viewport. + // + // 'last' - last or only sidebar box is sticky + // 'column' - entire column, incl. multiple boxes from top, is sticky + // 'none' - sidebar not sticky at all, stays at top of page + // + // Note: This doesn't affect the content of any sidebar section, only + // the whole section's containing box (or the sidebar column as a whole). + [side + 'StickyMode']: { + validate: v => v.is('last', 'column', 'static'), + }, + + // Collapsing sidebars disappear when the viewport is sufficiently + // thin. (This is the default.) Override as false to make the sidebar + // stay visible in thinner viewports, where the page layout will be + // reflowed so the sidebar is as wide as the screen and appears below + // nav, above the main content. + [side + 'Collapse']: {type: 'boolean', default: true}, + + // Wide sidebars generally take up more horizontal space in the normal + // page layout, and should be used if the content of the sidebar has + // a greater than typical focus compared to main content. + [side + 'Wide']: {type: 'boolean', defualt: false}, + }; +} + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'cachebust', + 'html', + 'language', + 'to', + 'wikiData', + ], + + sprawl({wikiInfo}) { + return { + footerContent: wikiInfo.footerContent, + wikiColor: wikiInfo.color, + wikiName: wikiInfo.nameShort, + }; + }, + + data({wikiName}) { + return { + wikiName, + }; + }, + + relations(relation, sprawl) { + const relations = {}; + + relations.footerLocalizationLinks = + relation('generateFooterLocalizationLinks'); + + relations.stickyHeadingContainer = + relation('generateStickyHeadingContainer'); + + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + + relations.defaultColorStyleRules = + relation('generateColorStyleRules', sprawl.wikiColor); + + return relations; + }, + + slots: { + title: {type: 'html'}, + showWikiNameInTitle: {type: 'boolean', default: true}, + + cover: {type: 'html'}, + + socialEmbed: {type: 'html'}, + + colorStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + additionalStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + mainClasses: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + // Main + + mainContent: {type: 'html'}, + + headingMode: { + validate: v => v.is('sticky', 'static'), + default: 'static', + }, + + // Sidebars + + ...sidebarSlots('leftSidebar'), + ...sidebarSlots('rightSidebar'), + + // Banner + + banner: {type: 'html'}, + bannerPosition: { + validate: v => v.is('top', 'bottom'), + default: 'top', + }, + + // Nav & Footer + + navContent: {type: 'html'}, + navBottomRowContent: {type: 'html'}, + + navLinkStyle: { + validate: v => v.is('hierarchical', 'index'), + default: 'index', + }, + + navLinks: { + validate: v => + v.arrayOf(object => { + v.isObject(object); + + const aggregate = openAggregate({message: `Errors validating navigation link`}); + + aggregate.call(v.validateProperties({ + auto: () => true, + html: () => true, + + path: () => true, + title: () => true, + accent: () => true, + }), object); + + if (object.auto || object.html) { + if (object.auto && object.html) { + aggregate.push(new TypeError(`Don't specify both auto and html`)); + } else if (object.auto) { + aggregate.call(v.is('home', 'current'), object.auto); + } else { + aggregate.call(v.isHTML, object.html); + } + + if (object.path || object.title) { + aggregate.push(new TypeError(`Don't specify path or title along with auto or html`)); + } + } else { + aggregate.call(v.validateProperties({ + path: v.arrayOf(v.isString), + title: v.isString, + }), { + path: object.path, + title: object.title, + }); + } + + aggregate.close(); + + return true; + }) + }, + + secondaryNav: {type: 'html'}, + + footerContent: {type: 'html'}, + }, + + generate(data, relations, slots, { + cachebust, + html, + language, + to, + }) { + let titleHTML = null; + + if (!html.isBlank(slots.title)) { + switch (slots.headingMode) { + case 'sticky': + titleHTML = + relations.stickyHeadingContainer.slots({ + title: slots.title, + cover: slots.cover, + }); + break; + case 'static': + titleHTML = html.tag('h1', slots.title); + break; + } + } + + let footerContent = slots.footerContent; + + if (html.isBlank(footerContent)) { + footerContent = relations.defaultFooterContent + .slot('mode', 'multiline'); + } + + const mainHTML = + html.tag('main', { + id: 'content', + class: slots.mainClasses, + }, [ + titleHTML, + + slots.cover, + + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'main-content-container', + }, + slots.mainContent), + ]); + + const footerHTML = + html.tag('footer', + {[html.onlyIfContent]: true, id: 'footer'}, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'footer-content', + }, + footerContent), + + relations.footerLocalizationLinks, + ]); + + const navHTML = html.tag('nav', + { + [html.onlyIfContent]: true, + id: 'header', + class: [ + !empty(slots.navLinks) && 'nav-has-main-links', + !html.isBlank(slots.navContent) && 'nav-has-content', + !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row', + ], + }, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: [ + 'nav-main-links', + 'nav-links-' + slots.navLinkStyle, + ], + }, + slots.navLinks?.map((cur, i) => { + let content; + + if (cur.html) { + content = cur.html; + } else { + let title; + let href; + + switch (cur.auto) { + case 'home': + title = data.wikiName; + href = to('localized.home'); + break; + case 'current': + title = slots.title; + href = ''; + break; + case null: + case undefined: + title = cur.title; + href = to(...cur.path); + break; + } + + content = html.tag('a', + {href}, + title); + } + + let className; + + if (cur.auto === 'current') { + className = 'current'; + } else if ( + slots.navLinkStyle === 'hierarchical' && + i === slots.navLinks.length - 1 + ) { + className = 'current'; + } + + return html.tag('span', + {class: className}, + [ + html.tag('span', + {class: 'nav-link-content'}, + content), + html.tag('span', + {[html.onlyIfContent]: true, class: 'nav-link-accent'}, + cur.accent), + ]); + })), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-bottom-row'}, + slots.navBottomRowContent), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-content'}, + slots.navContent), + ]) + + const generateSidebarHTML = (side, id) => { + const content = slots[side + 'Content']; + const multiple = slots[side + 'Multiple']; + const stickyMode = slots[side + 'StickyMode']; + const wide = slots[side + 'Wide']; + const collapse = slots[side + 'Collapse']; + + let sidebarClasses = []; + let sidebarContent = html.blank(); + + if (!html.isBlank(content)) { + sidebarClasses = ['sidebar']; + sidebarContent = content; + } else if (multiple) { + sidebarClasses = ['sidebar-multiple']; + sidebarContent = + multiple + .filter(Boolean) + .map(({content}) => + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'sidebar', + }, + content)); + } + + return html.tag('div', + { + [html.onlyIfContent]: true, + id, + class: [ + 'sidebar-column', + wide && 'wide', + !collapse && 'no-hide', + stickyMode !== 'static' && `sticky-${stickyMode}`, + ...sidebarClasses, + ], + }, + sidebarContent); + } + + const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left'); + const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right'); + const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse; + + const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'}, + html.tag('div', {id: 'image-overlay-content-container'}, [ + html.tag('a', {id: 'image-overlay-image-container'}, [ + html.tag('img', {id: 'image-overlay-image'}), + html.tag('img', {id: 'image-overlay-image-thumb'}), + ]), + html.tag('div', {id: 'image-overlay-action-container'}, [ + html.tag('div', {id: 'image-overlay-action-content-without-size'}, + language.$('releaseInfo.viewOriginalFile', { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$('releaseInfo.viewOriginalFile.link')), + })), + + html.tag('div', {id: 'image-overlay-action-content-with-size'}, [ + language.$('releaseInfo.viewOriginalFile.withSize', { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$('releaseInfo.viewOriginalFile.link')), + size: html.tag('span', + {[html.joinChildren]: ''}, + [ + html.tag('span', {id: 'image-overlay-file-size-kilobytes'}, + language.$('count.fileSize.kilobytes', { + kilobytes: html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + html.tag('span', {id: 'image-overlay-file-size-megabytes'}, + language.$('count.fileSize.megabytes', { + megabytes: html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + ]), + }), + + html.tag('span', {id: 'image-overlay-file-size-warning'}, + language.$('releaseInfo.viewOriginalFile.sizeWarning')), + ]), + ]), + ])); + + const layoutHTML = [ + navHTML, + slots.bannerPosition === 'top' && slots.banner, + slots.secondaryNav, + html.tag('div', + { + class: [ + 'layout-columns', + !collapseSidebars && 'vertical-when-thin', + (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', + (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', + !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', + sidebarLeftHTML && 'has-sidebar-left', + sidebarRightHTML && 'has-sidebar-right', + ], + }, + [ + sidebarLeftHTML, + mainHTML, + sidebarRightHTML, + ]), + slots.bannerPosition === 'bottom' && slots.banner, + footerHTML, + ].filter(Boolean).join('\n'); + + return html.tags([ + `<!DOCTYPE html>`, + html.tag('html', + { + lang: language.intlCode, + 'data-language-code': language.code, + + /* + 'data-url-key': 'localized.' + pagePath[0], + ...Object.fromEntries( + pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])), + */ + + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.root'), + }, + [ + // developersComment, + + html.tag('head', [ + html.tag('title', + (slots.showWikiNameInTitle + ? language.formatString('misc.pageTitle.withWikiName', { + title: slots.title, + wikiName: data.wikiName, + }) + : language.formatString('misc.pageTitle', { + title: slots.title, + }))), + + html.tag('meta', {charset: 'utf-8'}), + html.tag('meta', { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }), + + /* + ...( + Object.entries(meta) + .filter(([key, value]) => value) + .map(([key, value]) => html.tag('meta', {[key]: value}))), + + canonical && + html.tag('link', { + rel: 'canonical', + href: canonical, + }), + + ...( + localizedCanonical + .map(({lang, href}) => html.tag('link', { + rel: 'alternate', + hreflang: lang, + href, + }))), + + */ + + // slots.socialEmbed, + + html.tag('link', { + rel: 'stylesheet', + href: to('shared.staticFile', 'site4.css', cachebust), + }), + + html.tag('style', [ + (empty(slots.colorStyleRules) + ? relations.defaultColorStyleRules + : slots.colorStyleRules), + slots.additionalStyleRules, + ]), + + html.tag('script', { + src: to('shared.staticFile', 'lazy-loading.js', cachebust), + }), + ]), + + html.tag('body', + // {style: body.style || ''}, + [ + html.tag('div', {id: 'page-container'}, [ + // mainHTML && skippersHTML, + layoutHTML, + ]), + + // infoCardHTML, + imageOverlayHTML, + + html.tag('script', { + type: 'module', + src: to('shared.staticFile', 'client.js', cachebust), + }), + ]), + ]) + ]); + }, +}; diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js new file mode 100644 index 00000000..6cffcef4 --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLinks.js @@ -0,0 +1,32 @@ +export default { + // Returns an array with the slotted previous and next links, prepared + // for inclusion in a page's navigation bar. Include with other links + // in the nav bar and then join them all as a unit list, for example. + + extraDependencies: ['html', 'language'], + + slots: { + previousLink: {type: 'html'}, + nextLink: {type: 'html'}, + }, + + generate(slots, {html, language}) { + return [ + !html.isBlank(slots.previousLink) && + slots.previousLink.slots({ + tooltip: true, + color: false, + attributes: {id: 'previous-button'}, + content: language.$('misc.nav.previous'), + }), + + !html.isBlank(slots.nextLink) && + slots.nextLink?.slots({ + tooltip: true, + color: false, + attributes: {id: 'next-button'}, + content: language.$('misc.nav.next'), + }), + ]; + }, +}; diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js new file mode 100644 index 00000000..5a97e651 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -0,0 +1,42 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html', 'language'], + + relations(relation, contributions) { + if (empty(contributions)) { + return {}; + } + + return { + contributionLinks: + contributions + .slice(0, 4) + .map(contrib => relation('linkContribution', contrib)), + }; + }, + + slots: { + stringKey: {type: 'string'}, + + showContribution: {type: 'boolean', default: true}, + showIcons: {type: 'boolean', default: true}, + }, + + generate(relations, slots, {html, language}) { + if (!relations.contributionLinks) { + return html.blank(); + } + + return language.$(slots.stringKey, { + artists: + language.formatConjunctionList( + relations.contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + }); + }, +}; diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js new file mode 100644 index 00000000..6fdfd428 --- /dev/null +++ b/src/content/dependencies/generateSecondaryNav.js @@ -0,0 +1,19 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: {type: 'html'}, + + class: { + validate: v => v.oneOf(v.isString, v.arrayOf(v.isString)), + }, + }, + + generate(slots, {html}) { + return html.tag('nav', { + [html.onlyIfContent]: true, + id: 'secondary-nav', + class: slots.class, + }, slots.content); + }, +}; diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js new file mode 100644 index 00000000..cbd477e0 --- /dev/null +++ b/src/content/dependencies/generateStaticPage.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generatePageLayout', 'transformContent'], + + relations(relation, staticPage) { + return { + layout: relation('generatePageLayout'), + content: relation('transformContent', staticPage.content), + }; + }, + + data(staticPage) { + return { + name: staticPage.name, + stylesheet: staticPage.stylesheet, + }; + }, + + generate(data, relations) { + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + additionalStyleRules: + (data.stylesheet + ? [data.stylesheet] + : []), + + mainClasses: ['long-content'], + mainContent: relations.content, + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js new file mode 100644 index 00000000..5ea10765 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,33 @@ +export default { + extraDependencies: ['html'], + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + }, + + generate(slots, {html}) { + const hasCover = !html.isBlank(slots.cover); + + return html.tag('div', + { + class: [ + 'content-sticky-heading-container', + hasCover && 'has-cover', + ], + }, + [ + html.tag('div', {class: 'content-sticky-heading-row'}, [ + html.tag('h1', slots.title), + + hasCover && + html.tag('div', {class: 'content-sticky-heading-cover-container'}, + html.tag('div', {class: 'content-sticky-heading-cover'}, + slots.cover.slot('mode', 'thumbnail'))), + ]), + + html.tag('div', {class: 'content-sticky-subheading-row'}, + html.tag('h2', {class: 'content-sticky-subheading'})), + ]); + }, +}; diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js new file mode 100644 index 00000000..757ad2d6 --- /dev/null +++ b/src/content/dependencies/generateTrackCoverArtwork.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations(relation, track) { + return { + coverArtwork: + relation('generateCoverArtwork', + (track.hasUniqueCoverArt + ? track.artTags + : track.album.artTags)), + }; + }, + + data(track) { + return { + path: + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]), + }; + }, + + generate(data, relations) { + return relations.coverArtwork + .slots({ + path: data.path, + }); + }, +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 00000000..c4596f14 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,662 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; + +import { + sortAlbumsTracksChronologically, + sortFlashesChronologically, +} from '../../util/wiki-data.js'; + +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generateContentHeading', + 'generatePageLayout', + 'generateTrackCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'generateTrackReleaseInfo', + 'linkAlbum', + 'linkArtist', + 'linkContribution', + 'linkFlash', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + relations(relation, sprawl, track) { + const relations = {}; + const sections = relations.sections = {}; + const {album} = track; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', track.album); + + relations.colorStyleRules = + relation('generateColorStyleRules', track.color); + + relations.artistChronologyContributions = + getChronologyRelations(track, { + contributions: [...track.artistContribs, ...track.contributorContribs], + + linkArtist: artist => relation('linkArtist', artist), + linkThing: track => relation('linkTrack', track), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ]), + }); + + relations.coverArtistChronologyContributions = + getChronologyRelations(track, { + contributions: track.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ], { + getDate: albumOrTrack => albumOrTrack.coverArtDate, + }), + }), + + relations.albumLink = + relation('linkAlbum', track.album); + + relations.trackLink = + relation('linkTrack', track); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', track.album, track); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.sidebar = + relation('generateAlbumSidebar', track.album, track); + + const additionalFilesSection = additionalFiles => ({ + heading: relation('generateContentHeading'), + list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), + }); + + if (track.hasUniqueCoverArt || album.hasCoverArt) { + relations.cover = + relation('generateTrackCoverArtwork', track); + } + + // Section: Release info + + relations.releaseInfo = + relation('generateTrackReleaseInfo', track); + + // Section: Extra links + + const extra = sections.extra = {}; + + if (!empty(track.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', track.additionalFiles); + } + + // Section: Other releases + + if (!empty(track.otherReleases)) { + const otherReleases = sections.otherReleases = {}; + + otherReleases.heading = + relation('generateContentHeading'); + + otherReleases.items = + track.otherReleases.map(track => ({ + trackLink: relation('linkTrack', track), + albumLink: relation('linkAlbum', track.album), + })); + } + + // Section: Contributors + + if (!empty(track.contributorContribs)) { + const contributors = sections.contributors = {}; + + contributors.heading = + relation('generateContentHeading'); + + contributors.contributionLinks = + track.contributorContribs + .map(contrib => relation('linkContribution', contrib)); + } + + // Section: Referenced tracks + + if (!empty(track.referencedTracks)) { + const references = sections.references = {}; + + references.heading = + relation('generateContentHeading'); + + references.list = + relation('generateTrackList', track.referencedTracks); + } + + // Section: Tracks that reference + + if (!empty(track.referencedByTracks)) { + const referencedBy = sections.referencedBy = {}; + + referencedBy.heading = + relation('generateContentHeading'); + + referencedBy.list = + relation('generateTrackListDividedByGroups', + track.referencedByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Sampled tracks + + if (!empty(track.sampledTracks)) { + const samples = sections.samples = {}; + + samples.heading = + relation('generateContentHeading'); + + samples.list = + relation('generateTrackList', track.sampledTracks); + } + + // Section: Tracks that sample + + if (!empty(track.sampledByTracks)) { + const sampledBy = sections.sampledBy = {}; + + sampledBy.heading = + relation('generateContentHeading'); + + sampledBy.list = + relation('generateTrackListDividedByGroups', + track.sampledByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Flashes that feature + + if (sprawl.enableFlashesAndGames) { + const sortedFeatures = + sortFlashesChronologically( + [track, ...track.otherReleases].flatMap(track => + track.featuredInFlashes.map(flash => ({ + // These aren't going to be exposed directly, they're processed + // into the appropriate relations after this sort. + flash, track, + + // These properties are only used for the sort. + act: flash.act, + date: flash.date, + })))); + + if (!empty(sortedFeatures)) { + const flashesThatFeature = sections.flashesThatFeature = {}; + + flashesThatFeature.heading = + relation('generateContentHeading'); + + flashesThatFeature.entries = + sortedFeatures.map(({flash, track: directlyFeaturedTrack}) => + (directlyFeaturedTrack === track + ? { + flashLink: relation('linkFlash', flash), + } + : { + flashLink: relation('linkFlash', flash), + trackLink: relation('linkTrack', directlyFeaturedTrack), + })); + } + } + + // Section: Lyrics + + if (track.lyrics) { + const lyrics = sections.lyrics = {}; + + lyrics.heading = + relation('generateContentHeading'); + + lyrics.content = + relation('transformContent', track.lyrics); + } + + // Sections: Sheet music files, MIDI/proejct files, additional files + + if (!empty(track.sheetMusicFiles)) { + sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles); + } + + if (!empty(track.midiProjectFiles)) { + sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles); + } + + if (!empty(track.additionalFiles)) { + sections.additionalFiles = additionalFilesSection(track.additionalFiles); + } + + // Section: Artist commentary + + if (track.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', track.commentary); + } + + return relations; + }, + + data(sprawl, track) { + return { + name: track.name, + + hasTrackNumbers: track.album.hasTrackNumbers, + trackNumber: track.album.tracks.indexOf(track) + 1, + + numAdditionalFiles: track.additionalFiles.length, + }; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('trackPage.title', {track: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: + (relations.cover + ? relations.cover.slots({ + alt: language.$('misc.alt.trackCover'), + }) + : null), + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + sec.sheetMusicFiles && + language.$('releaseInfo.sheetMusicFiles.shortcut', { + link: html.tag('a', + {href: '#sheet-music-files'}, + language.$('releaseInfo.sheetMusicFiles.shortcut.link')), + }), + + sec.midiProjectFiles && + language.$('releaseInfo.midiProjectFiles.shortcut', { + link: html.tag('a', + {href: '#midi-project-files'}, + language.$('releaseInfo.midiProjectFiles.shortcut.link')), + }), + + sec.additionalFiles && + sec.extra.additionalFilesShortcut, + + sec.artistCommentary && + language.$('releaseInfo.readCommentary', { + link: html.tag('a', + {href: '#artist-commentary'}, + language.$('releaseInfo.readCommentary.link')), + }), + ]), + + sec.otherReleases && [ + sec.otherReleases.heading + .slots({ + id: 'also-released-as', + title: language.$('releaseInfo.alsoReleasedAs'), + }), + + html.tag('ul', + sec.otherReleases.items.map(({trackLink, albumLink}) => + html.tag('li', + language.$('releaseInfo.alsoReleasedAs.item', { + track: trackLink, + album: albumLink, + })))), + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + html.tag('ul', + sec.contributors.contributionLinks.map(contributionLink => + html.tag('li', + contributionLink + .slots({ + showIcons: true, + showContribution: true, + })))), + ], + + sec.references && [ + sec.references.heading + .slots({ + id: 'references', + title: + language.$('releaseInfo.tracksReferenced', { + track: html.tag('i', data.name), + }), + }), + + sec.references.list, + ], + + sec.referencedBy && [ + sec.referencedBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatReference', { + track: html.tag('i', data.name), + }), + }), + + sec.referencedBy.list, + ], + + sec.samples && [ + sec.samples.heading + .slots({ + id: 'samples', + title: + language.$('releaseInfo.tracksSampled', { + track: html.tag('i', data.name), + }), + }), + + sec.samples.list, + ], + + sec.sampledBy && [ + sec.sampledBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatSample', { + track: html.tag('i', data.name), + }), + }), + + sec.sampledBy.list, + ], + + sec.flashesThatFeature && [ + sec.flashesThatFeature.heading + .slots({ + id: 'featured-in', + title: + language.$('releaseInfo.flashesThatFeature', { + track: html.tag('i', data.name), + }), + }), + + html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) => + (trackLink + ? html.tag('li', {class: 'rerelease'}, + language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { + flash: flashLink, + track: trackLink, + })) + : html.tag('li', + language.$('releaseInfo.flashesThatFeature.item', { + flash: flashLink, + }))))), + ], + + sec.lyrics && [ + sec.lyrics.heading + .slots({ + id: 'lyrics', + title: language.$('releaseInfo.lyrics'), + }), + + html.tag('blockquote', + sec.lyrics.content + .slot('mode', 'lyrics')), + ], + + sec.sheetMusicFiles && [ + sec.sheetMusicFiles.heading + .slots({ + id: 'sheet-music-files', + title: language.$('releaseInfo.sheetMusicFiles.heading'), + }), + + sec.sheetMusicFiles.list, + ], + + sec.midiProjectFiles && [ + sec.midiProjectFiles.heading + .slots({ + id: 'midi-project-files', + title: language.$('releaseInfo.midiProjectFiles.heading'), + }), + + sec.midiProjectFiles.list, + ], + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.list, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.albumLink}, + { + html: + (data.hasTrackNumbers + ? language.$('trackPage.nav.track.withNumber', { + number: data.trackNumber, + track: relations.trackLink + .slot('attributes', {class: 'current'}), + }) + : language.$('trackPage.nav.track', { + track: relations.trackLink + .slot('attributes', {class: 'current'}), + })), + }, + ], + + navBottomRowContent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + }), + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.track', + contributions: relations.artistChronologyContributions, + }, + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + ...relations.sidebar, + }); + }, +}; + +/* + const data = { + type: 'data', + path: ['track', track.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForTrack, + serializeLink, + }) => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate, + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artistsContribs: serializeContribs(track.artistContribs), + contributorContribs: serializeContribs(track.contributorContribs), + coverArtistContribs: serializeContribs(track.coverArtistContribs || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map((track) => ({ + track: serializeLink(track), + album: serializeLink(track.album), + })), + }), + }; + + const getSocialEmbedDescription = ({ + getArtistString: _getArtistString, + language, + }) => { + const hasArtists = !empty(track.artistContribs); + const hasCoverArtists = !empty(track.coverArtistContribs); + const getArtistString = (contribs) => + _getArtistString(contribs, { + // We don't want to put actual HTML tags in social embeds (sadly + // they don't get parsed and displayed, generally speaking), so + // override the link argument so that artist "links" just show + // their names. + link: {artist: (artist) => artist.name}, + }); + if (!hasArtists && !hasCoverArtists) return ''; + return language.formatString( + 'trackPage.socialEmbed.body' + + [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] + .filter(Boolean) + .join(''), + Object.fromEntries( + [ + hasArtists && ['artists', getArtistString(track.artistContribs)], + hasCoverArtists && [ + 'coverArtists', + getArtistString(track.coverArtistContribs), + ], + ].filter(Boolean) + ) + ); + }; + + const page = { + page: () => { + return { + title: language.$('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + + themeColor: track.color, + theme: + getThemeString(track.color, { + additionalVariables: [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ] + }), + + socialEmbed: { + heading: language.$('trackPage.socialEmbed.heading', { + album: track.album.name, + }), + headingLink: absoluteTo('localized.album', album.directory), + title: language.$('trackPage.socialEmbed.title', { + track: track.name, + }), + description: getSocialEmbedDescription({getArtistString, language}), + image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), + color: track.color, + }, + + secondaryNav: generateAlbumSecondaryNav(album, track, { + getLinkThemeString, + html, + language, + link, + }), + }; + }, + }; +*/ diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js new file mode 100644 index 00000000..d0f14618 --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,49 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkTrack', 'linkContribution'], + + extraDependencies: ['html', 'language'], + + relations(relation, tracks) { + if (empty(tracks)) { + return {}; + } + + return { + items: tracks.map(track => ({ + trackLink: + relation('linkTrack', track), + + contributionLinks: + track.artistContribs + .map(contrib => relation('linkContribution', contrib)), + })), + }; + }, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + generate(relations, slots, {html, language}) { + return html.tag('ul', + relations.items.map(({trackLink, contributionLinks}) => + html.tag('li', + language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))); + }, +}; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js new file mode 100644 index 00000000..1f1ebef8 --- /dev/null +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -0,0 +1,53 @@ +import {empty} from '../../util/sugar.js'; + +import groupTracksByGroup from '../util/groupTracksByGroup.js'; + +export default { + contentDependencies: ['generateTrackList', 'linkGroup'], + extraDependencies: ['html', 'language'], + + relations(relation, tracks, groups) { + if (empty(tracks)) { + return {}; + } + + if (empty(groups)) { + return { + flatList: + relation('generateTrackList', tracks), + }; + } + + const lists = groupTracksByGroup(tracks, groups); + + return { + groupedLists: + Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({ + ...(groupOrOther === 'other' + ? {other: true} + : {groupLink: relation('linkGroup', groupOrOther)}), + + list: + relation('generateTrackList', tracks), + })), + }; + }, + + generate(relations, {html, language}) { + if (relations.flatList) { + return relations.flatList; + } + + return html.tag('dl', + relations.groupedLists.map(({other, groupLink, list}) => [ + html.tag('dt', + (other + ? language.$('trackList.group.fromOther') + : language.$('trackList.group', { + group: groupLink + }))), + + html.tag('dd', list), + ])); + }, +}; diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js new file mode 100644 index 00000000..2ac20388 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -0,0 +1,87 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, track) { + const relations = {}; + + relations.artistContributionLinks = + relation('generateReleaseInfoContributionsLine', track.artistContribs); + + if (track.hasUniqueCoverArt) { + relations.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', track.coverArtistContribs); + } + + if (!empty(track.urls)) { + relations.externalLinks = + track.urls.map(url => + relation('linkExternal', url)); + } + + return relations; + }, + + data(track) { + const data = {}; + + data.name = track.name; + data.date = track.date; + data.duration = track.duration; + + if ( + track.hasUniqueCoverArt && + track.coverArtDate && + +track.coverArtDate !== +track.date + ) { + data.coverArtDate = track.coverArtDate; + } + + return data; + }, + + generate(data, relations, {html, language}) { + return html.tags([ + html.tag('p', { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, [ + relations.artistContributionLinks + .slots({stringKey: 'releaseInfo.by'}), + + relations.coverArtistContributionsLine + ?.slots({stringKey: 'releaseInfo.coverArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: language.formatDuration(data.duration), + }), + ]), + + html.tag('p', + (relations.externalLinks + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(relations.externalLinks), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + ]); + }, +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js new file mode 100644 index 00000000..2fbe1188 --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,204 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'getSizeOfImageFile', + 'html', + 'language', + 'thumb', + 'to', + ], + + data(artTags) { + const data = {}; + + if (artTags) { + data.contentWarnings = + artTags + .filter(tag => tag.isContentWarning) + .map(tag => tag.name); + } else { + data.contentWarnings = null; + } + + return data; + }, + + slots: { + src: {type: 'string'}, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + thumb: {type: 'string'}, + + reveal: {type: 'boolean', default: true}, + link: {type: 'boolean', default: false}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + + id: {type: 'string'}, + class: {type: 'string'}, + alt: {type: 'string'}, + width: {type: 'number'}, + height: {type: 'number'}, + + missingSourceContent: {type: 'html'}, + }, + + generate(data, slots, { + getSizeOfImageFile, + html, + language, + thumb, + to, + }) { + let originalSrc; + + if (slots.src) { + originalSrc = slots.src; + } else if (!empty(slots.path)) { + originalSrc = to(...slots.path); + } else { + originalSrc = ''; + } + + const thumbSrc = + originalSrc && + (slots.thumb + ? thumb[slots.thumb](originalSrc) + : originalSrc); + + const willLink = typeof slots.link === 'string' || slots.link; + + const willReveal = + slots.reveal && + originalSrc && + !empty(data.contentWarnings); + + const willSquare = slots.square; + + const idOnImg = willLink ? null : slots.id; + const idOnLink = willLink ? slots.id : null; + const classOnImg = willLink ? null : slots.class; + const classOnLink = willLink ? slots.class : null; + + if (!originalSrc) { + return prepare( + html.tag('div', {class: 'image-text-area'}, + slots.missingSourceContent)); + } + + let fileSize = null; + if (willLink) { + const mediaRoot = to('media.root'); + if (originalSrc.startsWith(mediaRoot)) { + fileSize = + getSizeOfImageFile( + originalSrc + .slice(mediaRoot.length) + .replace(/^\//, '')); + } + } + + let reveal = null; + if (willReveal) { + reveal = [ + language.$('misc.contentWarnings', { + warnings: language.formatUnitList(data.contentWarnings), + }), + html.tag('br'), + html.tag('span', {class: 'reveal-interaction'}, + language.$('misc.contentWarnings.reveal')), + ]; + } + + const imgAttributes = { + id: idOnImg, + class: classOnImg, + alt: slots.alt, + width: slots.width, + height: slots.height, + 'data-original-size': fileSize, + }; + + const nonlazyHTML = + originalSrc && + prepare( + html.tag('img', { + ...imgAttributes, + src: thumbSrc, + })); + + if (slots.lazy) { + return html.tags([ + html.tag('noscript', nonlazyHTML), + prepare( + html.tag('img', + { + ...imgAttributes, + class: 'lazy', + 'data-original': thumbSrc, + }), + true), + ]); + } + + return nonlazyHTML; + + function prepare(content, hide = false) { + let wrapped = content; + + wrapped = + html.tag('div', {class: 'image-container'}, + html.tag('div', {class: 'image-inner-area'}, + wrapped)); + + if (willReveal) { + wrapped = + html.tag('div', {class: 'reveal'}, [ + wrapped, + html.tag('span', {class: 'reveal-text-container'}, + html.tag('span', {class: 'reveal-text'}, + reveal)), + ]); + } + + if (willSquare) { + wrapped = + html.tag('div', + { + class: [ + 'square', + hide && !willLink && 'js-hide' + ], + }, + + html.tag('div', {class: 'square-content'}, + wrapped)); + } + + if (willLink) { + wrapped = html.tag('a', + { + id: idOnLink, + class: [ + 'box', + 'image-link', + hide && 'js-hide', + classOnLink, + ], + + href: + (typeof slots.link === 'string' + ? slots.link + : originalSrc), + }, + wrapped); + } + + return wrapped; + } + }, +}; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js new file mode 100644 index 00000000..36cd27fc --- /dev/null +++ b/src/content/dependencies/index.js @@ -0,0 +1,255 @@ +import chokidar from 'chokidar'; +import {ESLint} from 'eslint'; + +import EventEmitter from 'node:events'; +import {readdir} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import contentFunction, {ContentFunctionSpecError} from '../../content-function.js'; +import {color, logWarn} from '../../util/cli.js'; +import {annotateFunction} from '../../util/sugar.js'; + +function cachebust(filePath) { + if (filePath in cachebust.cache) { + cachebust.cache[filePath] += 1; + return `${filePath}?cachebust${cachebust.cache[filePath]}`; + } else { + cachebust.cache[filePath] = 0; + return filePath; + } +} + +cachebust.cache = Object.create(null); + +export function watchContentDependencies({ + mock = null, + logging = true, +} = {}) { + const events = new EventEmitter(); + const contentDependencies = {}; + + let emittedReady = false; + let allDependenciesFulfilled = false; + let closed = false; + + let _close = () => {}; + + Object.assign(events, { + contentDependencies, + close, + }); + + const eslint = new ESLint(); + + const metaPath = fileURLToPath(import.meta.url); + const metaDirname = path.dirname(metaPath); + const watchPath = metaDirname; + + const mockKeys = new Set(); + if (mock) { + const errors = []; + + for (const [functionName, spec] of Object.entries(mock)) { + mockKeys.add(functionName); + try { + const fn = processFunctionSpec(functionName, spec); + contentDependencies[functionName] = fn; + } catch (error) { + error.message = `(${functionName}) ${error.message}`; + errors.push(error); + } + } + + if (errors.length) { + throw new AggregateError(errors, `Errors processing mocked content functions`); + } + } + + // Chokidar's 'ready' event is supposed to only fire once an 'add' event + // has been fired for everything in the watched directory, but it's not + // totally reliable. https://github.com/paulmillr/chokidar/issues/1011 + // + // Workaround here is to readdir for the names of all dependencies ourselves, + // and enter null for each into the contentDependencies object. We'll emit + // 'ready' ourselves only once no nulls remain. And we won't actually start + // watching until the readdir is done and nulls are entered (so we don't + // prematurely find out there aren't any nulls - before the nulls have + // been entered at all!). + + readdir(metaDirname).then(files => { + if (closed) { + return; + } + + const filePaths = files.map(file => path.join(metaDirname, file)); + for (const filePath of filePaths) { + if (filePath === metaPath) continue; + const functionName = getFunctionName(filePath); + if (!isMocked(functionName)) { + contentDependencies[functionName] = null; + } + } + + const watcher = chokidar.watch(metaDirname); + + watcher.on('all', (event, filePath) => { + if (!['add', 'change'].includes(event)) return; + if (filePath === metaPath) return; + handlePathUpdated(filePath); + + }); + + watcher.on('unlink', (filePath) => { + if (filePath === metaPath) { + console.error(`Yeowzers content dependencies just got nuked.`); + return; + } + + handlePathRemoved(filePath); + }); + + _close = () => watcher.close(); + }); + + return events; + + async function close() { + closed = true; + return _close(); + } + + function checkReadyConditions() { + if (emittedReady) return; + if (Object.values(contentDependencies).includes(null)) return; + + events.emit('ready'); + emittedReady = true; + } + + function getFunctionName(filePath) { + const shortPath = path.basename(filePath); + const functionName = shortPath.slice(0, -path.extname(shortPath).length); + return functionName; + } + + function isMocked(functionName) { + return mockKeys.has(functionName); + } + + async function handlePathRemoved(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + delete contentDependencies[functionName]; + } + + async function handlePathUpdated(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + let error = null; + + main: { + const eslintResults = await eslint.lintFiles([filePath]); + const eslintFormatter = await eslint.loadFormatter('stylish'); + const eslintResultText = eslintFormatter.format(eslintResults); + if (eslintResultText.trim().length) { + console.log(eslintResultText); + } + + let spec; + try { + spec = (await import(cachebust(filePath))).default; + } catch (caughtError) { + error = caughtError; + error.message = `Error importing: ${error.message}`; + break main; + } + + // Just skip newly created files. They'll be processed again when + // written. + if (spec === undefined) { + contentDependencies[functionName] = null; + return; + } + + let fn; + try { + fn = processFunctionSpec(functionName, spec); + } catch (caughtError) { + error = caughtError; + break main; + } + + if (logging && emittedReady) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(color.green(`[${timestamp}] Updated ${functionName}`)); + } + + contentDependencies[functionName] = fn; + + events.emit('update', functionName); + checkReadyConditions(); + } + + if (!error) { + return true; + } + + if (!(functionName in contentDependencies)) { + contentDependencies[functionName] = null; + } + + events.emit('error', functionName, error); + + if (logging) { + if (contentDependencies[functionName]) { + logWarn`Failed to import ${functionName} - using existing version`; + } else { + logWarn`Failed to import ${functionName} - no prior version loaded`; + } + + if (typeof error === 'string') { + console.error(color.yellow(error)); + } else if (error instanceof ContentFunctionSpecError) { + console.error(color.yellow(error.message)); + } else { + console.error(error); + } + } + + return false; + } + + function processFunctionSpec(functionName, spec) { + if (typeof spec?.data === 'function') { + annotateFunction(spec.data, {name: functionName, description: 'data'}); + } + + if (typeof spec?.generate === 'function') { + annotateFunction(spec.generate, {name: functionName}); + } + + return contentFunction(spec); + } +} + +export function quickLoadContentDependencies(opts) { + return new Promise((resolve, reject) => { + const watcher = watchContentDependencies(opts); + + watcher.on('error', (name, error) => { + watcher.close().then(() => { + error.message = `Error loading dependency ${name}: ${error}`; + reject(error); + }); + }); + + watcher.on('ready', () => { + watcher.close().then(() => { + resolve(watcher.contentDependencies); + }); + }); + }); +} diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js new file mode 100644 index 00000000..36b0d13a --- /dev/null +++ b/src/content/dependencies/linkAlbum.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.album', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js new file mode 100644 index 00000000..39e7111e --- /dev/null +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -0,0 +1,24 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(album, file) { + return { + albumDirectory: album.directory, + file, + }; + }, + + generate(data, relations) { + return relations.linkTemplate + .slots({ + path: ['media.albumAdditionalFile', data.albumDirectory, data.file], + content: data.file, + }); + }, +}; diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js new file mode 100644 index 00000000..ab519fd6 --- /dev/null +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumCommentary', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js new file mode 100644 index 00000000..e3f30a29 --- /dev/null +++ b/src/content/dependencies/linkAlbumGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumGallery', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js new file mode 100644 index 00000000..7ddb7786 --- /dev/null +++ b/src/content/dependencies/linkArtTag.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.tag', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js new file mode 100644 index 00000000..718ee6fa --- /dev/null +++ b/src/content/dependencies/linkArtist.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artist', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js new file mode 100644 index 00000000..66dc172d --- /dev/null +++ b/src/content/dependencies/linkArtistGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistGallery', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js new file mode 100644 index 00000000..f4c05388 --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,72 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkExternalAsIcon', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, contribution) { + const relations = {}; + + relations.artistLink = + relation('linkArtist', contribution.who); + + if (!empty(contribution.who.urls)) { + relations.artistIcons = + contribution.who.urls + .slice(0, 4) + .map(url => relation('linkExternalAsIcon', url)); + } + + return relations; + }, + + data(contribution) { + return { + what: contribution.what, + }; + }, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + generate(data, relations, slots, {html, language}) { + const hasContributionPart = !!(slots.showContribution && data.what); + const hasExternalPart = !!(slots.showIcons && relations.artistIcons); + + const externalLinks = hasExternalPart && + html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'icons'}, + language.formatUnitList(relations.artistIcons)); + + const parts = ['misc.artistLink']; + const options = {artist: relations.artistLink}; + + if (hasContributionPart) { + parts.push('withContribution'); + options.contrib = data.what; + } + + if (hasExternalPart) { + parts.push('withExternalLinks'); + options.links = externalLinks; + } + + const content = language.formatString(parts.join('.'), options); + + return ( + (parts.length > 1 + ? html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'nowrap'}, + content) + : content)); + }, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 00000000..7c3d86a8 --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,90 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language'], + + data(url) { + return {url}; + }, + + slots: { + mode: { + validate: v => v.is('generic', 'album'), + default: 'generic', + }, + }, + + generate(data, slots, {html, language}) { + let isLocal; + let domain; + try { + domain = new URL(data.url).hostname; + } catch (error) { + // No support for relative local URLs yet, sorry! (I.e, local URLs must + // be absolute relative to the domain name in order to work.) + isLocal = true; + } + + const a = html.tag('a', + { + href: data.url, + class: 'nowrap', + }, + + // truly unhinged indentation here + isLocal + ? language.$('misc.external.local') + + : domain.includes('bandcamp.com') + ? language.$('misc.external.bandcamp') + + : BANDCAMP_DOMAINS.includes(domain) + ? language.$('misc.external.bandcamp.domain', {domain}) + + : MASTODON_DOMAINS.includes(domain) + ? language.$('misc.external.mastodon.domain', {domain}) + + : domain.includes('youtu') + ? slots.mode === 'album' + ? data.url.includes('list=') + ? language.$('misc.external.youtube.playlist') + : language.$('misc.external.youtube.fullAlbum') + : language.$('misc.external.youtube') + + : domain.includes('soundcloud') + ? language.$('misc.external.soundcloud') + + : domain.includes('tumblr.com') + ? language.$('misc.external.tumblr') + + : domain.includes('twitter.com') + ? language.$('misc.external.twitter') + + : domain.includes('deviantart.com') + ? language.$('misc.external.deviantart') + + : domain.includes('wikipedia.org') + ? language.$('misc.external.wikipedia') + + : domain.includes('poetryfoundation.org') + ? language.$('misc.external.poetryFoundation') + + : domain.includes('instagram.com') + ? language.$('misc.external.instagram') + + : domain.includes('patreon.com') + ? language.$('misc.external.patreon') + + : domain.includes('spotify.com') + ? language.$('misc.external.spotify') + + : domain.includes('newgrounds.com') + ? language.$('misc.external.newgrounds') + + : domain); + + return a; + } +}; diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js new file mode 100644 index 00000000..cd168992 --- /dev/null +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -0,0 +1,46 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language', 'to'], + + data(url) { + return {url}; + }, + + generate(data, {html, language, to}) { + const domain = new URL(data.url).hostname; + const [id, msg] = ( + domain.includes('bandcamp.com') + ? ['bandcamp', language.$('misc.external.bandcamp')] + : BANDCAMP_DOMAINS.includes(domain) + ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] + : MASTODON_DOMAINS.includes(domain) + ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] + : domain.includes('youtu') + ? ['youtube', language.$('misc.external.youtube')] + : domain.includes('soundcloud') + ? ['soundcloud', language.$('misc.external.soundcloud')] + : domain.includes('tumblr.com') + ? ['tumblr', language.$('misc.external.tumblr')] + : domain.includes('twitter.com') + ? ['twitter', language.$('misc.external.twitter')] + : domain.includes('deviantart.com') + ? ['deviantart', language.$('misc.external.deviantart')] + : domain.includes('instagram.com') + ? ['instagram', language.$('misc.external.bandcamp')] + : domain.includes('newgrounds.com') + ? ['newgrounds', language.$('misc.external.newgrounds')] + : ['globe', language.$('misc.external.domain', {domain})]); + + return html.tag('a', + {href: data.url, class: 'icon'}, + html.tag('svg', [ + html.tag('title', msg), + html.tag('use', { + href: to('shared.staticIcon', id), + }), + ])); + }, +}; diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js new file mode 100644 index 00000000..65158ff8 --- /dev/null +++ b/src/content/dependencies/linkExternalFlash.js @@ -0,0 +1,41 @@ +// Note: This function is seriously hard-coded for HSMusic, with custom +// presentation of links to Homestuck flashes hosted various places. + +export default { + contentDependencies: ['linkExternal'], + extraDependencies: ['html', 'language'], + + relations(relation, url) { + return { + link: relation('linkExternal', url), + }; + }, + + data(url, flash) { + return { + url, + page: flash.page, + }; + }, + + generate(data, relations, {html, language}) { + const {link} = relations; + const {url, page} = data; + + return html.tag('span', + {class: 'nowrap'}, + + url.includes('homestuck.com') + ? isNaN(Number(page)) + ? language.$('misc.external.flash.homestuck.secret', {link}) + : language.$('misc.external.flash.homestuck.page', {link, page}) + + : url.includes('bgreco.net') + ? language.$('misc.external.flash.bgreco', {link}) + + : url.includes('youtu') + ? language.$('misc.external.flash.youtube', {link}) + + : link); + }, +}; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js new file mode 100644 index 00000000..93dd5a28 --- /dev/null +++ b/src/content/dependencies/linkFlash.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, flash) => + ({link: relation('linkThing', 'localized.flash', flash)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js new file mode 100644 index 00000000..ebab1b5b --- /dev/null +++ b/src/content/dependencies/linkGroup.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupInfo', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js new file mode 100644 index 00000000..ee6a3b1d --- /dev/null +++ b/src/content/dependencies/linkGroupExtra.js @@ -0,0 +1,34 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html'], + + relations(relation, group) { + const relations = {}; + + relations.info = + relation('linkGroup', group); + + if (!empty(group.albums)) { + relations.gallery = + relation('linkGroupGallery', group); + } + + return relations; + }, + + slots: { + extra: { + validate: v => v.is('gallery'), + }, + }, + + generate(relations, slots) { + return relations[slots.extra ?? 'info'] ?? relations.info; + }, +}; diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js new file mode 100644 index 00000000..86c4a0f3 --- /dev/null +++ b/src/content/dependencies/linkGroupGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupGallery', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js new file mode 100644 index 00000000..2fc516bc --- /dev/null +++ b/src/content/dependencies/linkListing.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkThing'], + extraDependencies: ['language'], + + relations: (relation, listing) => + ({link: relation('linkThing', 'localized.listing', listing)}), + + data: (listing) => + ({stringsKey: listing.stringsKey}), + + generate: (data, relations, {language}) => + relations.link + .slot('content', language.$(`listingPage.${data.stringsKey}.title`)), +}; diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js new file mode 100644 index 00000000..1bfaf46e --- /dev/null +++ b/src/content/dependencies/linkListingIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.listingIndex', + 'listingIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js new file mode 100644 index 00000000..1fb32dd9 --- /dev/null +++ b/src/content/dependencies/linkNewsEntry.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, newsEntry) => + ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js new file mode 100644 index 00000000..032af6c9 --- /dev/null +++ b/src/content/dependencies/linkStaticPage.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, staticPage) => + ({link: relation('linkThing', 'localized.staticPage', staticPage)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js new file mode 100644 index 00000000..d5506e60 --- /dev/null +++ b/src/content/dependencies/linkStationaryIndex.js @@ -0,0 +1,24 @@ +// Not to be confused with "html.Stationery". + +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['language'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, stringKey) { + return {pathKey, stringKey}; + }, + + generate(data, relations, {language}) { + return relations.linkTemplate + .slots({ + path: [data.pathKey], + content: language.formatString(data.stringKey), + }); + } +} diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js new file mode 100644 index 00000000..98e2c8b9 --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,67 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'appendIndexHTML', + 'getColors', + 'html', + 'to', + ], + + slots: { + href: {type: 'string'}, + path: {validate: v => v.validateArrayItems(v.isString)}, + hash: {type: 'string'}, + + tooltip: {validate: v => v.isString}, + attributes: {validate: v => v.isAttributes}, + color: {validate: v => v.isColor}, + content: {type: 'html'}, + }, + + generate(slots, { + appendIndexHTML, + getColors, + html, + to, + }) { + let href = slots.href; + let style; + let title; + + if (!href && !empty(slots.path)) { + href = to(...slots.path); + } + + if (appendIndexHTML) { + if ( + /^(?!https?:\/\/).+\/$/.test(href) && + href.endsWith('/') + ) { + href += 'index.html'; + } + } + + if (slots.hash) { + href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash; + } + + if (slots.color) { + const {primary, dim} = getColors(slots.color); + style = `--primary-color: ${primary}; --dim-color: ${dim}`; + } + + if (slots.tooltip) { + title = slots.tooltip; + } + + return html.tag('a', + { + ...slots.attributes ?? {}, + href, + style, + title, + }, + slots.content); + }, +} diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js new file mode 100644 index 00000000..4ebf4d76 --- /dev/null +++ b/src/content/dependencies/linkThing.js @@ -0,0 +1,84 @@ +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['html'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, thing) { + return { + pathKey, + + color: thing.color, + directory: thing.directory, + + name: thing.name, + nameShort: thing.nameShort, + }; + }, + + slots: { + content: {type: 'html'}, + + preferShortName: {type: 'boolean', default: false}, + + tooltip: { + validate: v => v.oneOf(v.isBoolean, v.isString), + default: false, + }, + + color: { + validate: v => v.oneOf(v.isBoolean, v.isColor), + default: true, + }, + + anchor: {type: 'boolean', default: false}, + + attributes: {validate: v => v.isAttributes}, + hash: {type: 'string'}, + }, + + generate(data, relations, slots, {html}) { + const path = [data.pathKey, data.directory]; + + let content = slots.content; + + const name = + (slots.preferShortName + ? data.nameShort ?? data.name + : data.name); + + if (html.isBlank(content)) { + content = name; + } + + let color = null; + if (slots.color === true) { + color = data.color ?? null; + } else if (typeof slots.color === 'string') { + color = slots.color; + } + + let tooltip = null; + if (slots.tooltip === true) { + tooltip = name; + } else if (typeof slots.tooltip === 'string') { + tooltip = slots.tooltip; + } + + return relations.linkTemplate + .slots({ + path: slots.anchor ? [] : path, + href: slots.anchor ? '' : null, + content, + color, + tooltip, + + attributes: slots.attributes, + hash: slots.hash, + }); + }, +} diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js new file mode 100644 index 00000000..d5d96726 --- /dev/null +++ b/src/content/dependencies/linkTrack.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.track', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js new file mode 100644 index 00000000..1c584282 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDate.js @@ -0,0 +1,52 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + + albums: + sortChronologically(albumData.filter(album => album.date)), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + dates: + query.albums + .map(album => album.date), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + date: data.dates, + }).map(({link, date}) => ({ + album: link, + date: language.formatDate(date), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js new file mode 100644 index 00000000..e2ff8461 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDateAdded.js @@ -0,0 +1,59 @@ +import {chunkByProperties, sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + + chunks: + chunkByProperties( + sortAlphabetically(albumData.filter(a => a.dateAddedToWiki)) + .sort((a, b) => { + if (a.dateAddedToWiki < b.dateAddedToWiki) return -1; + if (a.dateAddedToWiki > b.dateAddedToWiki) return 1; + }), + ['dateAddedToWiki']), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.chunks.map(({chunk}) => + chunk.map(album => relation('linkAlbum', album))), + }; + }, + + data(query) { + return { + dates: + query.chunks.map(({dateAddedToWiki}) => dateAddedToWiki), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + data.dates.map(date => ({ + date: language.formatDate(date), + })), + + chunkRows: + relations.albumLinks.map(albumLinks => + albumLinks.map(link => ({ + album: link, + }))), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js new file mode 100644 index 00000000..650a5d1e --- /dev/null +++ b/src/content/dependencies/listAlbumsByDuration.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = albumData.slice(); + const durations = albums.map(album => getTotalDuration(album.tracks)); + + filterByCount(albums, durations); + sortByCount(albums, durations, {greatestFirst: true}); + + return {spec, albums, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + album: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js new file mode 100644 index 00000000..c302a9cb --- /dev/null +++ b/src/content/dependencies/listAlbumsByName.js @@ -0,0 +1,50 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + albums: sortAlphabetically(albumData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + counts: + query.albums + .map(album => album.tracks.length), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + count: data.counts, + }).map(({link, count}) => ({ + album: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js new file mode 100644 index 00000000..c31609bd --- /dev/null +++ b/src/content/dependencies/listAlbumsByTracks.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = albumData.slice(); + const counts = albums.map(album => album.tracks.length); + + filterByCount(albums, counts); + sortByCount(albums, counts, {greatestFirst: true}); + + return {spec, albums, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.albumLinks, + count: data.counts, + }).map(({link, count}) => ({ + album: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js new file mode 100644 index 00000000..eae6dd6e --- /dev/null +++ b/src/content/dependencies/listArtistsByCommentaryEntries.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = artistData.slice(); + const counts = + artists.map(artist => + artist.tracksAsCommentator.length + + artist.albumsAsCommentator.length); + + filterByCount(artists, counts); + sortByCount(artists, counts, {greatestFirst: true}); + + return {artists, counts, spec}; + }, + + relations(relation, query) { + return { + page: + relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + entries: language.countCommentaryEntries(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js new file mode 100644 index 00000000..442b8329 --- /dev/null +++ b/src/content/dependencies/listArtistsByContributions.js @@ -0,0 +1,163 @@ +import {stitchArrays, unique} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return { + artistData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, spec) { + const query = { + spec, + enableFlashesAndGames: sprawl.enableFlashesAndGames, + }; + + const queryContributionInfo = (artistsKey, countsKey, fn) => { + const artists = sprawl.artistData.slice(); + const counts = artists.map(artist => fn(artist)); + + filterByCount(artists, counts); + sortByCount(artists, counts, {greatestFirst: true}); + + query[artistsKey] = artists; + query[countsKey] = counts; + }; + + queryContributionInfo( + 'artistsByTrackContributions', + 'countsByTrackContributions', + artist => + unique([ + ...artist.tracksAsContributor, + ...artist.tracksAsArtist, + ]).length); + + queryContributionInfo( + 'artistsByArtworkContributions', + 'countsByArtworkContributions', + artist => + artist.tracksAsCoverArtist.length + + artist.albumsAsCoverArtist.length + + artist.albumsAsWallpaperArtist.length + + artist.albumsAsBannerArtist.length); + + if (sprawl.enableFlashesAndGames) { + queryContributionInfo( + 'artistsByFlashContributions', + 'countsByFlashContributions', + artist => + artist.flashesAsContributor.length); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + relations.artistLinksByTrackContributions = + query.artistsByTrackContributions + .map(artist => relation('linkArtist', artist)); + + relations.artistLinksByArtworkContributions = + query.artistsByArtworkContributions + .map(artist => relation('linkArtist', artist)); + + if (query.enableFlashesAndGames) { + relations.artistLinksByFlashContributions = + query.artistsByFlashContributions + .map(artist => relation('linkArtist', artist)); + } + + return relations; + }, + + data(query) { + const data = {}; + + data.enableFlashesAndGames = query.enableFlashesAndGames; + + data.countsByTrackContributions = query.countsByTrackContributions; + data.countsByArtworkContributions = query.countsByArtworkContributions; + + if (query.enableFlashesAndGames) { + data.countsByFlashContributions = query.countsByFlashContributions; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const lists = Object.fromEntries( + ([ + ['tracks', [ + relations.artistLinksByTrackContributions, + data.countsByTrackContributions, + 'countTracks', + ]], + + ['artworks', [ + relations.artistLinksByArtworkContributions, + data.countsByArtworkContributions, + 'countArtworks', + ]], + + data.enableFlashesAndGames && + ['flashes', [ + relations.artistLinksByFlashContributions, + data.countsByFlashContributions, + 'countFlashes', + ]], + ]).filter(Boolean) + .map(([key, [artistLinks, counts, countFunction]]) => [ + key, + html.tag('ul', + stitchArrays({ + artistLink: artistLinks, + count: counts, + }).map(({artistLink, count}) => + html.tag('li', + language.$('listingPage.listArtists.byContribs.item', { + artist: artistLink, + contributions: language[countFunction](count, {unit: true}), + })))), + ])); + + return relations.page.slots({ + type: 'custom', + content: + html.tag('div', {class: 'content-columns'}, [ + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$('listingPage.misc.trackContributors')), + + lists.tracks, + ]), + + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$( + 'listingPage.misc.artContributors')), + + lists.artworks, + + lists.flashes && [ + html.tag('h2', + language.$('listingPage.misc.flashContributors')), + + lists.flashes, + ], + ]), + ]), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js new file mode 100644 index 00000000..478e99bb --- /dev/null +++ b/src/content/dependencies/listArtistsByDuration.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = artistData.slice(); + const durations = artists.map(artist => + getTotalDuration([ + ...(artist.tracksAsArtist ?? []), + ...(artist.tracksAsContributor ?? []), + ], {originalReleasesOnly: true})); + + filterByCount(artists, durations); + sortByCount(artists, durations, {greatestFirst: true}); + + return {spec, artists, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + artist: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js new file mode 100644 index 00000000..3b9b3a51 --- /dev/null +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -0,0 +1,367 @@ +import {transposeArrays, empty, stitchArrays} from '../../util/sugar.js'; + +import { + chunkMultipleArrays, + compareCaseLessSensitive, + compareDates, + filterMultipleArrays, + reduceMultipleArrays, + sortAlphabetically, + sortMultipleArrays, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkArtist', + 'linkFlash', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return { + artistData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + query(sprawl, spec) { + const query = { + spec, + enableFlashesAndGames: sprawl.enableFlashesAndGames, + }; + + const queryContributionInfo = ( + artistsKey, + chunkThingsKey, + datesKey, + datelessArtistsKey, + fn, + ) => { + const artists = sortAlphabetically(sprawl.artistData.slice()); + + // Each value stored in dateLists, corresponding to each artist, + // is going to be a list of dates and nulls. Any nulls represent + // a contribution which isn't associated with a particular date. + const [chunkThingLists, dateLists] = + transposeArrays(artists.map(artist => fn(artist))); + + // Scrap artists who don't even have any relevant contributions. + // These artists may still have other contributions across the wiki, but + // they weren't returned by the callback and so aren't relevant to this + // list. + filterMultipleArrays( + artists, + chunkThingLists, + dateLists, + (artists, chunkThings, dates) => !empty(dates)); + + // Also exclude artists whose remaining contributions are all dateless. + // But keep track of the artists removed here, since they'll be displayed + // in an additional list in the final listing page. + const {removed: [datelessArtists]} = + filterMultipleArrays( + artists, + chunkThingLists, + dateLists, + (artist, chunkThings, dates) => !empty(dates.filter(Boolean))); + + // Cut out dateless contributions. They're not relevant to finding the + // latest date. + for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) { + filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date); + } + + const [chunkThings, dates] = + transposeArrays( + transposeArrays([chunkThingLists, dateLists]) + .map(([chunkThings, dates]) => + reduceMultipleArrays( + chunkThings, dates, + (accChunkThing, accDate, chunkThing, date) => + (date && date > accDate + ? [chunkThing, date] + : [accChunkThing, accDate])))); + + sortMultipleArrays(artists, dates, chunkThings, + (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => { + const dateComparison = compareDates(dateA, dateB, {latestFirst: true}); + if (dateComparison !== 0) { + return dateComparison; + } + + // TODO: Compare alphabetically, not just by directory. + return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory); + }); + + const chunks = + chunkMultipleArrays(artists, dates, chunkThings, + (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) => + +date !== +lastDate || chunkThing !== lastChunkThing); + + query[chunkThingsKey] = + chunks.map(([artists, dates, chunkThings]) => chunkThings[0]); + + query[datesKey] = + chunks.map(([artists, dates, chunkThings]) => dates[0]); + + query[artistsKey] = + chunks.map(([artists, dates, chunkThings]) => artists); + + query[datelessArtistsKey] = datelessArtists; + }; + + queryContributionInfo( + 'artistsByTrackContributions', + 'albumsByTrackContributions', + 'datesByTrackContributions', + 'datelessArtistsByTrackContributions', + artist => { + const tracks = + [...artist.tracksAsArtist, ...artist.tracksAsContributor] + .filter(track => !track.originalReleaseTrack); + + const albums = tracks.map(track => track.album); + const dates = tracks.map(track => track.date); + + return [albums, dates]; + }); + + queryContributionInfo( + 'artistsByArtworkContributions', + 'albumsByArtworkContributions', + 'datesByArtworkContributions', + 'datelessArtistsByArtworkContributions', + artist => [ + [ + ...artist.tracksAsCoverArtist.map(track => track.album), + ...artist.albumsAsCoverArtist, + ...artist.albumsAsWallpaperArtist, + ...artist.albumsAsBannerArtist, + ], + [ + // TODO: Per-artwork dates, see #90. + ...artist.tracksAsCoverArtist.map(track => track.coverArtDate), + ...artist.albumsAsCoverArtist.map(album => album.coverArtDate), + ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate), + ...artist.albumsAsBannerArtist.map(album => album.coverArtDate), + ], + ]); + + if (sprawl.enableFlashesAndGames) { + queryContributionInfo( + 'artistsByFlashContributions', + 'flashesByFlashContributions', + 'datesByFlashContributions', + 'datelessArtistsByFlashContributions', + artist => [ + [ + ...artist.flashesAsContributor, + ], + [ + ...artist.flashesAsContributor.map(flash => flash.date), + ], + ]); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + // Track contributors + + relations.albumLinksByTrackContributions = + query.albumsByTrackContributions + .map(album => relation('linkAlbum', album)); + + relations.artistLinksByTrackContributions = + query.artistsByTrackContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByTrackContributions = + query.datelessArtistsByTrackContributions + .map(artist => relation('linkArtist', artist)); + + // Artwork contributors + + relations.albumLinksByArtworkContributions = + query.albumsByArtworkContributions + .map(album => relation('linkAlbum', album)); + + relations.artistLinksByArtworkContributions = + query.artistsByArtworkContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByArtworkContributions = + query.datelessArtistsByArtworkContributions + .map(artist => relation('linkArtist', artist)); + + // Flash contributors + + if (query.enableFlashesAndGames) { + relations.flashLinksByFlashContributions = + query.flashesByFlashContributions + .map(flash => relation('linkFlash', flash)); + + relations.artistLinksByFlashContributions = + query.artistsByFlashContributions + .map(artists => + artists.map(artist => relation('linkArtist', artist))); + + relations.datelessArtistLinksByFlashContributions = + query.datelessArtistsByFlashContributions + .map(artist => relation('linkArtist', artist)); + } + + return relations; + }, + + data(query) { + const data = {}; + + data.enableFlashesAndGames = query.enableFlashesAndGames; + + data.datesByTrackContributions = query.datesByTrackContributions; + data.datesByArtworkContributions = query.datesByArtworkContributions; + + if (query.enableFlashesAndGames) { + data.datesByFlashContributions = query.datesByFlashContributions; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const chunkTitles = Object.fromEntries( + ([ + ['tracks', [ + 'album', + relations.albumLinksByTrackContributions, + data.datesByTrackContributions, + ]], + + ['artworks', [ + 'album', + relations.albumLinksByArtworkContributions, + data.datesByArtworkContributions, + ]], + + data.enableFlashesAndGames && + ['flashes', [ + 'flash', + relations.flashLinksByFlashContributions, + data.datesByFlashContributions, + ]], + ]).filter(Boolean) + .map(([key, [stringsKey, links, dates]]) => [ + key, + stitchArrays({link: links, date: dates}) + .map(({link, date}) => + html.tag('dt', + language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, { + [stringsKey]: link, + date: language.formatDate(date), + }))), + ])); + + const chunkItems = Object.fromEntries( + ([ + ['tracks', relations.artistLinksByTrackContributions], + ['artworks', relations.artistLinksByArtworkContributions], + data.enableFlashesAndGames && + ['flashes', relations.artistLinksByFlashContributions], + ]).filter(Boolean) + .map(([key, artistLinkLists]) => [ + key, + artistLinkLists.map(artistLinks => + html.tag('dd', + html.tag('ul', + artistLinks.map(artistLink => + html.tag('li', + language.$('listingPage.listArtists.byLatest.chunk.item', { + artist: artistLink, + })))))), + ])); + + const lists = Object.fromEntries( + ([ + ['tracks', [ + chunkTitles.tracks, + chunkItems.tracks, + relations.datelessArtistLinksByTrackContributions, + ]], + + ['artworks', [ + chunkTitles.artworks, + chunkItems.artworks, + relations.datelessArtistLinksByArtworkContributions, + ]], + + data.enableFlashesAndGames && + ['flashes', [ + chunkTitles.flashes, + chunkItems.flashes, + relations.datelessArtistLinksByFlashContributions, + ]], + ]).filter(Boolean) + .map(([key, [titles, items, datelessArtistLinks]]) => [ + key, + html.tags([ + html.tag('dl', + stitchArrays({ + title: titles, + items: items, + }).map(({title, items}) => [title, items])), + + !empty(datelessArtistLinks) && [ + html.tag('p', + language.$('listingPage.listArtists.byLatest.dateless.title')), + + html.tag('ul', + datelessArtistLinks.map(artistLink => + html.tag('li', + language.$('listingPage.listArtists.byLatest.dateless.item', { + artist: artistLink, + })))), + ], + ]), + ])); + + return relations.page.slots({ + type: 'custom', + content: + html.tag('div', {class: 'content-columns'}, [ + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$('listingPage.misc.trackContributors')), + + lists.tracks, + ]), + + html.tag('div', {class: 'column'}, [ + html.tag('h2', + language.$( + 'listingPage.misc.artContributors')), + + lists.artworks, + + lists.flashes && [ + html.tag('h2', + language.$('listingPage.misc.flashContributors')), + + lists.flashes, + ], + ]), + ]), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js new file mode 100644 index 00000000..1b93eca8 --- /dev/null +++ b/src/content/dependencies/listArtistsByName.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + getArtistNumContributions, + sortAlphabetically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + return { + spec, + + artists: sortAlphabetically(artistData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(album => relation('linkArtist', album)), + }; + }, + + data(query) { + return { + counts: + query.artists + .map(artist => getArtistNumContributions(artist)), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js new file mode 100644 index 00000000..2235c0dd --- /dev/null +++ b/src/content/dependencies/listGroupsByAlbums.js @@ -0,0 +1,51 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const counts = groups.map(group => group.albums.length); + + filterByCount(groups, counts); + sortByCount(groups, counts, {greatestFirst: true}); + + return {spec, groups, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + count: data.counts, + }).map(({link, count}) => ({ + group: link, + albums: language.countAlbums(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js new file mode 100644 index 00000000..84a895f6 --- /dev/null +++ b/src/content/dependencies/listGroupsByCategory.js @@ -0,0 +1,76 @@ +import {stitchArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupCategoryData}) { + return {groupCategoryData}; + }, + + query({groupCategoryData}, spec) { + return { + spec, + groupCategories: groupCategoryData, + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + categoryLinks: + query.groupCategories + .map(category => relation('linkGroup', category.groups[0])), + + infoLinks: + query.groupCategories + .map(category => + category.groups + .map(group => relation('linkGroup', group))), + + galleryLinks: + query.groupCategories + .map(category => + category.groups + .map(group => relation('linkGroupGallery', group))) + }; + }, + + data(query) { + return { + categoryNames: + query.groupCategories + .map(category => category.name), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + stitchArrays({ + link: relations.categoryLinks, + name: data.categoryNames, + }).map(({link, name}) => ({ + category: link.slot('content', name), + })), + + chunkRows: + stitchArrays({ + infoLinks: relations.infoLinks, + galleryLinks: relations.galleryLinks, + }).map(({infoLinks, galleryLinks}) => + stitchArrays({ + infoLink: infoLinks, + galleryLink: galleryLinks, + }).map(({infoLink, galleryLink}) => ({ + group: infoLink, + gallery: + galleryLink + .slot('content', language.$('listingPage.listGroups.byCategory.chunk.item.gallery')), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js new file mode 100644 index 00000000..cf24a472 --- /dev/null +++ b/src/content/dependencies/listGroupsByDuration.js @@ -0,0 +1,55 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const durations = + groups.map(group => + getTotalDuration( + group.albums.flatMap(album => album.tracks), + {originalReleasesOnly: true})); + + filterByCount(groups, durations); + sortByCount(groups, durations, {greatestFirst: true}); + + return {spec, groups, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + group: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js new file mode 100644 index 00000000..0d2ee5c2 --- /dev/null +++ b/src/content/dependencies/listGroupsByLatestAlbum.js @@ -0,0 +1,78 @@ +import {stitchArrays} from '../../util/sugar.js'; + +import { + compareDates, + filterMultipleArrays, + sortChronologically, + sortMultipleArrays, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = sortChronologically(groupData.slice()); + + const albums = + groups + .map(group => + sortChronologically( + group.albums.filter(album => album.date), + {latestFirst: true})) + .map(albums => albums[0]); + + filterMultipleArrays(groups, albums, (group, album) => album); + + const dates = albums.map(album => album.date); + + // Note: After this sort, the groups/dates arrays are misaligned with + // albums. That's OK only because we aren't doing anything further with + // the albums array. + sortMultipleArrays(groups, dates, + (groupA, groupB, dateA, dateB) => + compareDates(dateA, dateB, {latestFirst: true})); + + return {spec, groups, dates}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + dates: query.dates, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + groupLink: relations.groupLinks, + date: data.dates, + }).map(({groupLink, date}) => ({ + group: groupLink, + date: language.formatDate(date), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js new file mode 100644 index 00000000..df35937b --- /dev/null +++ b/src/content/dependencies/listGroupsByName.js @@ -0,0 +1,49 @@ +import {stitchArrays} from '../../util/sugar.js'; +import {sortAlphabetically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + return { + spec, + + groups: sortAlphabetically(groupData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + infoLinks: + query.groups + .map(group => relation('linkGroup', group)), + + galleryLinks: + query.groups + .map(group => relation('linkGroupGallery', group)), + }; + }, + + generate(relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + infoLink: relations.infoLinks, + galleryLink: relations.galleryLinks, + }).map(({infoLink, galleryLink}) => ({ + group: infoLink, + gallery: + galleryLink + .slot('content', language.$('listingPage.listGroups.byName.item.gallery')), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js new file mode 100644 index 00000000..35ce153d --- /dev/null +++ b/src/content/dependencies/listGroupsByTracks.js @@ -0,0 +1,55 @@ +import {accumulateSum, stitchArrays} from '../../util/sugar.js'; +import {filterByCount, sortByCount} from '../../util/wiki-data.js'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = groupData.slice(); + const counts = + groups.map(group => + accumulateSum( + group.albums, + ({tracks}) => tracks.length)); + + filterByCount(groups, counts); + sortByCount(groups, counts, {greatestFirst: true}); + + return {spec, groups, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + counts: query.counts, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + count: data.counts, + }).map(({link, count}) => ({ + group: link, + tracks: language.countTracks(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js new file mode 100644 index 00000000..7010e9de --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,322 @@ +import {marked} from 'marked'; + +import {bindFind} from '../../util/find.js'; +import {parseInput} from '../../util/replacer.js'; +import {replacerSpec} from '../../util/transform-content.js'; + +const linkThingRelationMap = { + album: 'linkAlbum', + albumCommentary: 'linkAlbumCommentary', + albumGallery: 'linkAlbumGallery', + artist: 'linkArtist', + artistGallery: 'linkArtistGallery', + flash: 'linkFlash', + groupInfo: 'linkGroup', + groupGallery: 'linkGroupGallery', + listing: 'linkListing', + newsEntry: 'linkNewsEntry', + staticPage: 'linkStaticPage', + tag: 'linkArtTag', + track: 'linkTrack', +}; + +const linkValueRelationMap = { + // media: 'linkPathFromMedia', + // root: 'linkPathFromRoot', + // site: 'linkPathFromSite', +}; + +const linkIndexRelationMap = { + // commentaryIndex: 'linkCommentaryIndex', + // flashIndex: 'linkFlashIndex', + // home: 'linkHome', + // listingIndex: 'linkListingIndex', + // newsIndex: 'linkNewsIndex', +}; + +function getPlaceholder(node, content) { + return {type: 'text', data: content.slice(node.i, node.iEnd)}; +} + +export default { + contentDependencies: [ + ...Object.values(linkThingRelationMap), + ...Object.values(linkValueRelationMap), + ...Object.values(linkIndexRelationMap), + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl(wikiData, content) { + const find = bindFind(wikiData); + + const parsedNodes = parseInput(content); + + return { + nodes: parsedNodes + .map(node => { + if (node.type !== 'tag') { + return node; + } + + const placeholder = getPlaceholder(node, content); + + const replacerKeyImplied = !node.data.replacerKey; + const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data; + + // TODO: We don't support recursive nodes like before, at the moment. Sorry! + // const replacerValue = transformNodes(node.data.replacerValue, opts); + const replacerValue = node.data.replacerValue[0].data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return placeholder; + } + + if (spec.link) { + let data = {key: spec.link}; + + determineData: { + // No value at all: this is an index link. + if (!replacerValue) { + break determineData; + } + + // Nothing to find: the link operates on a path or string, not a data object. + if (!spec.find) { + data.value = replacerValue; + break determineData; + } + + const thing = + find[spec.find]( + (replacerKeyImplied + ? replacerValue + : replacerKey + `:` + replacerValue), + wikiData); + + // Nothing was found: this is unexpected, so return placeholder. + if (!thing) { + return placeholder; + } + + // Something was found: the link operates on that thing. + data.thing = thing; + } + + const {transformName} = spec; + + // TODO: Again, no recursive nodes. Sorry! + // const enteredLabel = node.data.label && transformNode(node.data.label, opts); + const enteredLabel = node.data.label?.data; + const enteredHash = node.data.hash?.data; + + data.label = + enteredLabel ?? + (transformName && data.thing.name + ? transformName(data.thing.name, node, content) + : null); + + data.hash = enteredHash ?? null; + + return {i: node.i, iEnd: node.iEnd, type: 'link', data}; + } + + // This will be another {type: 'tag'} node which gets processed in + // generate. + return node; + }), + }; + }, + + data(sprawl, content) { + return { + content, + + nodes: + sprawl.nodes + .map(node => { + // Replace link nodes with a stub. It'll be replaced (by position) + // with an item from relations. + if (node.type === 'link') { + return {type: 'link'}; + } + + // Other nodes will get processed in generate. + return node; + }), + }; + }, + + relations(relation, sprawl, content) { + const {nodes} = sprawl; + + const relationOrPlaceholder = + (node, name, arg) => + (name + ? { + link: relation(name, arg), + label: node.data.label, + hash: node.data.hash, + } + : getPlaceholder(node, content)); + + return { + links: + nodes + .filter(({type}) => type === 'link') + .map(node => { + const {key, thing, value} = node.data; + + if (thing) { + return relationOrPlaceholder(node, linkThingRelationMap[key], thing); + } else if (value) { + return relationOrPlaceholder(node, linkValueRelationMap[key], value); + } else { + return relationOrPlaceholder(node, linkIndexRelationMap[key]); + } + }), + }; + }, + + slots: { + mode: { + validate: v => v.is('inline', 'multiline', 'lyrics'), + default: 'multiline', + }, + }, + + generate(data, relations, slots, {html, language}) { + let linkIndex = 0; + + // This array contains only straight text and link nodes, which are directly + // representable in html (so no further processing is needed on the level of + // individual nodes). + const contentFromNodes = + data.nodes.map(node => { + if (node.type === 'text') { + return {type: 'text', data: node.data}; + } + + if (node.type === 'link') { + const linkNode = relations.links[linkIndex++]; + if (linkNode.type === 'text') { + return {type: 'text', data: linkNode.data}; + } + + const {link, label, hash} = linkNode; + + return { + type: 'link', + data: link.slots({content: label, hash}), + }; + } + + if (node.type === 'tag') { + const {replacerKey, replacerValue} = node.data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return getPlaceholder(node, data.content); + } + + const {value: valueFn, html: htmlFn} = spec; + + const value = + (valueFn + ? valueFn(replacerValue) + : replacerValue); + + const contents = + (htmlFn + ? htmlFn(value, {html, language}) + : value); + + return {type: 'text', data: contents}; + } + + return getPlaceholder(node, data.content); + }); + + // In inline mode, no further processing is needed! + + if (slots.mode === 'inline') { + return html.tags(contentFromNodes.map(node => node.data)); + } + + // Multiline mode has a secondary processing stage where it's passed... + // through marked! Rolling your own Markdown only gets you so far :D + + const markedOptions = { + headerIds: false, + mangle: false, + }; + + // This is separated into its own function just since we're gonna reuse + // it in a minute if everything goes to heck in lyrics mode. + const transformMultiline = () => { + const markedInput = + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data; + } else { + return node.data.toString(); + } + }) + .join('') + + // Compress multiple line breaks into single line breaks. + .replace(/\n{2,}/g, '\n') + // Expand line breaks which don't follow a list, quote, + // or <br> / " ". + .replace(/(?<!^ *-.*|^>.*| $|<br>$)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n') + // Expand line breaks which are at the end of a quote. + .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + + return marked.parse(markedInput, markedOptions); + } + + if (slots.mode === 'multiline') { + // Unfortunately, we kind of have to be super evil here and stringify + // the links, or else parse marked's output into html tags, which is + // very out of scope at the moment. + return transformMultiline(); + } + + // Lyrics mode goes through marked too, but line breaks are processed + // differently. Instead of having each line get its own paragraph, + // "adjacent" lines are joined together (with blank lines separating + // each verse/paragraph). + + if (slots.mode === 'lyrics') { + // If it looks like old data, using <br> instead of bunched together + // lines... then oh god... just use transformMultiline. Perishes. + if ( + contentFromNodes.some(node => + node.type === 'text' && + node.data.includes('<br')) + ) { + return transformMultiline(); + } + + // Lyrics mode is also evil for the same stringifying reasons as + // multiline. + return marked.parse( + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data.replace(/\b\n\b/g, '<br>\n'); + } else { + return node.data.toString(); + } + }) + .join(''), + markedOptions); + } + }, +} diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js new file mode 100644 index 00000000..11281e75 --- /dev/null +++ b/src/content/util/getChronologyRelations.js @@ -0,0 +1,42 @@ +export default function getChronologyRelations(thing, { + contributions, + linkArtist, + linkThing, + getThings, +}) { + // One call to getChronologyRelations is considered "lumping" together all + // contributions as carrying equivalent meaning (for example, "artist" + // contributions and "contributor" contributions are bunched together in + // one call to getChronologyRelations, while "cover artist" contributions + // are a separate call). getChronologyRelations prevents duplicates that + // carry the same meaning by only using the first instance of each artist + // in the contributions array passed to it. It's expected that the string + // identifying which kind of contribution ("track" or "cover art") is + // shared and applied to all contributions, as providing them together + // in one call to getChronologyRelations implies they carry the same + // meaning. + + const artistsSoFar = new Set(); + + contributions = contributions.filter(({who}) => { + if (artistsSoFar.has(who)) { + return false; + } else { + artistsSoFar.add(who); + return true; + } + }); + + return contributions.map(({who}) => { + const things = Array.from(new Set(getThings(who))); + const index = things.indexOf(thing); + const previous = things[index - 1]; + const next = things[index + 1]; + return { + index: index + 1, + artistLink: linkArtist(who), + previousLink: previous ? linkThing(previous) : null, + nextLink: next ? linkThing(next) : null, + }; + }); +} diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js new file mode 100644 index 00000000..559967bc --- /dev/null +++ b/src/content/util/groupTracksByGroup.js @@ -0,0 +1,23 @@ +import {empty} from '../../util/sugar.js'; + +export default function groupTracksByGroup(tracks, groups) { + const lists = new Map(groups.map(group => [group, []])); + lists.set('other', []); + + for (const track of tracks) { + const group = groups.find(group => group.albums.includes(track.album)); + if (group) { + lists.get(group).push(track); + } else { + lists.get('other').push(track); + } + } + + for (const [key, tracks] of lists.entries()) { + if (empty(tracks)) { + lists.delete(key); + } + } + + return lists; +} |