diff options
Diffstat (limited to 'src/content/dependencies')
180 files changed, 15507 insertions, 0 deletions
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js new file mode 100644 index 0000000..930b6f1 --- /dev/null +++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js @@ -0,0 +1,53 @@ +export default { + contentDependencies: [ + 'generateDatetimestampTemplate', + 'generateTooltip', + ], + + extraDependencies: ['html', 'language'], + + data: (date) => + ({date}), + + relations: (relation) => ({ + template: + relation('generateDatetimestampTemplate'), + + tooltip: + relation('generateTooltip'), + }), + + slots: { + style: { + validate: v => v.is('full', 'year'), + default: 'full', + }, + + // Only has an effect for 'year' style. + tooltip: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {language}) => + relations.template.slots({ + mainContent: + (slots.style === 'full' + ? language.formatDate(data.date) + : slots.style === 'year' + ? data.date.getFullYear().toString() + : null), + + tooltip: + slots.tooltip && + slots.style === 'year' && + relations.tooltip.slots({ + content: + language.formatDate(data.date), + }), + + datetime: + data.date.toISOString(), + }), +}; diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js new file mode 100644 index 0000000..f504cf8 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,24 @@ +import {stitchArrays} from '#sugar'; + +export default { + extraDependencies: ['html'], + + slots: { + chunks: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + chunkItems: { + validate: v => v.strictArrayOf(v.isHTML), + }, + }, + + generate: (slots, {html}) => + html.tag('ul', {class: 'additional-files-list'}, + stitchArrays({ + chunk: slots.chunks, + items: slots.chunkItems, + }).map(({chunk, items}) => + chunk.clone() + .slot('items', items))), +}; diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js new file mode 100644 index 0000000..5804115 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesListChunk.js @@ -0,0 +1,53 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + title: { + type: 'html', + mutable: false, + }, + + description: { + type: 'html', + mutable: false, + }, + + items: { + validate: v => v.looseArrayOf(v.isHTML), + }, + }, + + generate(slots, {html, language}) { + const summary = + html.tag('summary', + html.tag('span', + language.$('releaseInfo.additionalFiles.entry', { + title: + html.tag('span', {class: 'group-name'}, + slots.title), + }))); + + const description = + html.tag('li', {class: 'entry-description'}, + {[html.onlyIfContent]: true}, + slots.description); + + const items = + (html.isBlank(slots.items) + ? html.tag('li', + language.$('releaseInfo.additionalFiles.entry.noFilesAvailable')) + : slots.items); + + const content = + html.tag('ul', [description, items]); + + const details = + html.tag('details', + html.isBlank(slots.items) && + {open: true}, + + [summary, content]); + + return html.tag('li', details); + }, +}; diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js new file mode 100644 index 0000000..c37d6bb --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesListChunkItem.js @@ -0,0 +1,30 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + fileLink: { + type: 'html', + mutable: false, + }, + + fileSize: { + validate: v => v.isWholeNumber, + }, + }, + + generate(slots, {html, language}) { + const itemParts = ['releaseInfo.additionalFiles.file']; + const itemOptions = {file: slots.fileLink}; + + if (slots.fileSize) { + itemParts.push('withSize'); + itemOptions.size = language.formatFileSize(slots.fileSize); + } + + const li = + html.tag('li', + language.$(...itemParts, itemOptions)); + + return li; + }, +}; diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js new file mode 100644 index 0000000..9e119bc --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesShortcut.js @@ -0,0 +1,27 @@ +import {empty} from '#sugar'; + +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/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js new file mode 100644 index 0000000..63427c5 --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['generateAdditionalNamesBoxItem'], + extraDependencies: ['html', 'language'], + + relations: (relation, additionalNames) => ({ + items: + additionalNames + .map(entry => relation('generateAdditionalNamesBoxItem', entry)), + }), + + generate: (relations, {html, language}) => + html.tag('div', {id: 'additional-names-box'}, [ + html.tag('p', + language.$('misc.additionalNames.title')), + + html.tag('ul', + relations.items + .map(item => html.tag('li', item))), + ]), +}; diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js new file mode 100644 index 0000000..7515b5b --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js @@ -0,0 +1,71 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkTrack', 'transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + nameContent: + relation('transformContent', entry.name), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + trackLinks: + (entry.from + ? entry.from.map(track => relation('linkTrack', track)) + : null), + }), + + data: (entry) => ({ + albumNames: + (entry.from + ? entry.from.map(track => track.album.name) + : null), + }), + + generate: (data, relations, {html, language}) => { + const prefix = 'misc.additionalNames.item'; + + const itemParts = [prefix]; + const itemOptions = {}; + + itemOptions.name = + html.tag('span', {class: 'additional-name'}, + relations.nameContent.slot('mode', 'inline')); + + const accentParts = [prefix, 'accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (relations.trackLinks) { + accentParts.push('withAlbums'); + accentOptions.albums = + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + }).map(({trackLink, albumName}) => + trackLink.slot('content', + language.sanitize(albumName)))); + } + + if (accentParts.length > 2) { + itemParts.push('withAccent'); + itemOptions.accent = + html.tag('span', {class: 'accent'}, + html.metatag('chunkwrap', {split: ','}, + html.resolve( + language.$(...accentParts, accentOptions)))); + } + + return language.$(...itemParts, itemOptions); + }, +}; diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js new file mode 100644 index 0000000..9818a43 --- /dev/null +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -0,0 +1,96 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesList', + 'generateAdditionalFilesListChunk', + 'generateAdditionalFilesListChunkItem', + 'linkAlbumAdditionalFile', + 'transformContent', + ], + + extraDependencies: ['getSizeOfAdditionalFile', 'html', 'urls'], + + relations: (relation, album, additionalFiles) => ({ + list: + relation('generateAdditionalFilesList', additionalFiles), + + chunks: + additionalFiles + .map(() => relation('generateAdditionalFilesListChunk')), + + chunkDescriptions: + additionalFiles + .map(({description}) => + (description + ? relation('transformContent', description) + : null)), + + chunkItems: + additionalFiles + .map(({files}) => + (files ?? []) + .map(() => relation('generateAdditionalFilesListChunkItem'))), + + chunkItemFileLinks: + additionalFiles + .map(({files}) => + (files ?? []) + .map(file => relation('linkAlbumAdditionalFile', album, file))), + }), + + data: (album, additionalFiles) => ({ + albumDirectory: album.directory, + + chunkTitles: + additionalFiles + .map(({title}) => title), + + chunkItemLocations: + additionalFiles + .map(({files}) => files ?? []), + }), + + slots: { + showFileSizes: {type: 'boolean', default: true}, + }, + + generate: (data, relations, slots, {getSizeOfAdditionalFile, urls}) => + relations.list.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + description: relations.chunkDescriptions, + title: data.chunkTitles, + }).map(({chunk, title, description}) => + chunk.slots({ + title, + description: + (description + ? description.slot('mode', 'inline') + : null), + })), + + chunkItems: + stitchArrays({ + items: relations.chunkItems, + fileLinks: relations.chunkItemFileLinks, + locations: data.chunkItemLocations, + }).map(({items, fileLinks, locations}) => + stitchArrays({ + item: items, + fileLink: fileLinks, + location: locations, + }).map(({item, fileLink, location}) => + item.slots({ + fileLink: fileLink, + fileSize: + (slots.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, location)) + : 0), + }))), + }), +}; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js new file mode 100644 index 0000000..3cc141b --- /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 0000000..751a0c9 --- /dev/null +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -0,0 +1,274 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumCoverArtwork', + 'generateAlbumNavAccent', + 'generateAlbumSidebarTrackSection', + 'generateAlbumStyleRules', + 'generateCommentaryEntry', + 'generateContentHeading', + 'generateTrackCoverArtwork', + 'generatePageLayout', + 'generatePageSidebar', + 'linkAlbum', + 'linkExternal', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generatePageSidebar'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album, null); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + if (album.commentary) { + relations.albumCommentaryHeading = + relation('generateContentHeading'); + + relations.albumCommentaryLink = + relation('linkAlbum', album); + + relations.albumCommentaryListeningLinks = + album.urls.map(url => relation('linkExternal', url)); + + if (album.hasCoverArt) { + relations.albumCommentaryCover = + relation('generateAlbumCoverArtwork', album); + } + + relations.albumCommentaryEntries = + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)); + } + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + relations.trackCommentaryHeadings = + tracksWithCommentary + .map(() => relation('generateContentHeading')); + + relations.trackCommentaryLinks = + tracksWithCommentary + .map(track => relation('linkTrack', track)); + + relations.trackCommentaryListeningLinks = + tracksWithCommentary + .map(track => + track.urls.map(url => relation('linkExternal', url))); + + relations.trackCommentaryCovers = + tracksWithCommentary + .map(track => + (track.hasUniqueCoverArt + ? relation('generateTrackCoverArtwork', track) + : null)); + + relations.trackCommentaryEntries = + tracksWithCommentary + .map(track => + track.commentary + .map(entry => relation('generateCommentaryEntry', entry))); + + relations.sidebarAlbumLink = + relation('linkAlbum', album); + + relations.sidebarTrackSections = + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, null, trackSection)); + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + data.color = album.color; + + const tracksWithCommentary = + album.tracks + .filter(({commentary}) => commentary); + + const thingsWithCommentary = + (album.commentary + ? [album, ...tracksWithCommentary] + : tracksWithCommentary); + + data.entryCount = + thingsWithCommentary + .flatMap(({commentary}) => commentary) + .length; + + data.wordCount = + thingsWithCommentary + .flatMap(({commentary}) => commentary) + .map(({body}) => body) + .join(' ') + .split(' ') + .length; + + data.trackCommentaryDirectories = + tracksWithCommentary + .map(track => track.directory); + + data.trackCommentaryColors = + tracksWithCommentary + .map(track => + (track.color === album.color + ? null + : track.color)); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('albumCommentaryPage.title', { + album: data.name, + }), + + headingMode: 'sticky', + + color: data.color, + styleRules: [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.albumCommentaryEntries && [ + relations.albumCommentaryHeading.slots({ + tag: 'h3', + color: data.color, + + title: + language.$('albumCommentaryPage.entry.title.albumCommentary', { + album: relations.albumCommentaryLink, + }), + + accent: + !empty(relations.albumCommentaryListeningLinks) && + language.$('albumCommentaryPage.entry.title.albumCommentary.accent', { + listeningLinks: + language.formatUnitList( + relations.albumCommentaryListeningLinks + .map(link => link.slots({ + context: 'album', + tab: 'separate', + }))), + }), + }), + + relations.albumCommentaryCover + ?.slots({mode: 'commentary'}), + + relations.albumCommentaryEntries, + ], + + stitchArrays({ + heading: relations.trackCommentaryHeadings, + link: relations.trackCommentaryLinks, + listeningLinks: relations.trackCommentaryListeningLinks, + directory: data.trackCommentaryDirectories, + cover: relations.trackCommentaryCovers, + entries: relations.trackCommentaryEntries, + color: data.trackCommentaryColors, + }).map(({ + heading, + link, + listeningLinks, + directory, + cover, + entries, + color, + }) => [ + heading.slots({ + tag: 'h3', + id: directory, + color, + + title: + language.$('albumCommentaryPage.entry.title.trackCommentary', { + track: link, + }), + + accent: + !empty(listeningLinks) && + language.$('albumCommentaryPage.entry.title.trackCommentary.accent', { + listeningLinks: + language.formatUnitList( + listeningLinks.map(link => + link.slot('tab', 'separate'))), + }), + }), + + cover?.slots({mode: 'commentary'}), + + entries.map(entry => entry.slot('color', color)), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: false, + showExtraLinks: true, + currentExtra: 'commentary', + }), + }, + ], + + leftSidebar: + relations.sidebar.slots({ + attributes: {class: 'commentary-track-list-sidebar-box'}, + + stickyMode: 'column', + + content: [ + html.tag('h1', relations.sidebarAlbumLink), + relations.sidebarTrackSections.map(section => + section.slots({ + anchor: true, + open: true, + mode: 'commentary', + })), + ], + }), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js new file mode 100644 index 0000000..dbb22fe --- /dev/null +++ b/src/content/dependencies/generateAlbumCoverArtwork.js @@ -0,0 +1,26 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, album) => ({ + coverArtwork: + relation('generateCoverArtwork', album.artTags), + }), + + data: (album) => ({ + path: + ['media.albumCover', album.directory, album.coverArtFileExtension], + + color: + album.color, + + dimensions: + album.coverArtDimensions, + }), + + generate: (data, relations) => + relations.coverArtwork.slots({ + path: data.path, + color: data.color, + dimensions: data.dimensions, + }), +}; diff --git a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js new file mode 100644 index 0000000..7dcdf6d --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['linkArtistGallery'], + extraDependencies: ['html', 'language'], + + relations(relation, coverArtists) { + return { + coverArtistLinks: + coverArtists + .map(artist => relation('linkArtistGallery', artist)), + }; + }, + + generate(relations, {html, language}) { + return ( + html.tag('p', {class: 'quick-info'}, + language.$('albumGalleryPage.coverArtistsLine', { + artists: language.formatConjunctionList(relations.coverArtistLinks), + }))); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js new file mode 100644 index 0000000..ad99cb8 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js @@ -0,0 +1,7 @@ +export default { + extraDependencies: ['html', 'language'], + + generate: ({html, language}) => + html.tag('p', {class: 'quick-info'}, + language.$('albumGalleryPage.noTrackArtworksLine')), +}; diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js new file mode 100644 index 0000000..b4f9268 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -0,0 +1,228 @@ +import {compareArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryCoverArtistsLine', + 'generateAlbumGalleryNoTrackArtworksLine', + 'generateAlbumGalleryStatsLine', + 'generateAlbumNavAccent', + 'generateAlbumSecondaryNav', + 'generateAlbumStyleRules', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(album) { + const query = {}; + + const tracksWithUniqueCoverArt = + album.tracks + .filter(track => track.hasUniqueCoverArt); + + // Don't display "all artwork by..." for albums where there's + // only one unique artwork in the first place. + if (tracksWithUniqueCoverArt.length > 1) { + const allCoverArtistArrays = + tracksWithUniqueCoverArt + .map(track => track.coverArtistContribs) + .map(contribs => contribs.map(contrib => contrib.who)); + + const allSameCoverArtists = + allCoverArtistArrays + .slice(1) + .every(artists => compareArrays(artists, allCoverArtistArrays[0])); + + if (allSameCoverArtists) { + query.coverArtistsForAllTracks = + allCoverArtistArrays[0]; + } + } + + return query; + }, + + relations(relation, query, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album, null); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.secondaryNav = + relation('generateAlbumSecondaryNav', album); + + relations.statsLine = + relation('generateAlbumGalleryStatsLine', album); + + if (album.tracks.every(track => !track.hasUniqueCoverArt)) { + relations.noTrackArtworksLine = + relation('generateAlbumGalleryNoTrackArtworksLine'); + } + + if (query.coverArtistsForAllTracks) { + relations.coverArtistsLine = + relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks); + } + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = [ + relation('linkAlbum', album), + + ... + album.tracks + .map(track => relation('linkTrack', track)), + ]; + + relations.images = [ + (album.hasCoverArt + ? relation('image', album.artTags) + : relation('image')), + + ... + album.tracks.map(track => + (track.hasUniqueCoverArt + ? relation('image', track.artTags) + : relation('image'))), + ]; + + return relations; + }, + + data(query, album) { + const data = {}; + + data.name = album.name; + data.color = album.color; + + data.names = [ + album.name, + ...album.tracks.map(track => track.name), + ]; + + data.coverArtists = [ + (album.hasCoverArt + ? album.coverArtistContribs.map(({who: artist}) => artist.name) + : null), + + ... + album.tracks.map(track => { + if (query.coverArtistsForAllTracks) { + return null; + } + + if (track.hasUniqueCoverArt) { + return track.coverArtistContribs.map(({who: artist}) => artist.name); + } + + return null; + }), + ]; + + data.paths = [ + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null), + + ... + album.tracks.map(track => + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : null)), + ]; + + data.dimensions = [ + (album.hasCoverArt + ? album.coverArtDimensions + : null), + + ... + album.tracks.map(track => + (track.hasUniqueCoverArt + ? track.coverArtDimensions + : null)), + ]; + + return data; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: + language.$('albumGalleryPage.title', { + album: data.name, + }), + + headingMode: 'static', + + color: data.color, + styleRules: [relations.albumStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.statsLine, + relations.coverArtistsLine, + relations.noTrackArtworksLine, + + relations.coverGrid + .slots({ + links: relations.links, + names: data.names, + images: + stitchArrays({ + image: relations.images, + path: data.paths, + dimensions: data.dimensions, + name: data.names, + }).map(({image, path, dimensions, name}) => + image.slots({ + path, + dimensions, + 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', + }), + }, + ], + + secondaryNav: relations.secondaryNav, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js new file mode 100644 index 0000000..75bffb3 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js @@ -0,0 +1,38 @@ +import {getTotalDuration} from '#wiki-data'; + +export default { + extraDependencies: ['html', 'language'], + + data(album) { + return { + name: album.name, + date: album.date, + duration: getTotalDuration(album.tracks), + numTracks: album.tracks.length, + }; + }, + + generate(data, {html, language}) { + const parts = ['albumGalleryPage.statsLine']; + const options = {}; + + options.tracks = + html.tag('b', + language.countTracks(data.numTracks, {unit: true})); + + options.duration = + html.tag('b', + language.formatDuration(data.duration, {unit: true})); + + if (data.date) { + parts.push('withDate'); + options.date = + html.tag('b', + language.formatDate(data.date)); + } + + return ( + html.tag('p', {class: 'quick-info'}, + language.formatString(...parts, options))); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 0000000..e0f23bd --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,273 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {empty} from '#sugar'; + +import getChronologyRelations from '../util/getChronologyRelations.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumBanner', + 'generateAlbumCoverArtwork', + 'generateAlbumNavAccent', + 'generateAlbumReleaseInfo', + 'generateAlbumSecondaryNav', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateAlbumTrackList', + 'generateChronologyLinks', + 'generateCommentarySection', + '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, null); + + 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) { + const getDate = thing => thing.coverArtDate ?? thing.date; + + const things = [ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ].filter(getDate); + + return sortAlbumsTracksChronologically(things, {getDate}); + }, + }); + + 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) { + sections.artistCommentary = + relation('generateCommentarySection', album.commentary); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.name = album.name; + data.color = album.color; + + 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', + + color: data.color, + styleRules: [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]: html.tag('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, + ], + + 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, + + leftSidebar: relations.sidebar, + + socialEmbed: relations.socialEmbed, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js new file mode 100644 index 0000000..121af43 --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,112 @@ +import {empty} from '#sugar'; + +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]); + } + } + + 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, + galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt), + 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: [ + (!data.galleryIsStub || slots.currentExtra === 'gallery') && + 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', + {id: 'random-button'}, + {href: '#', 'data-random': 'track-in-sidebar'}, + + (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 0000000..6fc1375 --- /dev/null +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -0,0 +1,110 @@ +import {accumulateSum, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.artistContribs); + + relations.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; + + data.numTracks = album.tracks.length; + + 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('context', [ + 'album', + (data.numTracks === 0 + ? 'albumNoTracks' + : data.numTracks === 1 + ? 'albumOneTrack' + : 'albumMultipleTracks'), + ]))), + })), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js new file mode 100644 index 0000000..400420b --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -0,0 +1,168 @@ +import {sortChronologically} from '#sort'; +import {atOffset, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generatePreviousNextLinks', + 'generateSecondaryNav', + 'linkAlbumDynamically', + 'linkGroup', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(album) { + const query = {}; + + query.groups = + album.groups; + + if (album.date) { + // Sort by latest first. This matches the sorting order used on group + // gallery pages, ensuring that previous/next matches moving up/down + // the gallery. Note that this makes the index offsets "backwards" + // compared to how latest-last chronological lists are accessed. + const groupAlbums = + query.groups.map(group => + sortChronologically( + group.albums.filter(album => album.date), + {latestFirst: true})); + + const groupCurrentIndex = + groupAlbums.map(albums => + albums.indexOf(album)); + + query.groupPreviousAlbum = + stitchArrays({ + albums: groupAlbums, + index: groupCurrentIndex, + }).map(({albums, index}) => + atOffset(albums, index, +1)); + + query.groupNextAlbum = + stitchArrays({ + albums: groupAlbums, + index: groupCurrentIndex, + }).map(({albums, index}) => + atOffset(albums, index, -1)); + } + + return query; + }, + + relations(relation, query, album) { + const relations = {}; + + relations.secondaryNav = + relation('generateSecondaryNav'); + + relations.groupLinks = + album.groups + .map(group => relation('linkGroup', group)); + + relations.colorStyles = + album.groups + .map(group => relation('generateColorStyleAttribute', group.color)); + + if (album.date) { + relations.previousNextLinks = + stitchArrays({ + previousAlbum: query.groupPreviousAlbum, + nextAlbum: query.groupNextAlbum + }).map(({previousAlbum, nextAlbum}) => + (previousAlbum || nextAlbum + ? relation('generatePreviousNextLinks') + : null)); + + relations.previousAlbumLinks = + query.groupPreviousAlbum.map(previousAlbum => + (previousAlbum + ? relation('linkAlbumDynamically', previousAlbum) + : null)); + + relations.nextAlbumLinks = + query.groupNextAlbum.map(nextAlbum => + (nextAlbum + ? relation('linkAlbumDynamically', nextAlbum) + : null)); + } + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + }, + + generate(relations, slots, {html, language}) { + const navLinksShouldShowPreviousNext = + (slots.mode === 'track' + ? Array.from(relations.previousNextLinks, () => false) + : stitchArrays({ + previousAlbumLink: relations.previousAlbumLinks ?? null, + nextAlbumLink: relations.nextAlbumLinks ?? null, + }).map(({previousAlbumLink, nextAlbumLink}) => + previousAlbumLink || + nextAlbumLink)); + + const navLinkPreviousNextLinks = + stitchArrays({ + showPreviousNext: navLinksShouldShowPreviousNext, + previousNextLinks: relations.previousNextLinks ?? null, + previousAlbumLink: relations.previousAlbumLinks ?? null, + nextAlbumLink: relations.nextAlbumLinks ?? null, + }).map(({ + showPreviousNext, + previousNextLinks, + previousAlbumLink, + nextAlbumLink, + }) => + (showPreviousNext + ? previousNextLinks.slots({ + previousLink: previousAlbumLink, + nextLink: nextAlbumLink, + id: false, + }) + : null)); + + for (const groupLink of relations.groupLinks) { + groupLink.setSlot('color', false); + } + + const navLinkContents = + stitchArrays({ + groupLink: relations.groupLinks, + previousNextLinks: navLinkPreviousNextLinks, + }).map(({groupLink, previousNextLinks}) => [ + language.$('albumSidebar.groupBox.title', { + group: groupLink, + }), + + previousNextLinks && + `(${language.formatUnitList(previousNextLinks.content)})`, + ]); + + const navLinks = + stitchArrays({ + content: navLinkContents, + colorStyle: relations.colorStyles, + }).map(({content, colorStyle}, index) => + html.tag('span', {class: 'nav-link'}, + index > 0 && + {class: 'has-divider'}, + + colorStyle.slot('context', 'primary-only'), + + content)); + + return relations.secondaryNav.slots({ + class: 'nav-links-groups', + content: navLinks, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 0000000..355a9a9 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,47 @@ +export default { + contentDependencies: [ + 'generateAlbumSidebarGroupBox', + 'generateAlbumSidebarTrackListBox', + 'generatePageSidebar', + 'generatePageSidebarConjoinedBox', + ], + + relations: (relation, album, track) => ({ + sidebar: + relation('generatePageSidebar'), + + conjoinedBox: + relation('generatePageSidebarConjoinedBox'), + + trackListBox: + relation('generateAlbumSidebarTrackListBox', album, track), + + groupBoxes: + album.groups.map(group => + relation('generateAlbumSidebarGroupBox', album, group)), + }), + + data: (album, track) => ({ + isAlbumPage: !track, + }), + + generate: (data, relations) => + relations.sidebar.slots({ + boxes: [ + data.isAlbumPage && + relations.groupBoxes + .map(box => box.slot('mode', 'album')), + + relations.trackListBox, + + !data.isAlbumPage && + relations.conjoinedBox.slots({ + attributes: {class: 'conjoined-group-sidebar-box'}, + boxes: + relations.groupBoxes + .map(box => box.slot('mode', 'track')) + .map(box => box.content), /* TODO: Kludge. */ + }), + ], + }), +}; diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js new file mode 100644 index 0000000..00a96c3 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,116 @@ +import {sortChronologically} from '#sort'; +import {atOffset, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageSidebarBox', + 'linkAlbum', + 'linkExternal', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query(album, group) { + const query = {}; + + if (album.date) { + const albums = + group.albums.filter(album => album.date); + + // Sort by latest first. This matches the sorting order used on group + // gallery pages, ensuring that previous/next matches moving up/down + // the gallery. Note that this makes the index offsets "backwards" + // compared to how latest-last chronological lists are accessed. + sortChronologically(albums, {latestFirst: true}); + + const index = + albums.indexOf(album); + + query.previousAlbum = + atOffset(albums, index, +1); + + query.nextAlbum = + atOffset(albums, index, -1); + } + + return query; + }, + + relations(relation, query, album, group) { + const relations = {}; + + relations.box = + relation('generatePageSidebarBox'); + + relations.groupLink = + relation('linkGroup', group); + + relations.externalLinks = + group.urls.map(url => + relation('linkExternal', url)); + + if (group.descriptionShort) { + relations.description = + relation('transformContent', group.descriptionShort); + } + + if (query.previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', query.previousAlbum); + } + + if (query.nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', query.nextAlbum); + } + + return relations; + }, + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'track', + }, + }, + + generate: (relations, slots, {html, language}) => + relations.box.slots({ + attributes: {class: 'individual-group-sidebar-box'}, + content: [ + 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 + .map(link => link.slot('context', 'group'))), + })), + + 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/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js new file mode 100644 index 0000000..3a244e3 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js @@ -0,0 +1,31 @@ +export default { + contentDependencies: [ + 'generateAlbumSidebarTrackSection', + 'generatePageSidebarBox', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + relations: (relation, album, track) => ({ + box: + relation('generatePageSidebarBox'), + + albumLink: + relation('linkAlbum', album), + + trackSections: + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, track, trackSection)), + }), + + generate: (relations, {html}) => + relations.box.slots({ + attributes: {class: 'track-list-sidebar-box'}, + + content: [ + html.tag('h1', relations.albumLink), + relations.trackSections, + ], + }) +}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js new file mode 100644 index 0000000..aa5c723 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -0,0 +1,136 @@ +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; + } + } + + data.trackDirectories = + trackSection.tracks + .map(track => track.directory); + + data.tracksAreMissingCommentary = + trackSection.tracks + .map(track => !track.commentary); + + return data; + }, + + slots: { + anchor: {type: 'boolean'}, + open: {type: 'boolean'}, + + mode: { + validate: v => v.is('info', 'commentary'), + default: 'info', + }, + }, + + generate(data, relations, slots, {getColors, html, language}) { + const sectionName = + html.tag('span', {class: 'group-name'}, + (data.isDefaultTrackSection + ? language.$('albumSidebar.trackList.fallbackSectionName') + : data.name)); + + let colorStyle; + if (data.color) { + const {primary} = getColors(data.color); + colorStyle = {style: `--primary-color: ${primary}`}; + } + + const trackListItems = + relations.trackLinks.map((trackLink, index) => + html.tag('li', + data.includesCurrentTrack && + index === data.currentTrackIndex && + {class: 'current'}, + + slots.mode === 'commentary' && + data.tracksAreMissingCommentary[index] && + {class: 'no-commentary'}, + + language.$('albumSidebar.trackList.item', { + track: + (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index] + ? trackLink.slots({ + linkless: true, + }) + : slots.anchor + ? trackLink.slots({ + anchor: true, + hash: data.trackDirectories[index], + }) + : trackLink), + }))); + + return html.tag('details', + data.includesCurrentTrack && + {class: 'current'}, + + // Allow forcing open via a template slot. + // This isn't exactly janky, but the rest of this function + // kind of is when you contextualize it in a template... + slots.open && + {open: true}, + + // 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 && + {open: true}, + + [ + html.tag('summary', + colorStyle, + + 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 0000000..c8b123f --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,74 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateSocialEmbed', + 'generateAlbumSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language', 'urls'], + + relations(relation, album) { + return { + socialEmbed: + relation('generateSocialEmbed'), + + description: + relation('generateAlbumSocialEmbedDescription', album), + }; + }, + + 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; + + return data; + }, + + generate(data, relations, {absoluteTo, language, urls}) { + return relations.socialEmbed.slots({ + title: + language.$('albumPage.socialEmbed.title', { + album: data.albumName, + }), + + description: relations.description, + + headingContent: + (data.hasHeading + ? language.$('albumPage.socialEmbed.heading', { + group: data.headingGroupName, + }) + : null), + + headingLink: + (data.hasHeading + ? absoluteTo('localized.groupGallery', data.headingGroupDirectory) + : null), + + imagePath: + (data.hasImage + ? '/' + + urls + .from('shared.root') + .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension) + : null), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js new file mode 100644 index 0000000..7099616 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,48 @@ +import {accumulateSum} from '#sugar'; + +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 0000000..c5acf37 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,72 @@ +import {empty} from '#sugar'; + +export default { + extraDependencies: ['to'], + + data(album, track) { + const data = {}; + + data.hasWallpaper = !empty(album.wallpaperArtistContribs); + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasWallpaper) { + data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; + data.wallpaperStyle = album.wallpaperStyle; + } + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + data.albumDirectory = album.directory; + + if (track) { + data.trackDirectory = track.directory; + } + + return data; + }, + + generate(data, {to}) { + const indent = parts => + (parts ?? []) + .filter(Boolean) + .join('\n') + .split('\n') + .map(line => ' '.repeat(4) + line) + .join('\n'); + + const rule = (selector, parts) => + (!empty(parts.filter(Boolean)) + ? [`${selector} {`, indent(parts), `}`] + : []); + + const wallpaperRule = + data.hasWallpaper && + rule(`body::before`, [ + `background-image: url("${to(...data.wallpaperPath)}");`, + data.wallpaperStyle, + ]); + + const bannerRule = + data.hasBanner && + rule(`#banner img`, [ + data.bannerStyle, + ]); + + const dataRule = + rule(`:root`, [ + data.albumDirectory && + `--album-directory: ${data.albumDirectory};`, + data.trackDirectory && + `--track-directory: ${data.trackDirectory};`, + ]); + + return ( + [wallpaperRule, bannerRule, dataRule] + .filter(Boolean) + .flat() + .join('\n')); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 0000000..ee06b9e --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,181 @@ +import {accumulateSum, empty, stitchArrays} from '#sugar'; + +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.trackSectionItems = + album.trackSections.map(section => + section.tracks.map(track => + relation('generateAlbumTrackListItem', track, album))); + + break; + + case 'tracks': + relations.items = + 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.trackSectionNames = + album.trackSections + .map(section => section.name); + + data.trackSectionDurations = + album.trackSections + .map(section => + accumulateSum(section.tracks, track => track.duration)); + + data.trackSectionDurationsApproximate = + album.trackSections + .map(section => section.tracks.length > 1); + + if (album.hasTrackNumbers) { + data.trackSectionStartIndices = + album.trackSections + .map(section => section.startIndex); + } else { + data.trackSectionStartIndices = + album.trackSections + .map(() => null); + } + + break; + } + + return data; + }, + + slots: { + collapseDurationScope: { + validate: v => + v.is('never', 'track', 'section', 'album'), + + default: 'album', + }, + }, + + generate(data, relations, slots, {html, language}) { + const listTag = (data.hasTrackNumbers ? 'ol' : 'ul'); + + const slotItems = items => + items.map(item => + item.slots({ + collapseDurationScope: + slots.collapseDurationScope, + })); + + switch (data.displayMode) { + case 'trackSections': + return html.tag('dl', {class: 'album-group-list'}, + stitchArrays({ + heading: relations.trackSectionHeadings, + items: relations.trackSectionItems, + + name: data.trackSectionNames, + duration: data.trackSectionDurations, + durationApproximate: data.trackSectionDurationsApproximate, + startIndex: data.trackSectionStartIndices, + }).map(({ + heading, + items, + + name, + duration, + durationApproximate, + startIndex, + }) => [ + heading.slots({ + tag: 'dt', + title: + (duration === 0 + ? language.$('trackList.section', { + section: name, + }) + : language.$('trackList.section.withDuration', { + section: name, + duration: + language.formatDuration(duration, { + approximate: durationApproximate, + }), + })), + }), + + html.tag('dd', + html.tag(listTag, + data.hasTrackNumbers && + {start: startIndex + 1}, + + slotItems(items))), + ])); + + case 'tracks': + return html.tag(listTag, slotItems(relations.items)); + + default: + return html.blank(); + } + } +}; diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js new file mode 100644 index 0000000..1898074 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,133 @@ +import {compareArrays, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumTrackListMissingDuration', + 'linkContribution', + 'linkTrack', + ], + + extraDependencies: ['getColors', 'html', 'language'], + + query(track, album) { + const query = {}; + + query.duration = track.duration ?? 0; + + query.trackHasDuration = !!track.duration; + + query.sectionHasDuration = + !album.trackSections + .some(section => + section.tracks.every(track => !track.duration) && + section.tracks.includes(track)); + + query.albumHasDuration = + album.tracks.some(track => track.duration); + + return query; + }, + + relations(relation, query, track) { + const relations = {}; + + if (!empty(track.artistContribs)) { + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + } + + relations.trackLink = + relation('linkTrack', track); + + if (!query.trackHasDuration) { + relations.missingDuration = + relation('generateAlbumTrackListMissingDuration'); + } + + return relations; + }, + + data(query, track, album) { + const data = {}; + + data.duration = query.duration; + data.trackHasDuration = query.trackHasDuration; + data.sectionHasDuration = query.sectionHasDuration; + data.albumHasDuration = query.albumHasDuration; + + if (track.color !== album.color) { + data.color = track.color; + } + + data.showArtists = + !empty(track.artistContribs) && + (empty(album.artistContribs) || + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false})); + + return data; + }, + + slots: { + collapseDurationScope: { + validate: v => + v.is('never', 'track', 'section', 'album'), + + default: 'album', + }, + }, + + generate(data, relations, slots, {getColors, html, language}) { + let colorStyle; + if (data.color) { + const {primary} = getColors(data.color); + colorStyle = {style: `--primary-color: ${primary}`}; + } + + const parts = ['trackList.item']; + const options = {}; + + options.track = + relations.trackLink + .slot('color', false); + + const collapseDuration = + (slots.collapseDurationScope === 'track' + ? !data.trackHasDuration + : slots.collapseDurationScope === 'section' + ? !data.sectionHasDuration + : slots.collapseDurationScope === 'album' + ? !data.albumHasDuration + : false); + + if (!collapseDuration) { + parts.push('withDuration'); + + options.duration = + (data.trackHasDuration + ? language.$('trackList.item.withDuration.duration', { + duration: + language.formatDuration(data.duration), + }) + : relations.missingDuration); + } + + if (data.showArtists) { + parts.push('withArtists'); + options.by = + html.tag('span', {class: 'by'}, + html.metatag('chunkwrap', {split: ','}, + html.resolve( + language.$('trackList.item.withArtists.by', { + artists: language.formatConjunctionList(relations.contributionLinks), + })))); + } + + return html.tag('li', + colorStyle, + language.formatString(...parts, options)); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js new file mode 100644 index 0000000..6d4a6ec --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListMissingDuration.js @@ -0,0 +1,33 @@ +export default { + contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + }), + + generate: (relations, {html, language}) => + relations.textWithTooltip.slots({ + attributes: {class: 'missing-duration'}, + customInteractionCue: true, + + text: + language.$('trackList.item.withDuration.duration', { + duration: + html.tag('span', {class: 'text-with-tooltip-interaction-cue'}, + language.$('trackList.item.withDuration.duration.missing')), + }), + + tooltip: + relations.tooltip.slots({ + attributes: {class: 'missing-duration-tooltip'}, + + content: + language.$('trackList.item.withDuration.duration.missing.info'), + }), + }), +}; diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js new file mode 100644 index 0000000..338d18f --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -0,0 +1,153 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkArtTag', + 'linkTrack', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableListings: wikiInfo.enableListings, + }; + }, + + query(sprawl, tag) { + const things = tag.taggedInThings.slice(); + + sortAlbumsTracksChronologically(things, { + getDate: thing => thing.coverArtDate ?? thing.date, + latestFirst: true, + }); + + return {things}; + }, + + relations(relation, query, sprawl, tag) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artTagMainLink = + relation('linkArtTag', tag); + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = + query.things.map(thing => + (thing.album + ? relation('linkTrack', thing) + : relation('linkAlbum', thing))); + + relations.images = + query.things.map(thing => + relation('image', thing.artTags)); + + return relations; + }, + + data(query, sprawl, tag) { + const data = {}; + + data.enableListings = sprawl.enableListings; + + data.name = tag.name; + data.color = tag.color; + + data.numArtworks = query.things.length; + + data.names = + query.things.map(thing => thing.name); + + data.paths = + query.things.map(thing => + (thing.album + ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] + : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); + + data.dimensions = + query.things.map(thing => thing.coverArtDimensions); + + data.coverArtists = + query.things.map(thing => + thing.coverArtistContribs + .map(({who: artist}) => artist.name)); + + return data; + }, + + generate(data, relations, {html, language}) { + return relations.layout + .slots({ + title: + language.$('tagPage.title', { + tag: data.name, + }), + + headingMode: 'static', + + color: data.color, + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {class: 'quick-info'}, + language.$('tagPage.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, + dimensions: data.dimensions, + }).map(({image, path, dimensions}) => + image.slots({ + path, + dimensions, + })), + + info: + data.coverArtists.map(names => + (names === null + ? null + : language.$('misc.albumGrid.details.coverArtists', { + artists: language.formatUnitList(names), + }))), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + html: + language.$('tagPage.nav.tag', { + tag: relations.artTagMainLink, + }), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js new file mode 100644 index 0000000..36343c1 --- /dev/null +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -0,0 +1,140 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const things = [ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ]; + + sortAlbumsTracksChronologically(things, { + latestFirst: true, + getDate: thing => thing.coverArtDate ?? thing.date, + }); + + return {things}; + }, + + relations(relation, query, artist) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = + query.things.map(thing => + (thing.album + ? relation('linkTrack', thing) + : relation('linkAlbum', thing))); + + relations.images = + query.things.map(thing => + relation('image', thing.artTags)); + + return relations; + }, + + data(query, artist) { + const data = {}; + + data.name = artist.name; + + data.numArtworks = query.things.length; + + data.names = + query.things.map(thing => thing.name); + + data.paths = + query.things.map(thing => + (thing.album + ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] + : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); + + data.dimensions = + query.things.map(thing => thing.coverArtDimensions); + + data.otherCoverArtists = + query.things.map(thing => + (thing.coverArtistContribs.length > 1 + ? thing.coverArtistContribs + .filter(({who}) => who !== artist) + .map(({who}) => who.name) + : null)); + + 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, + dimensions: data.dimensions, + }).map(({image, path, dimensions}) => + image.slots({ + path, + dimensions, + })), + + info: + data.otherCoverArtists.map(names => + (names === null + ? null + : language.$('misc.albumGrid.details.otherCoverArtists', { + artists: language.formatUnitList(names), + }))), + }), + ], + + 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 0000000..1725d4b --- /dev/null +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -0,0 +1,224 @@ +import {empty, filterProperties, stitchArrays, unique} from '#sugar'; + +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 && track.originalReleaseTrack === null) { + groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration); + groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1); + } + } + } + + const groupsSortedByCount = + allGroupsOrdered + .slice() + .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', + mutable: false, + }, + + 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', {class: 'group-contributions-sort-button'}, + {href: '#'}, + + (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.groupDurationsApproximateSortedByDuration), + }).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 0000000..ac9209a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,311 @@ +import {empty, unique} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +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 + .map(link => link.slot('context', 'artist'))), + })), + + 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 0000000..0beeb27 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -0,0 +1,241 @@ +import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort'; +import {chunkByProperties, stitchArrays} from '#sugar'; + +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 processEntry = ({thing, type, track, album, contribs}) => ({ + thing: thing, + entry: { + type: type, + track: track, + album: album, + contribs: contribs, + date: thing.coverArtDate ?? thing.date, + }, + }); + + const processAlbumEntry = ({type, album, contribs}) => + processEntry({ + thing: album, + type: type, + track: null, + album: album, + contribs: contribs, + }); + + const processTrackEntry = ({type, track, contribs}) => + processEntry({ + thing: track, + type: type, + track: track, + album: track.album, + contribs: contribs, + }); + + const processAlbumEntries = ({type, albums, contribs}) => + stitchArrays({ + album: albums, + contribs: contribs, + }).map(entry => + processAlbumEntry({type, ...entry})); + + const processTrackEntries = ({type, tracks, contribs}) => + stitchArrays({ + track: tracks, + contribs: contribs, + }).map(entry => + processTrackEntry({type, ...entry})); + + const { + albumsAsCoverArtist, + albumsAsWallpaperArtist, + albumsAsBannerArtist, + tracksAsCoverArtist, + } = artist; + + const albumsAsCoverArtistContribs = + albumsAsCoverArtist + .map(album => album.coverArtistContribs); + + const albumsAsWallpaperArtistContribs = + albumsAsWallpaperArtist + .map(album => album.wallpaperArtistContribs); + + const albumsAsBannerArtistContribs = + albumsAsBannerArtist + .map(album => album.bannerArtistContribs); + + const tracksAsCoverArtistContribs = + tracksAsCoverArtist + .map(track => track.coverArtistContribs); + + const albumsAsCoverArtistEntries = + processAlbumEntries({ + type: 'albumCover', + albums: albumsAsCoverArtist, + contribs: albumsAsCoverArtistContribs, + }); + + const albumsAsWallpaperArtistEntries = + processAlbumEntries({ + type: 'albumWallpaper', + albums: albumsAsWallpaperArtist, + contribs: albumsAsWallpaperArtistContribs, + }); + + const albumsAsBannerArtistEntries = + processAlbumEntries({ + type: 'albumBanner', + albums: albumsAsBannerArtist, + contribs: albumsAsBannerArtistContribs, + }); + + const tracksAsCoverArtistEntries = + processTrackEntries({ + type: 'trackCover', + tracks: tracksAsCoverArtist, + contribs: tracksAsCoverArtistContribs, + }); + + const entries = [ + ...albumsAsCoverArtistEntries, + ...albumsAsWallpaperArtistEntries, + ...albumsAsBannerArtistEntries, + ...tracksAsCoverArtistEntries, + ]; + + sortEntryThingPairs(entries, + things => sortAlbumsTracksChronologically(things, { + getDate: thing => thing.coverArtDate ?? thing.date, + })); + + 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, + annotation: 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 0000000..4094391 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -0,0 +1,91 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + mode: { + validate: v => v.is('flash', 'album'), + }, + + albumLink: { + type: 'html', + mutable: false, + }, + + flashActLink: { + type: 'html', + mutable: false, + }, + + items: { + type: 'html', + mutable: false, + }, + + date: {validate: v => v.isDate}, + dateRangeStart: {validate: v => v.isDate}, + dateRangeEnd: {validate: v => v.isDate}, + + duration: {validate: v => v.isDuration}, + durationApproximate: {type: 'boolean'}, + }, + + 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, 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.dateRangeStart ?? slots.date); + } + + accentedLink = language.formatString(...parts, 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 0000000..b6f4072 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -0,0 +1,60 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + content: { + type: 'html', + mutable: false, + }, + + annotation: { + type: 'html', + mutable: false, + }, + + otherArtistLinks: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + 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 (!html.isBlank(slots.annotation)) { + parts.push('withAnnotation'); + options.annotation = slots.annotation; + } + + if (parts.length === 1) { + break accent; + } + + accentedContent = language.formatString(...parts, options); + } + + return ( + html.tag('li', + slots.rerelease && {class: 'rerelease'}, + accentedContent)); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js new file mode 100644 index 0000000..8503d01 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -0,0 +1,23 @@ +export default { + extraDependencies: ['html'], + + slots: { + groupInfo: { + type: 'html', + mutable: false, + }, + + chunks: { + type: 'html', + mutable: false, + }, + }, + + 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 0000000..133095e --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -0,0 +1,269 @@ +import {chunkByProperties, stitchArrays} from '#sugar'; + +import { + sortAlbumsTracksChronologically, + sortByDate, + sortEntryThingPairs, +} from '#sort'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkFlash', + 'linkFlashAct', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const processEntry = ({ + thing, + entry, + + chunkType, + itemType, + + album = null, + track = null, + flashAct = null, + flash = null, + }) => ({ + thing: thing, + entry: { + chunkType, + itemType, + + album, + track, + flashAct, + flash, + + annotation: entry.annotation, + }, + }); + + const processAlbumEntry = ({thing: album, entry}) => + processEntry({ + thing: album, + entry: entry, + + chunkType: 'album', + itemType: 'album', + + album: album, + track: null, + }); + + const processTrackEntry = ({thing: track, entry}) => + processEntry({ + thing: track, + entry: entry, + + chunkType: 'album', + itemType: 'track', + + album: track.album, + track: track, + }); + + const processFlashEntry = ({thing: flash, entry}) => + processEntry({ + thing: flash, + entry: entry, + + chunkType: 'flash-act', + itemType: 'flash', + + flashAct: flash.act, + flash: flash, + }); + + const processEntries = ({things, processEntry}) => + things + .flatMap(thing => + thing.commentary + .filter(entry => entry.artists.includes(artist)) + .map(entry => processEntry({thing, entry}))); + + const processAlbumEntries = ({albums}) => + processEntries({ + things: albums, + processEntry: processAlbumEntry, + }); + + const processTrackEntries = ({tracks}) => + processEntries({ + things: tracks, + processEntry: processTrackEntry, + }); + + const processFlashEntries = ({flashes}) => + processEntries({ + things: flashes, + processEntry: processFlashEntry, + }); + + const { + albumsAsCommentator, + tracksAsCommentator, + flashesAsCommentator, + } = artist; + + const albumEntries = + processAlbumEntries({ + albums: albumsAsCommentator, + }); + + const trackEntries = + processTrackEntries({ + tracks: tracksAsCommentator, + }); + + const flashEntries = + processFlashEntries({ + flashes: flashesAsCommentator, + }) + + const albumTrackEntries = + sortEntryThingPairs( + [...albumEntries, ...trackEntries], + sortAlbumsTracksChronologically); + + const allEntries = + sortEntryThingPairs( + [...albumTrackEntries, ...flashEntries], + sortByDate); + + const chunks = + chunkByProperties( + allEntries.map(({entry}) => entry), + ['chunkType', 'album', 'flashAct']); + + return {chunks}; + }, + + relations: (relation, query) => ({ + chunks: + query.chunks + .map(() => relation('generateArtistInfoPageChunk')), + + chunkLinks: + query.chunks + .map(({chunkType, album, flashAct}) => + (chunkType === 'album' + ? relation('linkAlbum', album) + : chunkType === 'flash-act' + ? relation('linkFlashAct', flashAct) + : null)), + + items: + query.chunks + .map(({chunk}) => chunk + .map(() => relation('generateArtistInfoPageChunkItem'))), + + itemLinks: + query.chunks + .map(({chunk}) => chunk + .map(({track, flash}) => + (track + ? relation('linkTrack', track) + : flash + ? relation('linkFlash', flash) + : null))), + + itemAnnotations: + query.chunks + .map(({chunk}) => chunk + .map(({annotation}) => + (annotation + ? relation('transformContent', annotation) + : null))), + }), + + data: (query) => ({ + chunkTypes: + query.chunks + .map(({chunkType}) => chunkType), + + itemTypes: + query.chunks + .map(({chunk}) => chunk + .map(({itemType}) => itemType)), + }), + + generate: (data, relations, {html, language}) => + html.tag('dl', + stitchArrays({ + chunk: relations.chunks, + chunkLink: relations.chunkLinks, + chunkType: data.chunkTypes, + + items: relations.items, + itemLinks: relations.itemLinks, + itemAnnotations: relations.itemAnnotations, + itemTypes: data.itemTypes, + }).map(({ + chunk, + chunkLink, + chunkType, + + items, + itemLinks, + itemAnnotations, + itemTypes, + }) => + (chunkType === 'album' + ? chunk.slots({ + mode: 'album', + albumLink: chunkLink, + items: + stitchArrays({ + item: items, + link: itemLinks, + annotation: itemAnnotations, + type: itemTypes, + }).map(({item, link, annotation, type}) => + item.slots({ + annotation: + (annotation + ? annotation.slot('mode', 'inline') + : null), + + content: + (type === 'album' + ? html.tag('i', + language.$('artistPage.creditList.entry.album.commentary')) + : language.$('artistPage.creditList.entry.track', { + track: link, + })), + })), + }) + : chunkType === 'flash-act' + ? chunk.slots({ + mode: 'flash', + flashActLink: chunkLink, + items: + stitchArrays({ + item: items, + link: itemLinks, + annotation: itemAnnotations, + }).map(({item, link, annotation}) => + item.slots({ + annotation: + (annotation + ? annotation.slot('mode', 'inline') + : null), + + content: + language.$('artistPage.creditList.entry.flash', { + flash: link, + }), + })), + }) + : null))), +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js new file mode 100644 index 0000000..88a97af --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -0,0 +1,149 @@ +import {sortEntryThingPairs, sortFlashesChronologically} from '#sort'; +import {chunkByProperties, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'linkFlash', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const processFlashEntry = ({flash, contribs}) => ({ + thing: flash, + entry: { + flash: flash, + act: flash.act, + contribs: contribs, + }, + }); + + const processFlashEntries = ({flashes, contribs}) => + stitchArrays({ + flash: flashes, + contribs: contribs, + }).map(processFlashEntry); + + const {flashesAsContributor} = artist; + + const flashesAsContributorContribs = + flashesAsContributor + .map(flash => flash.contributorContribs); + + const flashesAsContributorEntries = + processFlashEntries({ + flashes: flashesAsContributor, + contribs: flashesAsContributorContribs, + }); + + const entries = [ + ...flashesAsContributorEntries, + ]; + + 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.at(-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({ + annotation: 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 0000000..dea7742 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -0,0 +1,23 @@ +import {empty} from '#sugar'; + +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 0000000..f003779 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -0,0 +1,293 @@ +import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort'; +import {accumulateSum, chunkByProperties, empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(artist) { + const processTrackEntry = ({track, contribs}) => ({ + thing: track, + entry: { + track: track, + album: track.album, + date: track.date, + contribs: contribs, + }, + }); + + const processTrackEntries = ({tracks, contribs}) => + stitchArrays({ + track: tracks, + contribs: contribs, + }).map(processTrackEntry); + + const {tracksAsArtist, tracksAsContributor} = artist; + + const tracksAsArtistAndContributor = + tracksAsArtist + .filter(track => tracksAsContributor.includes(track)); + + const tracksAsArtistOnly = + tracksAsArtist + .filter(track => !tracksAsContributor.includes(track)); + + const tracksAsContributorOnly = + tracksAsContributor + .filter(track => !tracksAsArtist.includes(track)); + + const tracksAsArtistAndContributorContribs = + tracksAsArtistAndContributor + .map(track => [ + ... + track.artistContribs + .map(contrib => ({...contrib, kind: 'artist'})), + ... + track.contributorContribs + .map(contrib => ({...contrib, kind: 'contributor'})), + ]); + + const tracksAsArtistOnlyContribs = + tracksAsArtistOnly + .map(track => track.artistContribs + .map(contrib => ({...contrib, kind: 'artist'}))); + + const tracksAsContributorOnlyContribs = + tracksAsContributorOnly + .map(track => track.contributorContribs + .map(contrib => ({...contrib, kind: 'contributor'}))); + + const tracksAsArtistAndContributorEntries = + processTrackEntries({ + tracks: tracksAsArtistAndContributor, + contribs: tracksAsArtistAndContributorContribs, + }); + + const tracksAsArtistOnlyEntries = + processTrackEntries({ + tracks: tracksAsArtistOnly, + contribs: tracksAsArtistOnlyContribs, + }); + + const tracksAsContributorOnlyEntries = + processTrackEntries({ + tracks: tracksAsContributorOnly, + contribs: tracksAsContributorOnlyContribs, + }); + + const entries = [ + ...tracksAsArtistAndContributorEntries, + ...tracksAsArtistOnlyEntries, + ...tracksAsContributorOnlyEntries, + ]; + + 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.filter(({who}) => who === artist)) + .map(ownContribs => ({ + creditedAsArtist: + ownContribs + .some(({kind}) => kind === 'artist'), + + creditedAsContributor: + ownContribs + .some(({kind}) => kind === 'contributor'), + + annotatedContribs: + ownContribs + .filter(({what}) => what), + })) + .map(({annotatedContribs, ...rest}) => ({ + ...rest, + + annotatedArtistContribs: + annotatedContribs + .filter(({kind}) => kind === 'artist'), + + annotatedContributorContribs: + annotatedContribs + .filter(({kind}) => kind === 'contributor'), + })) + .map(({ + creditedAsArtist, + creditedAsContributor, + annotatedArtistContribs, + annotatedContributorContribs, + }) => { + // Don't display annotations associated with crediting in the + // Contributors field if the artist is also credited as an Artist + // *and* the Artist-field contribution is non-annotated. This is + // so that we don't misrepresent the artist - the contributor + // annotation tends to be for "secondary" and performance roles. + // For example, this avoids crediting Marcy Nabors on Renewed + // Return seemingly only for "bass clarinet" when they're also + // the one who composed and arranged Renewed Return! + if ( + creditedAsArtist && + creditedAsContributor && + empty(annotatedArtistContribs) + ) { + return []; + } + + return [ + ...annotatedArtistContribs, + ...annotatedContributorContribs, + ]; + }) + .map(contribs => + contribs.map(({what}) => what)) + .map(contributions => + (empty(contributions) + ? null + : contributions))), + + trackRereleases: + query.chunks.map(({chunk}) => + chunk.map(({track}) => track.originalReleaseTrack !== null)), + }; + }, + + generate(data, relations, {html, 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, + rerelease, + + annotation: + (contribution + ? language.formatUnitList(contribution) + : html.blank()), + + 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 0000000..aa95dba --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,100 @@ +import {empty} from '#sugar'; + +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 0000000..15eb08e --- /dev/null +++ b/src/content/dependencies/generateBanner.js @@ -0,0 +1,33 @@ +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}) => + html.tag('div', {id: 'banner'}, + html.tag('img', + {src: to(...slots.path)}, + + (slots.dimensions + ? {width: slots.dimensions[0]} + : {width: 1100}), + + (slots.dimensions + ? {height: slots.dimensions[1]} + : {height: 200}), + + slots.alt && + {alt: slots.alt})), +}; diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js new file mode 100644 index 0000000..8ec6ee0 --- /dev/null +++ b/src/content/dependencies/generateChronologyLinks.js @@ -0,0 +1,82 @@ +import {accumulateSum, empty} from '#sugar'; + +export default { + extraDependencies: ['html', 'language'], + + slots: { + chronologyInfoSets: { + validate: v => + v.strictArrayOf( + v.validateProperties({ + headingString: v.isString, + contributions: v.strictArrayOf(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({ + tooltipStyle: 'browser', + color: false, + content: language.$('misc.nav.previous'), + }), + + nextLink?.slots({ + tooltipStyle: 'browser', + 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/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js new file mode 100644 index 0000000..03d95ac --- /dev/null +++ b/src/content/dependencies/generateColorStyleAttribute.js @@ -0,0 +1,37 @@ +export default { + contentDependencies: ['generateColorStyleVariables'], + extraDependencies: ['html'], + + relations: (relation) => ({ + colorVariables: + relation('generateColorStyleVariables'), + }), + + data: (color) => ({ + color: + color ?? null, + }), + + slots: { + color: { + validate: v => v.isColor, + }, + + context: { + validate: v => v.is( + 'any-content', + 'image-box', + 'primary-only'), + + default: 'any-content', + }, + }, + + generate: (data, relations, slots) => ({ + style: + relations.colorVariables.slots({ + color: slots.color ?? data.color, + context: slots.context, + }).content, + }), +}; diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js new file mode 100644 index 0000000..c412b8f --- /dev/null +++ b/src/content/dependencies/generateColorStyleRules.js @@ -0,0 +1,42 @@ +export default { + contentDependencies: ['generateColorStyleVariables'], + extraDependencies: ['html'], + + relations: (relation) => ({ + variables: + relation('generateColorStyleVariables'), + }), + + data: (color) => ({ + color: + color ?? null, + }), + + slots: { + color: { + validate: v => v.isColor, + }, + }, + + generate(data, relations, slots) { + const color = data.color ?? slots.color; + + if (!color) { + return ''; + } + + return [ + `:root {`, + ...( + relations.variables + .slots({ + color, + context: 'page-root', + mode: 'property-list', + }) + .content + .map(line => line + ';')), + `}`, + ].join('\n'); + }, +}; diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js new file mode 100644 index 0000000..069d85d --- /dev/null +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -0,0 +1,89 @@ +export default { + extraDependencies: ['html', 'getColors'], + + slots: { + color: { + validate: v => v.isColor, + }, + + context: { + validate: v => v.is( + 'any-content', + 'image-box', + 'page-root', + 'image-box', + 'primary-only'), + + default: 'any-content', + }, + + mode: { + validate: v => v.is('style', 'property-list'), + default: 'style', + }, + }, + + generate(slots, {getColors}) { + if (!slots.color) return []; + + const { + primary, + dark, + dim, + deep, + deepGhost, + bg, + bgBlack, + shadow, + } = getColors(slots.color); + + let anyContent = [ + `--primary-color: ${primary}`, + `--dark-color: ${dark}`, + `--dim-color: ${dim}`, + `--deep-color: ${deep}`, + `--deep-ghost-color: ${deepGhost}`, + `--bg-color: ${bg}`, + `--bg-black-color: ${bgBlack}`, + `--shadow-color: ${shadow}`, + ]; + + let selectedProperties; + + switch (slots.context) { + case 'any-content': + selectedProperties = anyContent; + break; + + case 'image-box': + selectedProperties = [ + `--primary-color: ${primary}`, + `--dim-color: ${dim}`, + `--deep-color: ${deep}`, + `--bg-black-color: ${bgBlack}`, + ]; + break; + + case 'page-root': + selectedProperties = [ + ...anyContent, + `--page-primary-color: ${primary}`, + ]; + break; + + case 'primary-only': + selectedProperties = [ + `--primary-color: ${primary}`, + ]; + break; + } + + switch (slots.mode) { + case 'style': + return selectedProperties.join('; '); + + case 'property-list': + return selectedProperties; + } + }, +}; diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js new file mode 100644 index 0000000..522a028 --- /dev/null +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -0,0 +1,98 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'linkArtist', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + artistLinks: + (!empty(entry.artists) && !entry.artistDisplayText + ? entry.artists + .map(artist => relation('linkArtist', artist)) + : null), + + artistsContent: + (entry.artistDisplayText + ? relation('transformContent', entry.artistDisplayText) + : null), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + bodyContent: + (entry.body + ? relation('transformContent', entry.body) + : null), + + colorStyle: + relation('generateColorStyleAttribute'), + }), + + data: (entry) => ({ + date: entry.date, + }), + + slots: { + color: {validate: v => v.isColor}, + }, + + generate(data, relations, slots, {html, language}) { + const artistsSpan = + html.tag('span', {class: 'commentary-entry-artists'}, + (relations.artistsContent + ? relations.artistsContent.slot('mode', 'inline') + : relations.artistLinks + ? language.formatConjunctionList(relations.artistLinks) + : language.$('misc.artistCommentary.entry.title.noArtists'))); + + const accentParts = ['misc.artistCommentary.entry.title.accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (data.date) { + accentParts.push('withDate'); + accentOptions.date = + language.formatDate(data.date); + } + + const accent = + (accentParts.length > 1 + ? html.tag('span', {class: 'commentary-entry-accent'}, + language.$(...accentParts, accentOptions)) + : null); + + const titleParts = ['misc.artistCommentary.entry.title']; + const titleOptions = {artists: artistsSpan}; + + if (accent) { + titleParts.push('withAccent'); + titleOptions.accent = accent; + } + + const style = + slots.color && + relations.colorStyle.slot('color', slots.color); + + return html.tags([ + html.tag('p', {class: 'commentary-entry-heading'}, + style, + language.$(...titleParts, titleOptions)), + + html.tag('blockquote', {class: 'commentary-entry-body'}, + style, + relations.bodyContent.slot('mode', 'multiline')), + ]); + }, +}; diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js new file mode 100644 index 0000000..3c3504d --- /dev/null +++ b/src/content/dependencies/generateCommentaryIndexPage.js @@ -0,0 +1,102 @@ +import {sortChronologically} from '#sort'; +import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generatePageLayout', 'linkAlbumCommentary'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query(sprawl) { + const query = {}; + + query.albums = + sortChronologically(sprawl.albumData.slice()); + + const entries = + query.albums.map(album => + [album, ...album.tracks] + .filter(({commentary}) => commentary) + .flatMap(({commentary}) => commentary)); + + query.wordCounts = + entries.map(entries => + accumulateSum( + entries, + entry => entry.body.split(' ').length)); + + query.entryCounts = + entries.map(entries => entries.length); + + filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts, + (album, wordCount, entryCount) => entryCount >= 1); + + return query; + }, + + relations(relation, query) { + return { + layout: + relation('generatePageLayout'), + + albumLinks: + query.albums + .map(album => relation('linkAlbumCommentary', album)), + }; + }, + + data(query) { + return { + wordCounts: query.wordCounts, + entryCounts: query.entryCounts, + + totalWordCount: accumulateSum(query.wordCounts), + totalEntryCount: accumulateSum(query.entryCounts), + }; + }, + + generate(data, relations, {html, language}) { + return relations.layout.slots({ + title: language.$('commentaryIndex.title'), + + headingMode: 'static', + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', language.$('commentaryIndex.infoLine', { + words: + html.tag('b', + language.formatWordCount(data.totalWordCount, {unit: true})), + + entries: + html.tag('b', + language.countCommentaryEntries(data.totalEntryCount, {unit: true})), + })), + + html.tag('p', + language.$('commentaryIndex.albumList.title')), + + html.tag('ul', + stitchArrays({ + albumLink: relations.albumLinks, + wordCount: data.wordCounts, + entryCount: data.entryCounts, + }).map(({albumLink, wordCount, entryCount}) => + html.tag('li', + language.$('commentaryIndex.albumList.item', { + album: albumLink, + words: language.formatWordCount(wordCount, {unit: true}), + entries: language.countCommentaryEntries(entryCount, {unit: true}), + })))), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js new file mode 100644 index 0000000..8ae1b2d --- /dev/null +++ b/src/content/dependencies/generateCommentarySection.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: [ + 'transformContent', + 'generateCommentaryEntry', + 'generateContentHeading', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + entries: + entries.map(entry => + relation('generateCommentaryEntry', entry)), + }), + + generate: (relations, {html, language}) => + html.tags([ + relations.heading + .slots({ + id: 'artist-commentary', + title: language.$('misc.artistCommentary') + }), + + relations.entries, + ]), +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 0000000..469db87 --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,45 @@ +export default { + extraDependencies: ['html'], + contentDependencies: ['generateColorStyleAttribute'], + + relations: (relation) => ({ + colorStyle: relation('generateColorStyleAttribute'), + }), + + slots: { + title: { + type: 'html', + mutable: false, + }, + + accent: { + type: 'html', + mutable: false, + }, + + color: {validate: v => v.isColor}, + + id: {type: 'string'}, + tag: {type: 'string', default: 'p'}, + }, + + generate: (relations, slots, {html}) => + html.tag(slots.tag, {class: 'content-heading'}, + {tabindex: '0'}, + + slots.id && + {id: slots.id}, + + slots.color && + relations.colorStyle.slot('color', slots.color), + + [ + html.tag('span', {class: 'content-heading-main-title'}, + {[html.onlyIfContent]: true}, + slots.title), + + html.tag('span', {class: 'content-heading-accent'}, + {[html.onlyIfContent]: true}, + slots.accent), + ]), +} diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js new file mode 100644 index 0000000..6401e65 --- /dev/null +++ b/src/content/dependencies/generateContributionList.js @@ -0,0 +1,21 @@ +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html'], + + relations: (relation, contributions) => + ({contributionLinks: + contributions + .map(contrib => relation('linkContribution', contrib))}), + + generate: (relations, {html}) => + html.tag('ul', + relations.contributionLinks.map(contributionLink => + html.tag('li', + contributionLink + .slots({ + showIcons: true, + showContribution: true, + preventWrapping: false, + iconMode: 'tooltip', + })))), +}; diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js new file mode 100644 index 0000000..90c9db9 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,132 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['image', 'linkArtTag'], + extraDependencies: ['html'], + + query: (artTags) => ({ + linkableArtTags: + (artTags + ? artTags.filter(tag => !tag.isContentWarning) + : []), + }), + + relations: (relation, query, artTags) => ({ + image: + relation('image', artTags), + + tagLinks: + query.linkableArtTags + .filter(tag => !tag.isContentWarning) + .map(tag => relation('linkArtTag', tag)), + }), + + data: (query) => { + const data = {}; + + const seenShortNames = new Set(); + const duplicateShortNames = new Set(); + + for (const {nameShort: shortName} of query.linkableArtTags) { + if (seenShortNames.has(shortName)) { + duplicateShortNames.add(shortName); + } else { + seenShortNames.add(shortName); + } + } + + data.preferShortName = + query.linkableArtTags + .map(artTag => !duplicateShortNames.has(artTag.nameShort)); + + return data; + }, + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + + color: { + validate: v => v.isColor, + }, + + mode: { + validate: v => v.is('primary', 'thumbnail', 'commentary'), + default: 'primary', + }, + + dimensions: { + validate: v => v.isDimensions, + }, + }, + + generate(data, relations, slots, {html}) { + const square = + (slots.dimensions + ? slots.dimensions[0] === slots.dimensions[1] + : true); + + const sizeSlots = + (square + ? {square: true} + : {dimensions: slots.dimensions}); + + switch (slots.mode) { + case 'primary': + return html.tags([ + relations.image.slots({ + path: slots.path, + alt: slots.alt, + color: slots.color, + thumb: 'medium', + reveal: true, + link: true, + ...sizeSlots, + }), + + !empty(relations.tagLinks) && + html.tag('ul', {class: 'image-details'}, + stitchArrays({ + tagLink: relations.tagLinks, + preferShortName: data.preferShortName, + }).map(({tagLink, preferShortName}) => + html.tag('li', + tagLink.slot('preferShortName', preferShortName)))), + ]); + + case 'thumbnail': + return relations.image.slots({ + path: slots.path, + alt: slots.alt, + color: slots.color, + thumb: 'small', + reveal: false, + link: false, + ...sizeSlots, + }); + + case 'commentary': + return relations.image.slots({ + path: slots.path, + alt: slots.alt, + color: slots.color, + thumb: 'medium', + reveal: true, + link: true, + lazy: true, + ...sizeSlots, + + attributes: + {class: 'commentary-art'}, + }); + + default: + return html.blank(); + } + }, +}; diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js new file mode 100644 index 0000000..69220da --- /dev/null +++ b/src/content/dependencies/generateCoverCarousel.js @@ -0,0 +1,66 @@ +import {empty, repeat, stitchArrays} from '#sugar'; +import {getCarouselLayoutForNumberOfItems} from '#wiki-data'; + +export default { + contentDependencies: ['generateGridActionLinks'], + extraDependencies: ['html'], + + relations(relation) { + return { + actionLinks: relation('generateGridActionLinks'), + }; + }, + + slots: { + images: {validate: v => v.strictArrayOf(v.isHTML)}, + links: {validate: v => v.strictArrayOf(v.isHTML)}, + + lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, + actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + }, + + generate(relations, slots, {html}) { + const stitched = + stitchArrays({ + image: slots.images, + link: slots.links, + }); + + if (empty(stitched)) { + return; + } + + const layout = getCarouselLayoutForNumberOfItems(stitched.length); + + return html.tags([ + 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), + }), + })))), + ])), + + relations.actionLinks + .slot('actionLinks', slots.actionLinks), + ]); + }, +}; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js new file mode 100644 index 0000000..0433aaf --- /dev/null +++ b/src/content/dependencies/generateCoverGrid.js @@ -0,0 +1,59 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateGridActionLinks'], + extraDependencies: ['html', 'language'], + + relations(relation) { + return { + actionLinks: relation('generateGridActionLinks'), + }; + }, + + slots: { + images: {validate: v => v.strictArrayOf(v.isHTML)}, + links: {validate: v => v.strictArrayOf(v.isHTML)}, + names: {validate: v => v.strictArrayOf(v.isHTML)}, + info: {validate: v => v.strictArrayOf(v.isHTML)}, + + lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, + actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + }, + + generate(relations, slots, {html, language}) { + 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']}, + colorContext: 'image-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}, + language.sanitize(name)), + + html.tag('span', {[html.onlyIfContent]: true}, + language.sanitize(info)), + ], + })), + + relations.actionLinks + .slot('actionLinks', slots.actionLinks), + ])); + }, +}; diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js new file mode 100644 index 0000000..d9ed036 --- /dev/null +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -0,0 +1,38 @@ +export default { + contentDependencies: ['generateTextWithTooltip'], + extraDependencies: ['html'], + + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + }), + + slots: { + mainContent: { + type: 'html', + mutable: false, + }, + + tooltip: { + type: 'html', + mutable: true, + }, + + datetime: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + relations.textWithTooltip.slots({ + attributes: {class: 'datetimestamp'}, + + text: + html.tag('time', + {datetime: slots.datetime}, + slots.mainContent), + + tooltip: + slots.tooltip?.slots({ + attributes: [{class: 'datetimestamp-tooltip'}], + }), + }), +}; diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js new file mode 100644 index 0000000..1707812 --- /dev/null +++ b/src/content/dependencies/generateFlashActGalleryPage.js @@ -0,0 +1,91 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCoverGrid', + 'generateFlashActNavAccent', + 'generateFlashActSidebar', + 'generatePageLayout', + 'image', + 'linkFlash', + 'linkFlashIndex', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, act) => ({ + layout: + relation('generatePageLayout'), + + flashIndexLink: + relation('linkFlashIndex'), + + flashActNavAccent: + relation('generateFlashActNavAccent', act), + + sidebar: + relation('generateFlashActSidebar', act, null), + + coverGrid: + relation('generateCoverGrid'), + + coverGridImages: + act.flashes + .map(_flash => relation('image')), + + flashLinks: + act.flashes + .map(flash => relation('linkFlash', flash)), + }), + + data: (act) => ({ + name: act.name, + color: act.color, + + flashNames: + act.flashes.map(flash => flash.name), + + flashCoverPaths: + act.flashes.map(flash => + ['media.flashArt', flash.directory, flash.coverArtFileExtension]) + }), + + generate(data, relations, {html, language}) { + return relations.layout.slots({ + title: + language.$('flashPage.title', { + flash: new html.Tag(null, null, data.name), + }), + + color: data.color, + headingMode: 'static', + + mainClasses: ['flash-index'], + mainContent: [ + relations.coverGrid.slots({ + links: relations.flashLinks, + names: data.flashNames, + lazy: 6, + + images: + stitchArrays({ + image: relations.coverGridImages, + path: data.flashCoverPaths, + }).map(({image, path}) => + image.slot('path', path)), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.flashIndexLink}, + {auto: 'current'}, + ], + + navBottomRowContent: relations.flashActNavAccent, + + leftSidebar: relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js new file mode 100644 index 0000000..424948f --- /dev/null +++ b/src/content/dependencies/generateFlashActNavAccent.js @@ -0,0 +1,71 @@ +import {atOffset, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkFlashAct', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({flashActData}) { + return {flashActData}; + }, + + query(sprawl, flashAct) { + // Like with generateFlashNavAccent, don't sort chronologically here. + const flashActs = + sprawl.flashActData; + + const index = + flashActs.indexOf(flashAct); + + const previousFlashAct = + atOffset(flashActs, index, -1); + + const nextFlashAct = + atOffset(flashActs, index, +1); + + return {previousFlashAct, nextFlashAct}; + }, + + relations(relation, query) { + const relations = {}; + + if (query.previousFlashAct || query.nextFlashAct) { + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.previousFlashActLink = + (query.previousFlashAct + ? relation('linkFlashAct', query.previousFlashAct) + : null); + + relations.nextFlashActLink = + (query.nextFlashAct + ? relation('linkFlashAct', query.nextFlashAct) + : null); + } + + return relations; + }, + + generate(relations, {html, language}) { + const {content: previousNextLinks = []} = + relations.previousNextLinks && + relations.previousNextLinks.slots({ + previousLink: relations.previousFlashActLink, + nextLink: relations.nextFlashActLink, + }); + + const allLinks = [ + ...previousNextLinks, + ].filter(Boolean); + + if (empty(allLinks)) { + return html.blank(); + } + + return `(${language.formatUnitList(allLinks)})`; + }, +}; diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js new file mode 100644 index 0000000..1421dde --- /dev/null +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -0,0 +1,30 @@ +export default { + contentDependencies: [ + 'generateFlashActSidebarCurrentActBox', + 'generateFlashActSidebarSideMapBox', + 'generatePageSidebar', + ], + + relations: (relation, act, flash) => ({ + sidebar: + relation('generatePageSidebar'), + + currentActBox: + relation('generateFlashActSidebarCurrentActBox', act, flash), + + sideMapBox: + relation('generateFlashActSidebarSideMapBox', act, flash), + }), + + data: (_act, flash) => ({ + isFlashActPage: !flash, + }), + + generate: (data, relations) => + relations.sidebar.slots({ + boxes: + (data.isFlashActPage + ? [relations.sideMapBox, relations.currentActBox] + : [relations.currentActBox, relations.sideMapBox]), + }), +}; diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js new file mode 100644 index 0000000..c5426a4 --- /dev/null +++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js @@ -0,0 +1,63 @@ +export default { + contentDependencies: [ + 'generatePageSidebarBox', + 'linkFlash', + 'linkFlashAct', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, act, _flash) => ({ + box: + relation('generatePageSidebarBox'), + + actLink: + relation('linkFlashAct', act), + + flashLinks: + act.flashes + .map(flash => relation('linkFlash', flash)), + }), + + data: (act, flash) => ({ + isFlashActPage: + !flash, + + currentFlashIndex: + act.flashes.indexOf(flash), + + customListTerminology: + act.listTerminology, + }), + + generate: (data, relations, {html, language}) => + relations.box.slots({ + attributes: {class: 'flash-act-map-sidebar-box'}, + + content: [ + html.tag('h1', relations.actLink), + + html.tag('details', + (data.isFlashActPage + ? {} + : {class: 'current', open: true}), + + [ + html.tag('summary', + html.tag('span', {class: 'group-name'}, + (data.customListTerminology + ? language.sanitize(data.customListTerminology) + : language.$('flashSidebar.flashList.entriesInThisSection')))), + + html.tag('ul', + relations.flashLinks + .map((flashLink, index) => + html.tag('li', + index === data.currentFlashIndex && + {class: 'current'}, + + flashLink))), + ]), + ], + }), +}; diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js new file mode 100644 index 0000000..3d261ec --- /dev/null +++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js @@ -0,0 +1,85 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generatePageSidebarBox', + 'linkFlashAct', + 'linkFlashIndex', + ], + + extraDependencies: ['html', 'wikiData'], + + sprawl: ({flashSideData}) => ({flashSideData}), + + relations: (relation, sprawl, _act, _flash) => ({ + box: + relation('generatePageSidebarBox'), + + flashIndexLink: + relation('linkFlashIndex'), + + sideColorStyles: + sprawl.flashSideData + .map(side => relation('generateColorStyleAttribute', side.color)), + + sideActLinks: + sprawl.flashSideData + .map(side => side.acts + .map(act => relation('linkFlashAct', act))), + }), + + data: (sprawl, act, flash) => ({ + isFlashActPage: + !flash, + + sideNames: + sprawl.flashSideData + .map(side => side.name), + + currentSideIndex: + sprawl.flashSideData.indexOf(act.side), + + currentActIndex: + act.side.acts.indexOf(act), + }), + + generate: (data, relations, {html}) => + relations.box.slots({ + attributes: {class: 'flash-act-map-sidebar-box'}, + + content: [ + html.tag('h1', relations.flashIndexLink), + + stitchArrays({ + sideName: data.sideNames, + sideColorStyle: relations.sideColorStyles, + actLinks: relations.sideActLinks, + }).map(({sideName, sideColorStyle, actLinks}, sideIndex) => + html.tag('details', + sideIndex === data.currentSideIndex && + {class: 'current'}, + + data.isFlashActPage && + sideIndex === data.currentSideIndex && + {open: true}, + + sideColorStyle.slot('context', 'primary-only'), + + [ + html.tag('summary', + html.tag('span', {class: 'group-name'}, + sideName)), + + html.tag('ul', + actLinks.map((actLink, actIndex) => + html.tag('li', + sideIndex === data.currentSideIndex && + actIndex === data.currentActIndex && + {class: 'current'}, + + actLink))), + ])), + ], + }), +}; diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js new file mode 100644 index 0000000..374fa3f --- /dev/null +++ b/src/content/dependencies/generateFlashCoverArtwork.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation) => + ({coverArtwork: relation('generateCoverArtwork')}), + + data: (flash) => + ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}), + + generate: (data, relations) => + relations.coverArtwork.slot('path', data.path), +}; diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js new file mode 100644 index 0000000..36bfaba --- /dev/null +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -0,0 +1,154 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkFlash', + 'linkFlashAct', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({flashActData}) => ({flashActData}), + + query(sprawl) { + const flashActs = + sprawl.flashActData.slice(); + + const jumpActs = + flashActs + .filter(act => act.side.acts.indexOf(act) === 0); + + return {flashActs, jumpActs}; + }, + + relations: (relation, query) => ({ + layout: + relation('generatePageLayout'), + + jumpLinkColorStyles: + query.jumpActs + .map(act => relation('generateColorStyleAttribute', act.side.color)), + + actColorStyles: + query.flashActs + .map(act => relation('generateColorStyleAttribute', act.color)), + + actLinks: + query.flashActs + .map(act => relation('linkFlashAct', act)), + + actCoverGrids: + query.flashActs + .map(() => relation('generateCoverGrid')), + + actCoverGridLinks: + query.flashActs + .map(act => act.flashes + .map(flash => relation('linkFlash', flash))), + + actCoverGridImages: + query.flashActs + .map(act => act.flashes + .map(() => relation('image'))), + }), + + data: (query) => ({ + jumpLinkAnchors: + query.jumpActs + .map(act => act.directory), + + jumpLinkLabels: + query.jumpActs + .map(act => act.side.name), + + actAnchors: + query.flashActs + .map(act => act.directory), + + actCoverGridNames: + query.flashActs + .map(act => act.flashes + .map(flash => flash.name)), + + actCoverGridPaths: + query.flashActs + .map(act => act.flashes + .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])), + }), + + generate: (data, relations, {html, language}) => + relations.layout.slots({ + title: language.$('flashIndex.title'), + headingMode: 'static', + + mainClasses: ['flash-index'], + mainContent: [ + !empty(data.jumpLinkLabels) && [ + html.tag('p', {class: 'quick-info'}, + language.$('misc.jumpTo')), + + html.tag('ul', {class: 'quick-info'}, + stitchArrays({ + colorStyle: relations.jumpLinkColorStyles, + anchor: data.jumpLinkAnchors, + label: data.jumpLinkLabels, + }).map(({colorStyle, anchor, label}) => + html.tag('li', + html.tag('a', + {href: '#' + anchor}, + colorStyle, + label)))), + ], + + stitchArrays({ + colorStyle: relations.actColorStyles, + actLink: relations.actLinks, + anchor: data.actAnchors, + + coverGrid: relations.actCoverGrids, + coverGridImages: relations.actCoverGridImages, + coverGridLinks: relations.actCoverGridLinks, + coverGridNames: data.actCoverGridNames, + coverGridPaths: data.actCoverGridPaths, + }).map(({ + colorStyle, + actLink, + anchor, + + coverGrid, + coverGridImages, + coverGridLinks, + coverGridNames, + coverGridPaths, + }, index) => [ + html.tag('h2', + {id: anchor}, + colorStyle, + actLink), + + coverGrid.slots({ + links: coverGridLinks, + names: coverGridNames, + lazy: index === 0 ? 4 : true, + + images: + stitchArrays({ + image: coverGridImages, + path: coverGridPaths, + }).map(({image, path}) => + image.slot('path', path)), + }), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }), +}; diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js new file mode 100644 index 0000000..0596493 --- /dev/null +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -0,0 +1,198 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCommentarySection', + 'generateContentHeading', + 'generateContributionList', + 'generateFlashActSidebar', + 'generateFlashCoverArtwork', + 'generateFlashNavAccent', + 'generatePageLayout', + 'generateTrackList', + 'linkExternal', + 'linkFlashAct', + ], + + extraDependencies: ['html', 'language'], + + query(flash) { + const query = {}; + + if (flash.page || !empty(flash.urls)) { + query.urls = []; + + if (flash.page) { + query.urls.push(`https://homestuck.com/story/${flash.page}`); + } + + if (!empty(flash.urls)) { + query.urls.push(...flash.urls); + } + } + + return query; + }, + + relations(relation, query, flash) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generateFlashActSidebar', flash.act, flash); + + if (query.urls) { + relations.externalLinks = + query.urls.map(url => relation('linkExternal', url)); + } + + // TODO: Flashes always have cover art (#175) + /* eslint-disable-next-line no-constant-condition */ + if (true) { + relations.cover = + relation('generateFlashCoverArtwork', flash); + } + + // Section: navigation bar + + const nav = sections.nav = {}; + + nav.flashActLink = + relation('linkFlashAct', flash.act); + + nav.flashNavAccent = + relation('generateFlashNavAccent', flash); + + // Section: Featured tracks + + if (!empty(flash.featuredTracks)) { + const featuredTracks = sections.featuredTracks = {}; + + featuredTracks.heading = + relation('generateContentHeading'); + + featuredTracks.list = + relation('generateTrackList', flash.featuredTracks); + } + + // Section: Contributors + + if (!empty(flash.contributorContribs)) { + const contributors = sections.contributors = {}; + + contributors.heading = + relation('generateContentHeading'); + + contributors.list = + relation('generateContributionList', flash.contributorContribs); + } + + // Section: Artist commentary + + if (flash.commentary) { + sections.artistCommentary = + relation('generateCommentarySection', flash.commentary); + } + + return relations; + }, + + data(query, flash) { + const data = {}; + + data.name = flash.name; + data.color = flash.color; + data.date = flash.date; + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout.slots({ + title: + language.$('flashPage.title', { + flash: data.name, + }), + + color: data.color, + headingMode: 'sticky', + + cover: + (relations.cover + ? relations.cover.slots({ + alt: language.$('misc.alt.flashArt'), + }) + : null), + + mainContent: [ + html.tag('p', + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + })), + + relations.externalLinks && + html.tag('p', + language.$('releaseInfo.playOn', { + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'flash'))), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + sec.artistCommentary && + language.$('releaseInfo.readCommentary', { + link: html.tag('a', + {href: '#artist-commentary'}, + language.$('releaseInfo.readCommentary.link')), + }), + ]), + + sec.featuredTracks && [ + sec.featuredTracks.heading + .slots({ + id: 'features', + title: + language.$('releaseInfo.tracksFeatured', { + flash: html.tag('i', data.name), + }), + }), + + sec.featuredTracks.list, + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + sec.contributors.list, + ], + + sec.artistCommentary, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: sec.nav.flashActLink.slot('color', false)}, + {auto: 'current'}, + ], + + navBottomRowContent: sec.nav.flashNavAccent, + + leftSidebar: relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js new file mode 100644 index 0000000..55e056d --- /dev/null +++ b/src/content/dependencies/generateFlashNavAccent.js @@ -0,0 +1,73 @@ +import {atOffset, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkFlash', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({flashActData}) { + return {flashActData}; + }, + + query(sprawl, flash) { + // Don't sort chronologically here. The previous/next buttons should match + // the order in the sidebar, by act rather than date. + const flashes = + sprawl.flashActData + .flatMap(act => act.flashes); + + const index = + flashes.indexOf(flash); + + const previousFlash = + atOffset(flashes, index, -1); + + const nextFlash = + atOffset(flashes, index, +1); + + return {previousFlash, nextFlash}; + }, + + relations(relation, query) { + const relations = {}; + + if (query.previousFlash || query.nextFlash) { + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.previousFlashLink = + (query.previousFlash + ? relation('linkFlash', query.previousFlash) + : null); + + relations.nextFlashLink = + (query.nextFlash + ? relation('linkFlash', query.nextFlash) + : null); + } + + return relations; + }, + + generate(relations, {html, language}) { + const {content: previousNextLinks = []} = + relations.previousNextLinks && + relations.previousNextLinks.slots({ + previousLink: relations.previousFlashLink, + nextLink: relations.nextFlashLink, + }); + + const allLinks = [ + ...previousNextLinks, + ].filter(Boolean); + + if (empty(allLinks)) { + return html.blank(); + } + + return `(${language.formatUnitList(allLinks)})`; + }, +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 0000000..dfd83ae --- /dev/null +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -0,0 +1,59 @@ +import {sortByName} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + extraDependencies: [ + 'defaultLanguage', + 'html', + 'language', + 'languages', + 'pagePath', + 'to', + ], + + generate({ + defaultLanguage, + html, + language, + languages, + pagePath, + to, + }) { + const switchableLanguages = + Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language); + + if (switchableLanguages.length <= 1) { + return html.blank(); + } + + sortByName(switchableLanguages); + + const [pagePathSubkey, ...pagePathArgs] = pagePath; + + const linkPaths = + switchableLanguages.map(language => + (language === defaultLanguage + ? (['localizedDefaultLanguage.' + pagePathSubkey, + ...pagePathArgs]) + : (['localizedWithBaseDirectory.' + pagePathSubkey, + language.code, + ...pagePathArgs]))); + + const links = + stitchArrays({ + language: switchableLanguages, + linkPath: linkPaths, + }).map(({language, linkPath}) => + html.tag('span', + html.tag('a', + {href: to(...linkPath)}, + language.name))); + + return html.tag('div', {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', { + languages: language.formatListWithoutSeparator(links), + })); + }, +}; diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js new file mode 100644 index 0000000..f5b1aaa --- /dev/null +++ b/src/content/dependencies/generateGridActionLinks.js @@ -0,0 +1,22 @@ +import {empty} from '#sugar'; + +export default { + extraDependencies: ['html'], + + slots: { + actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + }, + + generate(slots, {html}) { + if (empty(slots.actionLinks)) { + return html.blank(); + } + + return ( + html.tag('div', {class: 'grid-actions'}, + slots.actionLinks + .filter(Boolean) + .map(link => link + .slot('attributes', {class: ['grid-item', 'box']})))); + }, +}; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js new file mode 100644 index 0000000..d07847c --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -0,0 +1,198 @@ +import {sortChronologically} from '#sort'; +import {empty, stitchArrays} from '#sugar'; +import {filterItemsForCarousel, getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateCoverCarousel', + 'generateCoverGrid', + 'generateGroupNavLinks', + 'generateGroupSecondaryNav', + 'generateGroupSidebar', + 'generatePageLayout', + 'image', + 'linkAlbum', + 'linkListing', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => + ({enableGroupUI: wikiInfo.enableGroupUI}), + + 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.secondaryNav = + relation('generateGroupSecondaryNav', group); + + relations.sidebar = + relation('generateGroupSidebar', group); + } + + 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; + data.color = group.color; + + 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', + + color: data.color, + + 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.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), + })), + }), + ], + + leftSidebar: + (relations.sidebar + ? relations.sidebar + .slot('currentExtra', 'gallery') + .content /* TODO: Kludge. */ + : null), + + navLinkStyle: 'hierarchical', + navLinks: + relations.navLinks + .slot('currentExtra', 'gallery') + .content, + + secondaryNav: + relations.secondaryNav ?? null, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js new file mode 100644 index 0000000..b5b456a --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -0,0 +1,222 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateColorStyleAttribute', + 'generateContentHeading', + 'generateGroupNavLinks', + 'generateGroupSecondaryNav', + 'generateGroupSidebar', + 'generatePageLayout', + 'linkAlbum', + 'linkExternal', + 'linkGroupGallery', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableGroupUI: wikiInfo.enableGroupUI, + }; + }, + + query(sprawl, group) { + const albums = + group.albums; + + const albumGroups = + albums + .map(album => album.groups); + + const albumOtherCategory = + albumGroups + .map(groups => groups + .map(group => group.category) + .find(category => category !== group.category)); + + const albumOtherGroups = + stitchArrays({ + groups: albumGroups, + category: albumOtherCategory, + }).map(({groups, category}) => + groups + .filter(group => group.category === category)); + + return {albums, albumOtherGroups}; + }, + + relations(relation, query, sprawl, group) { + const relations = {}; + const sec = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateGroupNavLinks', group); + + if (sprawl.enableGroupUI) { + relations.secondaryNav = + relation('generateGroupSecondaryNav', group); + + relations.sidebar = + relation('generateGroupSidebar', group); + } + + 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(query.albums)) { + sec.albums = {}; + + sec.albums.heading = + relation('generateContentHeading'); + + sec.albums.galleryLink = + relation('linkGroupGallery', group); + + sec.albums.albumColorStyles = + query.albums + .map(album => relation('generateColorStyleAttribute', album.color)); + + sec.albums.albumLinks = + query.albums + .map(album => relation('linkAlbum', album)); + + sec.albums.otherGroupLinks = + query.albumOtherGroups + .map(groups => groups + .map(group => relation('linkGroup', group))); + + sec.albums.datetimestamps = + group.albums.map(album => + (album.date + ? relation('generateAbsoluteDatetimestamp', album.date) + : null)); + } + + return relations; + }, + + data(query, sprawl, group) { + const data = {}; + + data.name = group.name; + data.color = group.color; + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + return relations.layout + .slots({ + title: language.$('groupInfoPage.title', {group: data.name}), + headingMode: 'sticky', + color: data.color, + + mainContent: [ + sec.info.visitLinks && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: + language.formatDisjunctionList( + sec.info.visitLinks + .map(link => link.slot('context', 'group'))), + })), + + 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', + stitchArrays({ + albumLink: sec.albums.albumLinks, + otherGroupLinks: sec.albums.otherGroupLinks, + datetimestamp: sec.albums.datetimestamps, + albumColorStyle: sec.albums.albumColorStyles, + }).map(({ + albumLink, + otherGroupLinks, + datetimestamp, + albumColorStyle, + }) => { + const prefix = 'groupInfoPage.albumList.item'; + const parts = [prefix]; + const options = {}; + + options.album = + albumLink.slot('color', false); + + if (datetimestamp) { + parts.push('withYear'); + options.yearAccent = + language.$(prefix, 'yearAccent', { + year: + datetimestamp.slots({style: 'year', tooltip: true}), + }); + } + + if (!empty(otherGroupLinks)) { + parts.push('withOtherGroup'); + options.otherGroupAccent = + html.tag('span', {class: 'other-group-accent'}, + language.$(prefix, 'otherGroupAccent', { + groups: + language.formatConjunctionList( + otherGroupLinks.map(groupLink => + groupLink.slot('color', false))), + })); + } + + return ( + html.tag('li', + albumColorStyle, + language.$(...parts, options))); + })), + ], + ], + + leftSidebar: + (relations.sidebar + ? relations.sidebar + .content /* TODO: Kludge. */ + : null), + + navLinkStyle: 'hierarchical', + navLinks: relations.navLinks.content, + + secondaryNav: relations.secondaryNav ?? null, + }); + }, +}; diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js new file mode 100644 index 0000000..5cde2ab --- /dev/null +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -0,0 +1,104 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'linkGroup', + 'linkGroupGallery', + ], + + 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.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 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 + ? `(${extrasPart})` + : null); + + 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/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js new file mode 100644 index 0000000..a4f8131 --- /dev/null +++ b/src/content/dependencies/generateGroupSecondaryNav.js @@ -0,0 +1,104 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generatePreviousNextLinks', + 'generateSecondaryNav', + 'linkGroupDynamically', + 'linkListing', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({listingSpec, wikiInfo}) => ({ + groupsByCategoryListing: + (wikiInfo.enableListings + ? listingSpec + .find(l => l.directory === 'groups/by-category') + : null), + }), + + query(sprawl, group) { + const groups = group.category.groups; + const index = groups.indexOf(group); + + return { + previousGroup: + atOffset(groups, index, -1), + + nextGroup: + atOffset(groups, index, +1), + }; + }, + + relations(relation, query, sprawl, group) { + const relations = {}; + + relations.secondaryNav = + relation('generateSecondaryNav'); + + if (sprawl.groupsByCategoryListing) { + relations.categoryLink = + relation('linkListing', sprawl.groupsByCategoryListing); + } + + relations.colorStyle = + relation('generateColorStyleAttribute', group.category.color); + + if (query.previousGroup || query.nextGroup) { + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + } + + relations.previousGroupLink = + (query.previousGroup + ? relation('linkGroupDynamically', query.previousGroup) + : null); + + relations.nextGroupLink = + (query.nextGroup + ? relation('linkGroupDynamically', query.nextGroup) + : null); + + return relations; + }, + + data: (query, sprawl, group) => ({ + categoryName: group.category.name, + }), + + generate(data, relations, {html, language}) { + const previousNextPart = + (relations.previousNextLinks + ? relations.previousNextLinks + .slots({ + previousLink: relations.previousGroupLink, + nextLink: relations.nextGroupLink, + id: true, + }) + .content /* TODO: Kludge. */ + : null); + + const {categoryLink} = relations; + + categoryLink?.setSlot('content', data.categoryName); + + return relations.secondaryNav.slots({ + class: 'nav-links-groups', + content: + (previousNextPart + ? html.tag('span', {class: 'nav-link'}, + relations.colorStyle.slot('context', 'primary-only'), + + [ + categoryLink?.slot('color', false), + `(${language.formatUnitList(previousNextPart)})`, + ]) + : categoryLink + ? html.tag('span', {class: 'nav-link'}, + categoryLink) + : html.blank()), + }); + }, +}; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js new file mode 100644 index 0000000..3abb339 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebar.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: [ + 'generateGroupSidebarCategoryDetails', + 'generatePageSidebar', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({groupCategoryData}) => ({groupCategoryData}), + + relations: (relation, sprawl, group) => ({ + sidebar: + relation('generatePageSidebar'), + + categoryDetails: + sprawl.groupCategoryData.map(category => + relation('generateGroupSidebarCategoryDetails', category, group)), + }), + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate: (relations, slots, {html, language}) => + relations.sidebar.slots({ + attributes: {class: 'category-map-sidebar-box'}, + + content: [ + 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 0000000..69de373 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -0,0 +1,82 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, category) { + return { + colorStyle: + relation('generateColorStyleAttribute', category.color), + + groupInfoLinks: + category.groups.map(group => + relation('linkGroup', group)), + + groupGalleryLinks: + category.groups.map(group => + (empty(group.albums) + ? null + : relation('linkGroupGallery', group))), + }; + }, + + 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', + data.isCurrentCategory && + {class: 'current', open: true}, + + [ + html.tag('summary', + relations.colorStyle, + + html.tag('span', + language.$('groupSidebar.groupList.category', { + category: + html.tag('span', {class: 'group-name'}, + data.name), + }))), + + html.tag('ul', + stitchArrays(({ + infoLink: relations.groupInfoLinks, + galleryLink: relations.groupGalleryLinks, + })).map(({infoLink, galleryLink}, index) => + html.tag('li', + index === data.currentGroupIndex && + {class: 'current'}, + + language.$('groupSidebar.groupList.item', { + group: + (slots.currentExtra === 'gallery' + ? galleryLink ?? infoLink + : infoLink), + })))), + ]); + }, +}; diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js new file mode 100644 index 0000000..43a78cb --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js @@ -0,0 +1,90 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + extraDependencies: ['html', 'language'], + + slots: { + title: { + type: 'html', + mutable: false, + }, + + additionalFileTitles: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + additionalFileLinks: { + validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)), + }, + + additionalFileFiles: { + validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)), + }, + + stringsKey: {type: 'string'}, + }, + + generate(slots, {html, language}) { + if (empty(slots.additionalFileLinks)) { + return html.blank(); + } + + return html.tags([ + html.tag('dt', slots.title), + html.tag('dd', + html.tag('ul', + stitchArrays({ + additionalFileTitle: slots.additionalFileTitles, + additionalFileLinks: slots.additionalFileLinks, + additionalFileFiles: slots.additionalFileFiles, + }).map(({ + additionalFileTitle, + additionalFileLinks, + additionalFileFiles, + }) => + (additionalFileLinks.length === 1 + ? html.tag('li', + additionalFileLinks[0].slots({ + content: + language.$('listingPage', slots.stringsKey, 'file', { + title: additionalFileTitle, + }), + })) + + : additionalFileLinks.length === 0 + ? html.tag('li', + language.$('listingPage', slots.stringsKey, 'file.withNoFiles', { + title: additionalFileTitle, + })) + + : html.tag('li', {class: 'has-details'}, + html.tag('details', [ + html.tag('summary', + html.tag('span', + language.$('listingPage', slots.stringsKey, 'file.withMultipleFiles', { + title: + html.tag('span', {class: 'group-name'}, + additionalFileTitle), + + files: + language.countAdditionalFiles( + additionalFileLinks.length, + {unit: true}), + }))), + + html.tag('ul', + stitchArrays({ + additionalFileLink: additionalFileLinks, + additionalFileFile: additionalFileFiles, + }).map(({additionalFileLink, additionalFileFile}) => + html.tag('li', + additionalFileLink.slots({ + content: + language.$('listingPage', slots.stringsKey, 'file', { + title: additionalFileFile, + }), + })))), + ])))))), + ]); + }, +}; diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js new file mode 100644 index 0000000..b3560ac --- /dev/null +++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js @@ -0,0 +1,18 @@ +export default { + contentDependencies: ['linkAlbum'], + + data: (album) => + ({directory: album.directory}), + + relations: (relation, album) => + ({albumLink: relation('linkAlbum', album)}), + + generate: (data, relations) => + relations.albumLink.slots({ + anchor: true, + attributes: { + 'data-random': 'track-in-album', + 'style': `--album-directory: ${data.directory}`, + }, + }), +}; diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js new file mode 100644 index 0000000..ed15365 --- /dev/null +++ b/src/content/dependencies/generateListingIndexList.js @@ -0,0 +1,131 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['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.targets = + sprawl.listingTargetSpec + .filter((target, index) => !empty(targetListings[index])); + + query.targetListings = + targetListings + .filter(listings => !empty(listings)) + + return query; + }, + + relations(relation, query) { + return { + 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', + targetIndex === data.currentTargetIndex && + listingIndex === data.currentListingIndex && + {class: 'current'}, + + listingLink.slots({ + 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', + targetIndex === data.currentTargetIndex && + {class: 'current', open: true}, + + [ + html.tag('summary', + 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 0000000..23377af --- /dev/null +++ b/src/content/dependencies/generateListingPage.js @@ -0,0 +1,282 @@ +import {bindOpts, empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateListingSidebar', + 'generatePageLayout', + 'linkListing', + 'linkListingIndex', + 'linkTemplate', + ], + + 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'); + + relations.showSkipToSectionLinkTemplate = + relation('linkTemplate'); + + 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.strictArrayOf(v.isObject), + }, + + rowAttributes: { + validate: v => v.strictArrayOf(v.optional(v.isObject)) + }, + + chunkTitles: { + validate: v => v.strictArrayOf(v.isObject), + }, + + chunkTitleAccents: { + validate: v => v.strictArrayOf(v.optional(v.isObject)), + }, + + chunkRows: { + validate: v => v.strictArrayOf(v.isObject), + }, + + chunkRowAttributes: { + validate: v => v.strictArrayOf(v.optional(v.isObject)), + }, + + showSkipToSection: { + type: 'boolean', + default: false, + }, + + chunkIDs: { + validate: v => v.strictArrayOf(v.optional(v.isString)), + }, + + listStyle: { + validate: v => v.is('ordered', 'unordered'), + default: 'unordered', + }, + + content: { + type: 'html', + mutable: false, + }, + }, + + generate(data, relations, slots, {html, language}) { + function formatListingString({ + context, + provided = {}, + }) { + const parts = ['listingPage', data.stringsKey]; + + if (Array.isArray(context)) { + parts.push(...context); + } else { + parts.push(context); + } + + if (provided.stringsKey) { + parts.push(provided.stringsKey); + } + + const options = {...provided}; + delete options.stringsKey; + + return language.formatString(...parts, options); + } + + const formatRow = ({context, row, attributes}) => + (attributes?.href + ? html.tag('li', + html.tag('a', + attributes, + formatListingString({ + context, + provided: row, + }))) + : html.tag('li', + attributes, + formatListingString({ + context, + provided: row, + }))); + + const formatRowList = ({context, rows, rowAttributes}) => + html.tag( + (slots.listStyle === 'ordered' ? 'ol' : 'ul'), + stitchArrays({ + row: rows, + attributes: rowAttributes ?? rows.map(() => null), + }).map( + bindOpts(formatRow, { + [bindOpts.bindIndex]: 0, + context, + }))); + + return relations.layout.slots({ + title: formatListingString({context: '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', + index === data.sameTargetListingsCurrentIndex && + {class: '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.content, + + slots.type === 'rows' && + formatRowList({ + context: 'item', + rows: slots.rows, + rowAttributes: slots.rowAttributes, + }), + + slots.type === 'chunks' && + html.tag('dl', [ + slots.showSkipToSection && [ + html.tag('dt', + language.$('listingPage.skipToSection')), + + html.tag('dd', + html.tag('ul', + stitchArrays({ + title: slots.chunkTitles, + id: slots.chunkIDs, + }).filter(({id}) => id) + .map(({title, id}) => + html.tag('li', + relations.showSkipToSectionLinkTemplate + .clone() + .slots({ + hash: id, + content: + html.normalize( + formatListingString({ + context: 'chunk.title', + provided: title, + }).toString() + .replace(/:$/, '')), + }))))), + ], + + stitchArrays({ + title: slots.chunkTitles, + titleAccent: slots.chunkTitleAccents, + id: slots.chunkIDs, + rows: slots.chunkRows, + rowAttributes: slots.chunkRowAttributes, + }).map(({title, titleAccent, id, rows, rowAttributes}) => [ + relations.chunkHeading + .clone() + .slots({ + tag: 'dt', + id, + + title: + formatListingString({ + context: 'chunk.title', + provided: title, + }), + + accent: + titleAccent && + formatListingString({ + context: ['chunk.title', title.stringsKey, 'accent'], + provided: titleAccent, + }), + }), + + html.tag('dd', + formatRowList({ + context: 'chunk.item', + rows, + rowAttributes, + })), + ]), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.listingsIndexLink}, + {auto: 'current'}, + ], + + leftSidebar: relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js new file mode 100644 index 0000000..1e5c8bf --- /dev/null +++ b/src/content/dependencies/generateListingSidebar.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: [ + 'generateListingIndexList', + 'generatePageSidebar', + 'linkListingIndex', + ], + + extraDependencies: ['html'], + + relations: (relation, currentListing) => ({ + sidebar: + relation('generatePageSidebar'), + + listingIndexLink: + relation('linkListingIndex'), + + listingIndexList: + relation('generateListingIndexList', currentListing), + }), + + generate: (relations, {html}) => + relations.sidebar.slots({ + attributes: {class: 'listing-map-sidebar-box'}, + content: [ + html.tag('h1', relations.listingIndexLink), + relations.listingIndexList.slot('mode', 'sidebar'), + ], + }), +}; diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js new file mode 100644 index 0000000..b57ebe1 --- /dev/null +++ b/src/content/dependencies/generateListingsIndexPage.js @@ -0,0 +1,89 @@ +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateListingIndexList', + 'generateListingSidebar', + 'generatePageLayout', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({albumData, trackData, wikiInfo}) { + return { + wikiName: wikiInfo.name, + numTracks: trackData.length, + numAlbums: albumData.length, + totalDuration: getTotalDuration(trackData), + }; + }, + + relations(relation) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generateListingSidebar', null); + + relations.list = + relation('generateListingIndexList', null); + + return relations; + }, + + data(sprawl) { + return { + wikiName: sprawl.wikiName, + numTracks: sprawl.numTracks, + numAlbums: sprawl.numAlbums, + totalDuration: sprawl.totalDuration, + }; + }, + + generate(data, relations, {html, language}) { + return relations.layout.slots({ + title: language.$('listingIndex.title'), + + headingMode: 'static', + + mainContent: [ + html.tag('p', + language.$('listingIndex.infoLine', { + wiki: data.wikiName, + + tracks: + html.tag('b', + language.countTracks(data.numTracks, {unit: true})), + + albums: + html.tag('b', + language.countAlbums(data.numAlbums, {unit: true})), + + duration: + html.tag('b', + language.formatDuration(data.totalDuration, { + approximate: true, + unit: true, + })), + })), + + html.tag('hr'), + + html.tag('p', + language.$('listingIndex.exploreList')), + + relations.list.slot('mode', 'content'), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + + leftSidebar: relations.sidebar, + }); + }, +}; diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js new file mode 100644 index 0000000..bcba719 --- /dev/null +++ b/src/content/dependencies/generateNewsEntryPage.js @@ -0,0 +1,131 @@ +import {sortChronologically} from '#sort'; +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateNewsEntryReadAnotherLinks', + 'generatePageLayout', + 'generatePreviousNextLinks', + 'linkNewsEntry', + 'linkNewsIndex', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({newsData}) { + return {newsData}; + }, + + query({newsData}, newsEntry) { + const entries = sortChronologically(newsData.slice()); + + const index = entries.indexOf(newsEntry); + + const previousEntry = + atOffset(entries, index, -1); + + const nextEntry = + atOffset(entries, index, +1); + + return {previousEntry, nextEntry}; + }, + + relations(relation, query, sprawl, newsEntry) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.content = + relation('transformContent', newsEntry.content); + + relations.newsIndexLink = + relation('linkNewsIndex'); + + relations.currentEntryLink = + relation('linkNewsEntry', newsEntry); + + if (query.previousEntry || query.nextEntry) { + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.readAnotherLinks = + relation('generateNewsEntryReadAnotherLinks', + newsEntry, + query.previousEntry, + query.nextEntry); + + if (query.previousEntry) { + relations.previousEntryNavLink = + relation('linkNewsEntry', query.previousEntry); + } + + if (query.nextEntry) { + relations.nextEntryNavLink = + relation('linkNewsEntry', query.nextEntry); + } + } + + return relations; + }, + + data(query, sprawl, newsEntry) { + return { + name: newsEntry.name, + date: newsEntry.date, + + daysSincePreviousEntry: + query.previousEntry && + Math.round((newsEntry.date - query.previousEntry.date) / 86400000), + + daysUntilNextEntry: + query.nextEntry && + Math.round((query.nextEntry.date - newsEntry.date) / 86400000), + + previousEntryDate: + query.previousEntry?.date, + + nextEntryDate: + query.nextEntry?.date, + }; + }, + + generate(data, relations, {html, language}) { + return relations.layout.slots({ + title: + language.$('newsEntryPage.title', { + entry: data.name, + }), + + headingMode: 'sticky', + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', + language.$('newsEntryPage.published', { + date: language.formatDate(data.date), + })), + + relations.content, + relations.readAnotherLinks, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.newsIndexLink}, + { + auto: 'current', + accent: + (relations.previousNextLinks + ? `(${language.formatUnitList(relations.previousNextLinks.slots({ + previousLink: relations.previousEntryNavLink ?? null, + nextLink: relations.nextEntryNavLink ?? null, + }).content)})` + : null), + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js new file mode 100644 index 0000000..d978b0e --- /dev/null +++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js @@ -0,0 +1,97 @@ +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateRelativeDatetimestamp', + 'linkNewsEntry', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, currentEntry, previousEntry, nextEntry) { + const relations = {}; + + if (previousEntry) { + relations.previousEntryLink = + relation('linkNewsEntry', previousEntry); + + if (previousEntry.date) { + relations.previousEntryDatetimestamp = + (currentEntry.date + ? relation('generateRelativeDatetimestamp', + previousEntry.date, + currentEntry.date) + : relation('generateAbsoluteDatetimestamp', + previousEntry.date)); + } + } + + if (nextEntry) { + relations.nextEntryLink = + relation('linkNewsEntry', nextEntry); + + if (nextEntry.date) { + relations.nextEntryDatetimestamp = + (currentEntry.date + ? relation('generateRelativeDatetimestamp', + nextEntry.date, + currentEntry.date) + : relation('generateAbsoluteDatetimestamp', + nextEntry.date)); + } + } + + return relations; + }, + + generate(relations, {html, language}) { + const prefix = `newsEntryPage.readAnother`; + + const entryLines = []; + + if (relations.previousEntryLink) { + const parts = [prefix, `previous`]; + const options = {}; + + options.entry = relations.previousEntryLink; + + if (relations.previousEntryDatetimestamp) { + parts.push('withDate'); + options.date = + relations.previousEntryDatetimestamp.slots({ + style: 'full', + tooltip: true, + }); + } + + entryLines.push(language.$(...parts, options)); + } + + if (relations.nextEntryLink) { + const parts = [prefix, `next`]; + const options = {}; + + options.entry = relations.nextEntryLink; + + if (relations.nextEntryDatetimestamp) { + parts.push('withDate'); + options.date = + relations.nextEntryDatetimestamp.slots({ + style: 'full', + tooltip: true, + }); + } + + entryLines.push(language.$(...parts, options)); + } + + return ( + html.tag('p', {class: 'read-another-links'}, + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + entryLines.length > 1 && + {class: 'offset-tooltips'}, + + entryLines)); + }, +}; diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js new file mode 100644 index 0000000..539af80 --- /dev/null +++ b/src/content/dependencies/generateNewsIndexPage.js @@ -0,0 +1,93 @@ +import {sortChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageLayout', + 'linkNewsEntry', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({newsData}) { + return {newsData}; + }, + + query({newsData}) { + return { + entries: + sortChronologically( + newsData.slice(), + {latestFirst: true}), + }; + }, + + relations(relation, query) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.entryLinks = + query.entries + .map(entry => relation('linkNewsEntry', entry)); + + relations.viewRestLinks = + query.entries + .map(entry => + (entry.content === entry.contentShort + ? null + : relation('linkNewsEntry', entry))); + + relations.entryContents = + query.entries + .map(entry => relation('transformContent', entry.contentShort)); + + return relations; + }, + + data(query) { + return { + entryDates: + query.entries.map(entry => entry.date), + + entryDirectories: + query.entries.map(entry => entry.directory), + }; + }, + + generate(data, relations, {html, language}) { + return relations.layout.slots({ + title: language.$('newsIndex.title'), + headingMode: 'sticky', + + mainClasses: ['long-content', 'news-index'], + mainContent: + stitchArrays({ + entryLink: relations.entryLinks, + viewRestLink: relations.viewRestLinks, + content: relations.entryContents, + date: data.entryDates, + directory: data.entryDirectories, + }).map(({entryLink, viewRestLink, content, date, directory}) => + html.tag('article', {id: directory}, [ + html.tag('h2', [ + html.tag('time', language.formatDate(date)), + entryLink, + ]), + + content, + + viewRestLink + ?.slot('content', language.$('newsIndex.entry.viewRest')), + ])), + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }); + }, +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 0000000..cbfc905 --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,672 @@ +import {openAggregate} from '#aggregate'; +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'cachebust', + 'getColors', + 'html', + 'language', + 'pagePath', + 'to', + 'wikiData', + ], + + sprawl({wikiInfo}) { + return { + footerContent: wikiInfo.footerContent, + wikiColor: wikiInfo.color, + wikiName: wikiInfo.nameShort, + }; + }, + + data({wikiColor, wikiName}) { + return { + wikiColor, + wikiName, + }; + }, + + relations(relation, sprawl) { + const relations = {}; + + relations.footerLocalizationLinks = + relation('generateFooterLocalizationLinks'); + + relations.stickyHeadingContainer = + relation('generateStickyHeadingContainer'); + + if (sprawl.footerContent) { + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + } + + relations.colorStyleRules = + relation('generateColorStyleRules'); + + return relations; + }, + + slots: { + title: { + type: 'html', + mutable: false, + }, + + showWikiNameInTitle: { + type: 'boolean', + default: true, + }, + + additionalNames: { + type: 'html', + mutable: false, + }, + + cover: { + type: 'html', + mutable: false, + }, + + // Strictly speaking we clone this each time we use it, so it doesn't + // need to be marked as mutable here. + socialEmbed: { + type: 'html', + mutable: true, + }, + + color: {validate: v => v.isColor}, + + styleRules: { + validate: v => v.sparseArrayOf(v.isHTML), + default: [], + }, + + mainClasses: { + validate: v => v.sparseArrayOf(v.isString), + default: [], + }, + + // Main + + mainContent: { + type: 'html', + mutable: false, + }, + + headingMode: { + validate: v => v.is('sticky', 'static'), + default: 'static', + }, + + // Sidebars + + leftSidebar: { + type: 'html', + mutable: true, + }, + + rightSidebar: { + type: 'html', + mutable: true, + }, + + // Banner + + banner: { + type: 'html', + mutable: false, + }, + + bannerPosition: { + validate: v => v.is('top', 'bottom'), + default: 'top', + }, + + // Nav & Footer + + navContent: { + type: 'html', + mutable: false, + }, + + navBottomRowContent: { + type: 'html', + mutable: false, + }, + + navLinkStyle: { + validate: v => v.is('hierarchical', 'index'), + default: 'index', + }, + + navLinks: { + validate: v => + v.sparseArrayOf(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, + + current: () => true, + }), object); + + if (object.current !== undefined) { + aggregate.call(v.isBoolean, object.current); + } + + 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.strictArrayOf(v.isString), + title: v.isHTML, + }), { + path: object.path, + title: object.title, + }); + } + + aggregate.close(); + + return true; + }) + }, + + secondaryNav: { + type: 'html', + mutable: false, + }, + + footerContent: { + type: 'html', + mutable: false, + }, + }, + + generate(data, relations, slots, { + cachebust, + getColors, + html, + language, + pagePath, + to, + }) { + const colors = getColors(slots.color ?? data.wikiColor); + const hasSocialEmbed = !html.isBlank(slots.socialEmbed); + + // Hilariously jank. Sorry! We're going to need this content later ANYWAY, + // so it's "fine" to stringify it here, but this DOES mean that we're + // stringifying (and resolving) the content without the context that it's + // e.g. going to end up in a page HTML hierarchy. Might have implications + // later, mainly for: https://github.com/hsmusic/hsmusic-wiki/issues/434 + const mainContentHTML = html.tags([slots.mainContent]).toString(); + const hasID = id => mainContentHTML.includes(`id="${id}"`); + + const titleContentsHTML = + (html.isBlank(slots.title) + ? null + : html.isBlank(slots.additionalNames) + ? language.sanitize(slots.title) + : html.tag('a', { + href: '#additional-names-box', + title: language.$('misc.additionalNames.tooltip').toString(), + }, language.sanitize(slots.title))); + + const titleHTML = + (html.isBlank(slots.title) + ? null + : slots.headingMode === 'sticky' + ? relations.stickyHeadingContainer.slots({ + title: titleContentsHTML, + cover: slots.cover, + }) + : html.tag('h1', titleContentsHTML)); + + let footerContent = slots.footerContent; + + if (html.isBlank(footerContent) && relations.defaultFooterContent) { + footerContent = + relations.defaultFooterContent.slots({ + mode: 'multiline', + indicateExternalLinks: false, + }); + } + + const mainHTML = + html.tag('main', {id: 'content'}, + {class: slots.mainClasses}, + + [ + titleHTML, + + html.tag('div', {id: 'cover-art-container'}, + {[html.onlyIfContent]: true}, + slots.cover), + + slots.additionalNames, + + html.tag('div', {class: 'main-content-container'}, + {[html.onlyIfContent]: true}, + mainContentHTML), + ]); + + const footerHTML = + html.tag('footer', {id: 'footer'}, + {[html.onlyIfContent]: true}, + + [ + html.tag('div', {class: 'footer-content'}, + {[html.onlyIfContent]: true}, + footerContent), + + relations.footerLocalizationLinks, + ]); + + const navHTML = + html.tag('nav', {id: 'header'}, + {[html.onlyIfContent]: true}, + + !empty(slots.navLinks) && + {class: 'nav-has-main-links'}, + + !html.isBlank(slots.navContent) && + {class: 'nav-has-content'}, + + !html.isBlank(slots.navBottomRowContent) && + {class: 'nav-has-bottom-row'}, + + [ + html.tag('div', {class: 'nav-main-links'}, + {[html.onlyIfContent]: true}, + {class: 'nav-links-' + slots.navLinkStyle}, + + slots.navLinks + ?.filter(Boolean) + ?.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); + } + + const showAsCurrent = + cur.current || + cur.auto === 'current' || + (slots.navLinkStyle === 'hierarchical' && + i === slots.navLinks.length - 1); + + return ( + html.tag('span', {class: 'nav-link'}, + showAsCurrent && + {class: 'current'}, + + i > 0 && + {class: 'has-divider'}, + + [ + html.tag('span', {class: 'nav-link-content'}, + // Use inline-block styling on the content span, + // rather than wrapping the whole nav-link in a proper + // blockwrap, so that if the content spans multiple + // lines, it'll kick the accent down beneath it. + i > 0 && + {class: 'blockwrap'}, + + content), + + html.tag('span', {class: 'nav-link-accent'}, + {[html.onlyIfContent]: true}, + cur.accent), + ])); + })), + + html.tag('div', {class: 'nav-bottom-row'}, + {[html.onlyIfContent]: true}, + slots.navBottomRowContent), + + html.tag('div', {class: 'nav-content'}, + {[html.onlyIfContent]: true}, + slots.navContent), + ]); + + const getSidebar = (side, id) => + (html.isBlank(slots[side]) + ? html.blank() + : slots[side].slots({ + attributes: + slots[side] + .getSlotValue('attributes') + .with({id}), + })); + + const leftSidebar = getSidebar('leftSidebar', 'sidebar-left'); + const rightSidebar = getSidebar('rightSidebar', 'sidebar-right'); + + const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar)); + const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar)); + + const collapseSidebars = + (hasSidebarLeft + ? leftSidebar.getSlotValue('collapse') + : true) && + (hasSidebarRight + ? rightSidebar.getSlotValue('collapse') + : true); + + const processSkippers = skipperList => + skipperList + .filter(({condition, id}) => + (condition === undefined + ? hasID(id) + : condition)) + .map(({id, string}) => + html.tag('span', {class: 'skipper'}, + html.tag('a', + {href: `#${id}`}, + language.$('misc.skippers', string)))); + + const skippersHTML = + mainHTML && + html.tag('div', {id: 'skippers'}, [ + html.tag('span', language.$('misc.skippers.skipTo')), + html.tag('div', {class: 'skipper-list'}, + processSkippers([ + {condition: true, id: 'content', string: 'content'}, + { + condition: hasSidebarLeft, + id: 'sidebar-left', + string: + (hasSidebarRight + ? 'sidebar.left' + : 'sidebar'), + }, + { + condition: hasSidebarRight, + id: 'sidebar-right', + string: + (hasSidebarLeft + ? 'sidebar.right' + : 'sidebar'), + }, + {condition: navHTML, id: 'header', string: 'header'}, + {condition: footerHTML, id: 'footer', string: 'footer'}, + ])), + + html.tag('div', {class: 'skipper-list'}, + {[html.onlyIfContent]: true}, + processSkippers([ + {id: 'tracks', string: 'tracks'}, + {id: 'art', string: 'artworks'}, + {id: 'flashes', string: 'flashes'}, + {id: 'contributors', string: 'contributors'}, + {id: 'references', string: 'references'}, + {id: 'referenced-by', string: 'referencedBy'}, + {id: 'samples', string: 'samples'}, + {id: 'sampled-by', string: 'sampledBy'}, + {id: 'features', string: 'features'}, + {id: 'featured-in', string: 'featuredIn'}, + {id: 'sheet-music-files', string: 'sheetMusicFiles'}, + {id: 'midi-project-files', string: 'midiProjectFiles'}, + {id: 'additional-files', string: 'additionalFiles'}, + {id: 'commentary', string: 'commentary'}, + {id: 'artist-commentary', string: 'artistCommentary'}, + ])), + ]); + + 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 && + {class: 'vertical-when-thin'}, + + [ + leftSidebar, + mainHTML, + rightSidebar, + ]), + + slots.bannerPosition === 'bottom' && + slots.banner, + + footerHTML, + ]; + + const pageHTML = 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', + }), + + slots.color && [ + html.tag('meta', { + name: 'theme-color', + content: colors.dark, + media: '(prefers-color-scheme: dark)', + }), + + html.tag('meta', { + name: 'theme-color', + content: colors.light, + media: '(prefers-color-scheme: light)', + }), + + html.tag('meta', { + name: 'theme-color', + content: colors.primary, + }), + ], + + /* + ...( + 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, + }))), + + */ + + hasSocialEmbed && + slots.socialEmbed + .clone() + .slot('mode', 'html'), + + html.tag('link', { + rel: 'stylesheet', + href: to('shared.staticFile', 'site6.css', cachebust), + }), + + html.tag('style', [ + relations.colorStyleRules + .slot('color', slots.color ?? data.wikiColor), + slots.styleRules, + ]), + + html.tag('script', { + src: to('shared.staticFile', 'lazy-loading.js', cachebust), + }), + ]), + + html.tag('body', + [ + html.tag('div', {id: 'page-container'}, + (hasSidebarLeft || hasSidebarRight + ? {class: 'has-one-sidebar'} + : {class: 'has-zero-sidebars'}), + + hasSidebarLeft && hasSidebarRight && + {class: 'has-two-sidebars'}, + + hasSidebarLeft && + {class: 'has-sidebar-left'}, + + hasSidebarRight && + {class: 'has-sidebar-right'}, + + [ + skippersHTML, + layoutHTML, + ]), + + // infoCardHTML, + imageOverlayHTML, + + html.tag('script', { + type: 'module', + src: to('shared.staticFile', 'client3.js', cachebust), + }), + ]), + ]) + ]).toString(); + + const oEmbedJSON = + (hasSocialEmbed + ? slots.socialEmbed + .clone() + .slot('mode', 'json') + .content + : null); + + return {pageHTML, oEmbedJSON}; + }, +}; diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js new file mode 100644 index 0000000..a7da3d1 --- /dev/null +++ b/src/content/dependencies/generatePageSidebar.js @@ -0,0 +1,103 @@ +export default { + contentDependencies: ['generatePageSidebarBox'], + extraDependencies: ['html'], + + relations: (relation) => ({ + box: + relation('generatePageSidebarBox'), + }), + + slots: { + // Content is a flat HTML array. It'll all be placed into one sidebar box + // if specified. + content: { + type: 'html', + mutable: false, + }, + + // Attributes to apply to the whole sidebar. If specifying multiple + // sections, this be added to the containing sidebar-column, arr - specify + // attributes on each section if that's more suitable. + attributes: { + type: 'attributes', + mutable: false, + }, + + // Chunks of content to be split into separate boxes in the sidebar. + boxes: { + type: 'html', + mutable: false, + }, + + // Sticky mode controls which sidebar sections, 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 + // 'static' - 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). + stickyMode: { + validate: v => v.is('last', 'column', 'static'), + default: '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. + 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. + wide: { + type: 'boolean', + default: false, + }, + }, + + generate(relations, slots, {html}) { + const attributes = + html.attributes({class: [ + 'sidebar-column', + 'sidebar-multiple', + ]}); + + attributes.add(slots.attributes); + + if (slots.class) { + attributes.add('class', slots.class); + } + + if (slots.wide) { + attributes.add('class', 'wide'); + } + + if (!slots.collapse) { + attributes.add('class', 'no-hide'); + } + + if (slots.stickyMode !== 'static') { + attributes.add('class', `sticky-${slots.stickyMode}`); + } + + const boxes = + (!html.isBlank(slots.boxes) + ? slots.boxes + : !html.isBlank(slots.content) + ? relations.box.slot('content', slots.content) + : html.blank()); + + if (html.isBlank(boxes)) { + return html.blank(); + } else { + return html.tag('div', attributes, boxes); + } + }, +}; diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js new file mode 100644 index 0000000..5183545 --- /dev/null +++ b/src/content/dependencies/generatePageSidebarBox.js @@ -0,0 +1,20 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: { + type: 'html', + mutable: false, + }, + + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (slots, {html}) => + html.tag('div', {class: 'sidebar'}, + slots.attributes, + slots.content), +}; diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js new file mode 100644 index 0000000..05b1d46 --- /dev/null +++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js @@ -0,0 +1,42 @@ +// This component is kind of unfortunately magical. It reads the content of +// various boxes and joins them together, discarding the boxes' attributes. +// Since it requires access to the actual box *templates* (rather than those +// templates' resolved content), take care when slotting into this. + +export default { + contentDependencies: ['generatePageSidebarBox'], + extraDependencies: ['html'], + + relations: (relation) => ({ + box: + relation('generatePageSidebarBox'), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + boxes: { + validate: v => v.looseArrayOf(v.isTemplate), + }, + }, + + generate: (relations, slots, {html}) => + relations.box.slots({ + attributes: slots.attributes, + content: + slots.boxes.slice() + .map(box => box.getSlotValue('content')) + .map((content, index, {length}) => [ + content, + index < length - 1 && + html.tag('hr', { + style: + `border-color: var(--primary-color); ` + + `border-style: none none dotted none`, + }), + ]), + }), +}; diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js new file mode 100644 index 0000000..9771de3 --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLinks.js @@ -0,0 +1,50 @@ +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', + mutable: true, + }, + + nextLink: { + type: 'html', + mutable: true, + }, + + id: { + type: 'boolean', + default: true, + }, + }, + + generate(slots, {html, language}) { + const previousNext = []; + + if (!html.isBlank(slots.previousLink)) { + previousNext.push( + slots.previousLink.slots({ + tooltipStyle: 'browser', + color: false, + attributes: {id: slots.id && 'previous-button'}, + content: language.$('misc.nav.previous'), + })); + } + + if (!html.isBlank(slots.nextLink)) { + previousNext.push( + slots.nextLink.slots({ + tooltipStyle: 'browser', + color: false, + attributes: {id: slots.id && 'next-button'}, + content: language.$('misc.nav.next'), + })); + } + + return previousNext; + }, +}; diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js new file mode 100644 index 0000000..a997de0 --- /dev/null +++ b/src/content/dependencies/generateRelativeDatetimestamp.js @@ -0,0 +1,69 @@ +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateDatetimestampTemplate', + 'generateTooltip', + ], + + extraDependencies: ['html', 'language'], + + data: (currentDate, referenceDate) => + (currentDate.getTime() === referenceDate.getTime() + ? {equal: true, date: currentDate} + : {equal: false, currentDate, referenceDate}), + + relations: (relation, currentDate) => ({ + template: + relation('generateDatetimestampTemplate'), + + fallback: + relation('generateAbsoluteDatetimestamp', currentDate), + + tooltip: + relation('generateTooltip'), + }), + + slots: { + style: { + validate: v => v.is('full', 'year'), + default: 'full', + }, + + tooltip: { + type: 'boolean', + default: false, + }, + }, + + generate(data, relations, slots, {language}) { + if (data.equal) { + return relations.fallback.slots({ + style: slots.style, + tooltip: slots.tooltip, + }); + } + + return relations.template.slots({ + mainContent: + (slots.style === 'full' + ? language.formatDate(data.currentDate) + : slots.style === 'year' + ? data.currentDate.getFullYear().toString() + : null), + + tooltip: + slots.tooltip && + relations.tooltip.slots({ + content: + language.formatRelativeDate(data.currentDate, data.referenceDate, { + considerRoundingDays: true, + approximate: true, + absolute: slots.style === 'year', + }), + }), + + datetime: + data.currentDate.toISOString(), + }); + }, +}; diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js new file mode 100644 index 0000000..2e6c470 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -0,0 +1,42 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html', 'language'], + + relations(relation, contributions) { + if (empty(contributions)) { + return {}; + } + + return { + contributionLinks: + contributions + .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, + iconMode: 'tooltip', + }))), + }); + }, +}; diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js new file mode 100644 index 0000000..e9aef66 --- /dev/null +++ b/src/content/dependencies/generateSecondaryNav.js @@ -0,0 +1,20 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: { + type: 'html', + mutable: false, + }, + + class: { + validate: v => v.anyOf(v.isString, v.sparseArrayOf(v.isString)), + }, + }, + + generate: (slots, {html}) => + html.tag('nav', {id: 'secondary-nav'}, + {[html.onlyIfContent]: true}, + {class: slots.class}, + slots.content), +}; diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js new file mode 100644 index 0000000..0144c7f --- /dev/null +++ b/src/content/dependencies/generateSocialEmbed.js @@ -0,0 +1,65 @@ +export default { + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + canonicalBase: wikiInfo.canonicalBase, + shortWikiName: wikiInfo.nameShort, + }; + }, + + data(sprawl) { + return { + canonicalBase: sprawl.canonicalBase, + shortWikiName: sprawl.shortWikiName, + }; + }, + + slots: { + mode: {validate: v => v.is('html', 'json')}, + + title: {type: 'string'}, + description: {type: 'string'}, + + headingContent: {type: 'string'}, + headingLink: {type: 'string'}, + imagePath: {type: 'string'}, + }, + + generate(data, slots, {html, language}) { + switch (slots.mode) { + case 'html': + return html.tags([ + slots.title && + html.tag('meta', {property: 'og:title', content: slots.title}), + + slots.description && + html.tag('meta', { + property: 'og:description', + content: slots.description, + }), + + slots.imagePath && + html.tag('meta', {property: 'og:image', content: slots.imagePath}), + ]); + + case 'json': + return JSON.stringify({ + author_name: + (slots.headingContent + ? language.$('misc.socialEmbed.heading', { + wikiName: data.shortWikiName, + heading: slots.headingContent, + }) + : undefined), + + author_url: + (slots.headingLink && data.canonicalBase + ? data.canonicalBase.replace(/\/$/, '') + + '/' + + slots.headingLink.replace(/^\//, '') + : undefined), + }); + } + }, +}; diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js new file mode 100644 index 0000000..226152c --- /dev/null +++ b/src/content/dependencies/generateStaticPage.js @@ -0,0 +1,46 @@ +export default { + contentDependencies: ['generatePageLayout', 'transformContent'], + extraDependencies: ['html'], + + relations(relation, staticPage) { + return { + layout: relation('generatePageLayout'), + content: relation('transformContent', staticPage.content), + }; + }, + + data(staticPage) { + return { + name: staticPage.name, + stylesheet: staticPage.stylesheet, + script: staticPage.script, + }; + }, + + generate(data, relations, {html}) { + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + styleRules: + (data.stylesheet + ? [data.stylesheet] + : []), + + mainClasses: ['long-content'], + mainContent: [ + relations.content, + + data.script && + html.tag('script', data.script), + ], + + 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 0000000..9becfb2 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,34 @@ +export default { + extraDependencies: ['html'], + + slots: { + title: { + type: 'html', + mutable: false, + }, + + cover: { + type: 'html', + mutable: true, + }, + }, + + generate: (slots, {html}) => + html.tag('div', {class: 'content-sticky-heading-container'}, + !html.isBlank(slots.cover) && + {class: 'has-cover'}, + + [ + html.tag('div', {class: 'content-sticky-heading-row'}, [ + html.tag('h1', slots.title), + + !html.isBlank(slots.cover) && + 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/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js new file mode 100644 index 0000000..462557d --- /dev/null +++ b/src/content/dependencies/generateTextWithTooltip.js @@ -0,0 +1,62 @@ +export default { + extraDependencies: ['html'], + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + customInteractionCue: { + type: 'boolean', + default: false, + }, + + text: { + type: 'html', + mutable: false, + }, + + tooltip: { + type: 'html', + mutable: false, + }, + }, + + generate(slots, {html}) { + const hasTooltip = + !html.isBlank(slots.tooltip); + + if (slots.attributes.blank && !hasTooltip) { + return slots.text; + } + + let {attributes} = slots; + + if (hasTooltip) { + attributes = attributes.clone(); + attributes.add({ + [html.joinChildren]: '', + [html.noEdgeWhitespace]: true, + class: 'text-with-tooltip', + }); + } + + const textPart = + (hasTooltip && slots.customInteractionCue + ? html.tag('span', {class: 'hoverable'}, + slots.text) + : hasTooltip + ? html.tag('span', {class: 'hoverable'}, + html.tag('span', {class: 'text-with-tooltip-interaction-cue'}, + slots.text)) + : slots.text); + + const content = + (hasTooltip + ? [textPart, slots.tooltip] + : textPart); + + return html.tag('span', attributes, content); + }, +}; diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js new file mode 100644 index 0000000..81f74ae --- /dev/null +++ b/src/content/dependencies/generateTooltip.js @@ -0,0 +1,30 @@ +export default { + extraDependencies: ['html'], + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + contentAttributes: { + type: 'attributes', + mutable: false, + }, + + content: { + type: 'html', + mutable: false, + }, + }, + + generate: (slots, {html}) => + html.tag('span', {class: 'tooltip'}, + {[html.noEdgeWhitespace]: true}, + slots.attributes, + + html.tag('span', {class: 'tooltip-content'}, + {[html.noEdgeWhitespace]: true}, + slots.contentAttributes, + slots.content)), +}; diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js new file mode 100644 index 0000000..bad04b7 --- /dev/null +++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js @@ -0,0 +1,53 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['generateAdditionalNamesBox'], + extraDependencies: ['html'], + + query: (track) => { + const { + additionalNames: own, + sharedAdditionalNames: shared, + inferredAdditionalNames: inferred, + } = track; + + if (empty(own) && empty(shared) && empty(inferred)) { + return {combinedList: []}; + } + + const firstFilter = + (empty(own) + ? new Set() + : new Set(own.map(({name}) => name))); + + const sharedFiltered = + shared.filter(({name}) => !firstFilter.has(name)) + + const secondFilter = + new Set([ + ...firstFilter, + ...sharedFiltered.map(({name}) => name), + ]); + + const inferredFiltered = + inferred.filter(({name}) => !secondFilter.has(name)); + + return { + combinedList: [ + ...own, + ...sharedFiltered, + ...inferredFiltered, + ], + }; + }, + + relations: (relation, query) => ({ + box: + (empty(query.combinedList) + ? null + : relation('generateAdditionalNamesBox', query.combinedList)), + }), + + generate: (relations, {html}) => + relations.box ?? html.blank(), +}; diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js new file mode 100644 index 0000000..a241eaf --- /dev/null +++ b/src/content/dependencies/generateTrackCoverArtwork.js @@ -0,0 +1,34 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, track) => ({ + coverArtwork: + relation('generateCoverArtwork', + (track.hasUniqueCoverArt + ? track.artTags + : track.album.artTags)), + }), + + data: (track) => ({ + path: + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]), + + color: + track.color, + + dimensions: + (track.hasUniqueCoverArt + ? track.coverArtDimensions + : track.album.coverArtDimensions), + }), + + generate: (data, relations) => + relations.coverArtwork.slots({ + path: data.path, + color: data.color, + dimensions: data.dimensions, + }), +}; + diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 0000000..1b5fbbf --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,652 @@ +import {sortAlbumsTracksChronologically, sortFlashesChronologically} + from '#sort'; +import {empty, stitchArrays} from '#sugar'; + +import getChronologyRelations from '../util/getChronologyRelations.js'; + +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleAttribute', + 'generateCommentarySection', + 'generateContentHeading', + 'generateContributionList', + 'generatePageLayout', + 'generateRelativeDatetimestamp', + 'generateTrackAdditionalNamesBox', + 'generateTrackCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'generateTrackReleaseInfo', + 'generateTrackSocialEmbed', + 'linkAlbum', + 'linkArtist', + '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, track); + + relations.socialEmbed = + relation('generateTrackSocialEmbed', track); + + relations.artistChronologyContributions = + getChronologyRelations(track, { + contributions: [ + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ], + + linkArtist: artist => relation('linkArtist', artist), + linkThing: track => relation('linkTrack', track), + + getThings(artist) { + const getDate = thing => thing.date; + + const things = [ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ].filter(getDate); + + return sortAlbumsTracksChronologically(things, {getDate}); + }, + }); + + relations.coverArtistChronologyContributions = + getChronologyRelations(track, { + contributions: track.coverArtistContribs ?? [], + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings(artist) { + const getDate = thing => thing.coverArtDate ?? thing.date; + + const things = [ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ].filter(getDate); + + return sortAlbumsTracksChronologically(things, {getDate}); + }, + }), + + 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), + }); + + // This'll take care of itself being blank if there's nothing to show here. + relations.additionalNamesBox = + relation('generateTrackAdditionalNamesBox', track); + + 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.colorStyles = + track.otherReleases + .map(track => relation('generateColorStyleAttribute', track.color)); + + otherReleases.trackLinks = + track.otherReleases + .map(track => relation('linkTrack', track)); + + otherReleases.albumLinks = + track.otherReleases + .map(track => relation('linkAlbum', track.album)); + + otherReleases.datetimestamps = + track.otherReleases.map(track2 => + (track2.date + ? (track.date + ? relation('generateRelativeDatetimestamp', + track2.date, + track.date) + : relation('generateAbsoluteDatetimestamp', + track2.date)) + : null)); + + 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.list = + relation('generateContributionList', track.contributorContribs); + } + + // Section: Referenced tracks + + if (!empty(track.referencedTracks)) { + const references = sections.references = {}; + + references.heading = + relation('generateContentHeading'); + + references.list = + relation('generateTrackList', track.referencedTracks); + } + + // Section: Sampled tracks + + if (!empty(track.sampledTracks)) { + const samples = sections.samples = {}; + + samples.heading = + relation('generateContentHeading'); + + samples.list = + relation('generateTrackList', track.sampledTracks); + } + + // 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: 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) { + sections.artistCommentary = + relation('generateCommentarySection', track.commentary); + } + + return relations; + }, + + data(sprawl, track) { + return { + name: track.name, + color: track.color, + + 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', + + additionalNames: relations.additionalNamesBox, + + color: data.color, + styleRules: [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]: html.tag('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', + stitchArrays({ + trackLink: sec.otherReleases.trackLinks, + albumLink: sec.otherReleases.albumLinks, + datetimestamp: sec.otherReleases.datetimestamps, + colorStyle: sec.otherReleases.colorStyles, + }).map(({ + trackLink, + albumLink, + datetimestamp, + colorStyle, + }) => { + const parts = ['releaseInfo.alsoReleasedAs.item']; + const options = {}; + + options.track = trackLink.slot('color', false); + options.album = albumLink; + + if (datetimestamp) { + parts.push('withYear'); + options.year = + datetimestamp.slots({ + style: 'year', + tooltip: true, + }); + } + + return ( + html.tag('li', + colorStyle, + language.$(...parts, options))); + })), + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + sec.contributors.list, + ], + + sec.references && [ + sec.references.heading + .slots({ + id: 'references', + title: + language.$('releaseInfo.tracksReferenced', { + track: html.tag('i', data.name), + }), + }), + + sec.references.list, + ], + + sec.samples && [ + sec.samples.heading + .slots({ + id: 'samples', + title: + language.$('releaseInfo.tracksSampled', { + track: html.tag('i', data.name), + }), + }), + + sec.samples.list, + ], + + sec.referencedBy && [ + sec.referencedBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatReference', { + track: html.tag('i', data.name), + }), + }), + + sec.referencedBy.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, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.albumLink.slot('color', false)}, + { + 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, + }, + ], + }), + + leftSidebar: relations.sidebar, + + socialEmbed: relations.socialEmbed, + }); + }, +}; + +/* + 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 page = { + page: () => { + return { + theme: + getThemeString(track.color, { + additionalVariables: [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ] + }), + }; + }, + }; +*/ diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js new file mode 100644 index 0000000..3c36d24 --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,59 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkTrack', 'linkContribution'], + + extraDependencies: ['html', 'language'], + + relations(relation, tracks) { + if (empty(tracks)) { + return {}; + } + + return { + trackLinks: + tracks + .map(track => relation('linkTrack', track)), + + contributionLinks: + tracks + .map(track => + (empty(track.artistContribs) + ? null + : 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', + stitchArrays({ + trackLink: relations.trackLinks, + contributionLinks: relations.contributionLinks, + }).map(({trackLink, contributionLinks}) => + html.tag('li', + (empty(contributionLinks) + ? trackLink + : language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + html.metatag('chunkwrap', {split: ','}, + 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 0000000..e070ac3 --- /dev/null +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -0,0 +1,53 @@ +import {empty} from '#sugar'; + +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 0000000..3bdeaa4 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -0,0 +1,90 @@ +import {empty} from '#sugar'; + +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}) => + 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 + .map(link => link.slot('context', 'track'))), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + ]), +}; diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js new file mode 100644 index 0000000..0337fc4 --- /dev/null +++ b/src/content/dependencies/generateTrackSocialEmbed.js @@ -0,0 +1,86 @@ +export default { + contentDependencies: [ + 'generateSocialEmbed', + 'generateTrackSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language', 'urls'], + + relations(relation, track) { + return { + socialEmbed: + relation('generateSocialEmbed'), + + description: + relation('generateTrackSocialEmbedDescription', track), + }; + }, + + data(track) { + const {album} = track; + const data = {}; + + data.trackName = track.name; + data.albumName = album.name; + + data.trackDirectory = track.directory; + data.albumDirectory = album.directory; + + if (track.hasUniqueCoverArt) { + data.imageSource = 'track'; + data.coverArtFileExtension = track.coverArtFileExtension; + } else if (album.hasCoverArt) { + data.imageSource = 'album'; + data.coverArtFileExtension = album.coverArtFileExtension; + } else { + data.imageSource = 'none'; + } + + return data; + }, + + generate(data, relations, {absoluteTo, language, urls}) { + return relations.socialEmbed.slots({ + title: + language.$('trackPage.socialEmbed.title', { + track: data.trackName, + }), + + headingContent: + language.$('trackPage.socialEmbed.heading', { + album: data.albumName, + }), + + headingLink: + absoluteTo('localized.album', data.albumDirectory), + + imagePath: + (data.imageSource === 'album' + ? '/' + + urls + .from('shared.root') + .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension) + : data.imageSource === 'track' + ? '/' + + urls + .from('shared.root') + .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension) + : null), + }); + }, +}; + +/* + 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, + }, +*/ diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js new file mode 100644 index 0000000..cf21ead --- /dev/null +++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js @@ -0,0 +1,38 @@ +export default { + generate() { + }, +}; + +/* + 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) + ) + ); + }; +*/ diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js new file mode 100644 index 0000000..a19f104 --- /dev/null +++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js @@ -0,0 +1,150 @@ +import {empty, stitchArrays} from '#sugar'; +import {getNewAdditions, getNewReleases} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateWikiHomeContentRow', + 'generateCoverCarousel', + 'generateCoverGrid', + 'image', + 'linkAlbum', + 'transformContent', + ], + + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}, row) { + const sprawl = {}; + + switch (row.sourceGroup) { + case 'new-releases': + sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData}); + break; + + case 'new-additions': + sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData}); + break; + + default: + sprawl.albums = + (row.sourceGroup + ? row.sourceGroup.albums + .slice() + .reverse() + .filter(album => album.isListedOnHomepage) + .slice(0, row.countAlbumsFromGroup) + : []); + } + + if (!empty(row.sourceAlbums)) { + sprawl.albums.push(...row.sourceAlbums); + } + + return sprawl; + }, + + relations(relation, sprawl, row) { + const relations = {}; + + relations.contentRow = + relation('generateWikiHomeContentRow', row); + + if (row.displayStyle === 'grid') { + relations.coverGrid = + relation('generateCoverGrid'); + } + + if (row.displayStyle === 'carousel') { + relations.coverCarousel = + relation('generateCoverCarousel'); + } + + relations.links = + sprawl.albums + .map(album => relation('linkAlbum', album)); + + relations.images = + sprawl.albums + .map(album => relation('image', album.artTags)); + + if (row.actionLinks) { + relations.actionLinks = + row.actionLinks + .map(content => relation('transformContent', content)); + } + + return relations; + }, + + data(sprawl, row) { + const data = {}; + + data.displayStyle = row.displayStyle; + + if (row.displayStyle === 'grid') { + data.names = + sprawl.albums + .map(album => album.name); + } + + data.paths = + sprawl.albums + .map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + + return data; + }, + + generate(data, relations, {language}) { + // Grids and carousels share some slots! Very convenient. + const commonSlots = {}; + + commonSlots.links = + relations.links; + + commonSlots.images = + stitchArrays({ + image: relations.images, + path: data.paths, + name: data.names ?? data.paths.slice().fill(null), + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + name && + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })); + + commonSlots.actionLinks = + (relations.actionLinks + ? relations.actionLinks + .map(contents => + contents + .slot('mode', 'single-link') + .content) + : null); + + let content; + + switch (data.displayStyle) { + case 'grid': + content = + relations.coverGrid.slots({ + ...commonSlots, + names: data.names, + }); + break; + + case 'carousel': + content = + relations.coverCarousel.slots(commonSlots); + break; + } + + return relations.contentRow.slots({content}); + }, +}; diff --git a/src/content/dependencies/generateWikiHomeContentRow.js b/src/content/dependencies/generateWikiHomeContentRow.js new file mode 100644 index 0000000..27b12e5 --- /dev/null +++ b/src/content/dependencies/generateWikiHomeContentRow.js @@ -0,0 +1,28 @@ +export default { + contentDependencies: ['generateColorStyleAttribute'], + extraDependencies: ['html'], + + relations: (relation, row) => ({ + colorStyle: + relation('generateColorStyleAttribute', row.color), + }), + + data: (row) => + ({name: row.name}), + + slots: { + content: { + type: 'html', + mutable: false, + }, + }, + + generate: (data, relations, slots, {html}) => + html.tag('section', {class: 'row'}, + relations.colorStyle, + + [ + html.tag('h2', data.name), + slots.content, + ]), +}; diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js new file mode 100644 index 0000000..e054edd --- /dev/null +++ b/src/content/dependencies/generateWikiHomeNewsBox.js @@ -0,0 +1,85 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageSidebarBox', + 'linkNewsEntry', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({newsData}) => ({ + entries: + newsData.slice(0, 3), + }), + + relations: (relation, sprawl) => ({ + box: + relation('generatePageSidebarBox'), + + entryContents: + sprawl.entries + .map(entry => relation('transformContent', entry.contentShort)), + + entryMainLinks: + sprawl.entries + .map(entry => relation('linkNewsEntry', entry)), + + entryReadMoreLinks: + sprawl.entries + .map(entry => + entry.contentShort !== entry.content && + relation('linkNewsEntry', entry)), + }), + + data: (sprawl) => ({ + entryDates: + sprawl.entries + .map(entry => entry.date), + }), + + generate(data, relations, {html, language}) { + if (empty(relations.entryContents)) { + return html.blank(); + } + + return relations.box.slots({ + attributes: {class: 'latest-news-sidebar-box'}, + content: [ + html.tag('h1', language.$('homepage.news.title')), + + stitchArrays({ + date: data.entryDates, + content: relations.entryContents, + mainLink: relations.entryMainLinks, + readMoreLink: relations.entryReadMoreLinks, + }).map(({ + date, + content, + mainLink, + readMoreLink, + }, index) => + html.tag('article', {class: 'news-entry'}, + index === 0 && + {class: 'first-news-entry'}, + + [ + html.tag('h2', [ + html.tag('time', language.formatDate(date)), + mainLink, + ]), + + content.slot('thumb', 'medium'), + + html.tag('p', + {[html.onlyIfContent]: true}, + readMoreLink + ?.slots({ + content: language.$('homepage.news.entry.viewRest'), + })), + ])), + ], + }); + }, +}; diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js new file mode 100644 index 0000000..35461d0 --- /dev/null +++ b/src/content/dependencies/generateWikiHomePage.js @@ -0,0 +1,115 @@ +export default { + contentDependencies: [ + 'generatePageLayout', + 'generatePageSidebar', + 'generatePageSidebarBox', + 'generateWikiHomeAlbumsRow', + 'generateWikiHomeNewsBox', + 'transformContent', + ], + + extraDependencies: ['wikiData'], + + sprawl({wikiInfo}) { + return { + wikiName: wikiInfo.name, + + enableNews: wikiInfo.enableNews, + }; + }, + + relations(relation, sprawl, homepageLayout) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.sidebar = + relation('generatePageSidebar'); + + if (homepageLayout.sidebarContent) { + relations.customSidebarBox = + relation('generatePageSidebarBox'); + + relations.customSidebarContent = + relation('transformContent', homepageLayout.sidebarContent); + } + + if (sprawl.enableNews) { + relations.newsSidebarBox = + relation('generateWikiHomeNewsBox'); + } + + if (homepageLayout.navbarLinks) { + relations.customNavLinkContents = + homepageLayout.navbarLinks + .map(content => relation('transformContent', content)); + } + + relations.contentRows = + homepageLayout.rows.map(row => { + switch (row.type) { + case 'albums': + return relation('generateWikiHomeAlbumsRow', row); + default: + return null; + } + }); + + return relations; + }, + + data(sprawl) { + return { + wikiName: sprawl.wikiName, + }; + }, + + generate(data, relations) { + return relations.layout.slots({ + title: data.wikiName, + showWikiNameInTitle: false, + + mainClasses: ['top-index'], + headingMode: 'static', + + mainContent: [ + relations.contentRows, + ], + + leftSidebar: + relations.sidebar.slots({ + collapse: false, + wide: true, + + boxes: [ + relations.customSidebarContent && + relations.customSidebarBox.slots({ + attributes: {class: 'custom-content-sidebar-box'}, + content: + relations.customSidebarContent + .slot('mode', 'multiline'), + }), + + relations.newsSidebarBox, + ], + }), + + navLinkStyle: 'index', + navLinks: [ + {auto: 'home', current: true}, + + ...( + relations.customNavLinkContents + ?.map(content => ({ + html: + content.slots({ + mode: 'single-link', + preferShortLinkNames: true, + }), + })) + ?? []), + ], + }); + }, +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js new file mode 100644 index 0000000..6b24f38 --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,383 @@ +import {logInfo, logWarn} from '#cli'; +import {empty} from '#sugar'; + +export default { + extraDependencies: [ + 'cachebust', + 'checkIfImagePathHasCachedThumbnails', + 'getDimensionsOfImagePath', + 'getSizeOfImagePath', + 'getThumbnailEqualOrSmaller', + 'getThumbnailsAvailableForDimensions', + 'html', + 'language', + 'missingImagePaths', + 'to', + ], + + contentDependencies: ['generateColorStyleAttribute'], + + relations: (relation) => ({ + colorStyle: + relation('generateColorStyleAttribute'), + }), + + 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'}, + + link: { + validate: v => v.anyOf(v.isBoolean, v.isString), + default: false, + }, + + color: { + validate: v => v.isColor, + }, + + warnings: { + validate: v => v.looseArrayOf(v.isString), + }, + + reveal: {type: 'boolean', default: true}, + lazy: {type: 'boolean', default: false}, + + square: {type: 'boolean', default: false}, + + dimensions: { + validate: v => v.isDimensions, + }, + + alt: {type: 'string'}, + + attributes: { + type: 'attributes', + mutable: false, + }, + + missingSourceContent: { + type: 'html', + mutable: false, + }, + }, + + generate(data, relations, slots, { + cachebust, + checkIfImagePathHasCachedThumbnails, + getDimensionsOfImagePath, + getSizeOfImagePath, + getThumbnailEqualOrSmaller, + getThumbnailsAvailableForDimensions, + html, + language, + missingImagePaths, + to, + }) { + let originalSrc; + + if (slots.src) { + originalSrc = slots.src; + } else if (!empty(slots.path)) { + originalSrc = to(...slots.path); + } else { + originalSrc = ''; + } + + // TODO: This feels janky. It's necessary to deal with static content that + // includes strings like <img src="media/misc/foo.png">, but processing the + // src string directly when a parts-formed path *is* available seems wrong. + // It should be possible to do urls.from(slots.path[0]).to(...slots.path), + // for example, but will require reworking the control flow here a little. + let mediaSrc = null; + if (originalSrc.startsWith(to('media.root'))) { + mediaSrc = + originalSrc + .slice(to('media.root').length) + .replace(/^\//, ''); + } + + const isMissingImageFile = + missingImagePaths.includes(mediaSrc); + + if (isMissingImageFile) { + logInfo`No image file for ${mediaSrc} - build again for list of missing images.`; + } + + const willLink = + !isMissingImageFile && + (typeof slots.link === 'string' || slots.link); + + const contentWarnings = + slots.warnings ?? + data.contentWarnings; + + const willReveal = + slots.reveal && + originalSrc && + !isMissingImageFile && + !empty(contentWarnings); + + const hasBothDimensions = + !!(slots.dimensions && + slots.dimensions[0] !== null && + slots.dimensions[1] !== null); + + const willSquare = + (hasBothDimensions + ? slots.dimensions[0] === slots.dimensions[1] + : slots.square); + + const imgAttributes = html.attributes([ + {class: 'image'}, + + slots.alt && {alt: slots.alt}, + + slots.dimensions?.[0] && + {width: slots.dimensions[0]}, + + slots.dimensions?.[1] && + {width: slots.dimensions[1]}, + ]); + + const isPlaceholder = + !originalSrc || isMissingImageFile; + + if (isPlaceholder) { + return ( + prepare( + html.tag('div', {class: 'image-text-area'}, + (html.isBlank(slots.missingSourceContent) + ? language.$('misc.missingImage') + : slots.missingSourceContent)), + 'visible')); + } + + let reveal = null; + if (willReveal) { + reveal = [ + html.tag('img', {class: 'reveal-symbol'}, + {src: to('shared.staticFile', 'warning.svg', cachebust)}), + + html.tag('br'), + + html.tag('span', {class: 'reveal-warnings'}, + language.$('misc.contentWarnings.warnings', { + warnings: language.formatUnitList(contentWarnings), + })), + + html.tag('br'), + + html.tag('span', {class: 'reveal-interaction'}, + language.$('misc.contentWarnings.reveal')), + ]; + } + + const hasThumbnails = + mediaSrc && + checkIfImagePathHasCachedThumbnails(mediaSrc); + + // Warn for images that *should* have cached thumbnail information but are + // missing from the thumbs cache. + if ( + slots.thumb && + !hasThumbnails && + !mediaSrc.endsWith('.gif') + ) { + logWarn`No thumbnail info cached: ${mediaSrc} - displaying original image here (instead of ${slots.thumb})`; + } + + let displaySrc = originalSrc; + + // This is only distinguished from displaySrc by being a thumbnail, + // so it won't be set if thumbnails aren't available. + let revealSrc = null; + + // If thumbnails are available *and* being used, calculate thumbSrc, + // and provide some attributes relevant to the large image overlay. + if (hasThumbnails && slots.thumb) { + const selectedSize = + getThumbnailEqualOrSmaller(slots.thumb, mediaSrc); + + const mediaSrcJpeg = + mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); + + displaySrc = + to('thumb.path', mediaSrcJpeg); + + if (willReveal) { + const miniSize = + getThumbnailEqualOrSmaller('mini', mediaSrc); + + const mediaSrcJpeg = + mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`); + + revealSrc = + to('thumb.path', mediaSrcJpeg); + } + + const originalDimensions = getDimensionsOfImagePath(mediaSrc); + const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); + const originalLength = Math.max(originalDimensions[0], originalDimensions[1]); + + const fileSize = + (willLink && mediaSrc + ? getSizeOfImagePath(mediaSrc) + : null); + + imgAttributes.add([ + fileSize && + {'data-original-size': fileSize}, + + originalLength && + {'data-original-length': originalLength}, + + !empty(availableThumbs) && + {'data-thumbs': + availableThumbs + .map(([name, size]) => `${name}:${size}`) + .join(' ')}, + ]); + } + + if (!displaySrc) { + return ( + prepare( + html.tag('img', imgAttributes), + 'visible')); + } + + const images = { + displayStatic: + html.tag('img', + imgAttributes, + {src: displaySrc}), + + displayLazy: + slots.lazy && + html.tag('img', + imgAttributes, + {class: 'lazy', 'data-original': displaySrc}), + + revealStatic: + revealSrc && + html.tag('img', {class: 'reveal-thumbnail'}, + imgAttributes, + {src: revealSrc}), + + revealLazy: + slots.lazy && + revealSrc && + html.tag('img', {class: 'reveal-thumbnail'}, + imgAttributes, + {class: 'lazy', 'data-original': revealSrc}), + }; + + const staticImageContent = + html.tags([images.displayStatic, images.revealStatic]); + + if (slots.lazy) { + const lazyImageContent = + html.tags([images.displayLazy, images.revealLazy]); + + return html.tags([ + html.tag('noscript', + prepare(staticImageContent, 'visible')), + + prepare(lazyImageContent, 'hidden'), + ]); + } else { + return prepare(staticImageContent, 'visible'); + } + + function prepare(imageContent, visibility) { + let wrapped = imageContent; + + if (willReveal) { + wrapped = + html.tags([ + wrapped, + html.tag('span', {class: 'reveal-text-container'}, + html.tag('span', {class: 'reveal-text'}, + reveal)), + ]); + } + + wrapped = + html.tag('div', {class: 'image-inner-area'}, + wrapped); + + if (willLink) { + wrapped = + html.tag('a', {class: 'image-link'}, + (typeof slots.link === 'string' + ? {href: slots.link} + : {href: originalSrc}), + + wrapped); + } + + wrapped = + html.tag('div', {class: 'image-outer-area'}, + willSquare && + {class: 'square-content'}, + + wrapped); + + wrapped = + html.tag('div', {class: 'image-container'}, + willSquare && + {class: 'square'}, + + typeof slots.link === 'string' && + {class: 'no-image-preview'}, + + (isPlaceholder + ? {class: 'placeholder-image'} + : [ + willLink && + {class: 'has-link'}, + + willReveal && + {class: 'reveal'}, + + revealSrc && + {class: 'has-reveal-thumbnail'}, + ]), + + visibility === 'hidden' && + {class: 'js-hide'}, + + slots.color && + relations.colorStyle.slots({ + color: slots.color, + context: 'image-box', + }), + + slots.attributes, + + wrapped); + + return wrapped; + } + }, +}; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js new file mode 100644 index 0000000..a500980 --- /dev/null +++ b/src/content/dependencies/index.js @@ -0,0 +1,274 @@ +import EventEmitter from 'node:events'; +import {readdir} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import chokidar from 'chokidar'; +import {ESLint} from 'eslint'; + +import {showAggregate as _showAggregate} from '#aggregate'; +import {colors, logWarn} from '#cli'; +import contentFunction, {ContentFunctionSpecError} from '#content-function'; +import {annotateFunction} from '#sugar'; + +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, + showAggregate = _showAggregate, +} = {}) { + const events = new EventEmitter(); + const contentDependencies = {}; + + let emittedReady = false; + let emittedErrorForFunctions = new Set(); + 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(watchPath).then(files => { + if (closed) { + return; + } + + const filePaths = files.map(file => path.join(watchPath, file)); + for (const filePath of filePaths) { + if (filePath === metaPath) continue; + const functionName = getFunctionName(filePath); + if (!isMocked(functionName)) { + contentDependencies[functionName] = null; + } + } + + const watcher = chokidar.watch(watchPath); + + 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 { + const module = + await import( + cachebust( + './' + + path + .relative(metaDirname, filePath) + .split(path.sep) + .join('/'))); + spec = module.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) { + // For practical purposes the file is treated as though it doesn't + // even exist (undefined), rather than not being ready yet (null). + // Apart from if existing contents of the file were erased (but not + // the file itself), this value might already be set (to null!) by + // the readdir performed at the beginning to evaluate which files + // should be read and processed at least once before reporting all + // dependencies as ready. + delete contentDependencies[functionName]; + return; + } + + let fn; + try { + fn = processFunctionSpec(functionName, spec); + } catch (caughtError) { + error = caughtError; + break main; + } + + const emittedError = emittedErrorForFunctions.has(functionName); + if (logging && (emittedReady || emittedError)) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(colors.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); + emittedErrorForFunctions.add(functionName); + + 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(colors.yellow(error)); + } else if (error instanceof ContentFunctionSpecError) { + console.error(colors.yellow(error.message)); + } else { + showAggregate(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 0000000..36b0d13 --- /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 0000000..39e7111 --- /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 0000000..ab519fd --- /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/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js new file mode 100644 index 0000000..3adc64d --- /dev/null +++ b/src/content/dependencies/linkAlbumDynamically.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkAlbumGallery', 'linkAlbum'], + extraDependencies: ['pagePath'], + + relations: (relation, album) => ({ + galleryLink: relation('linkAlbumGallery', album), + infoLink: relation('linkAlbum', album), + }), + + generate: (relations, {pagePath}) => + (pagePath[0] === 'albumGallery' + ? relations.galleryLink + : relations.infoLink), +}; diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js new file mode 100644 index 0000000..e3f30a2 --- /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 0000000..7ddb778 --- /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 0000000..718ee6f --- /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 0000000..66dc172 --- /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/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js new file mode 100644 index 0000000..5568ff8 --- /dev/null +++ b/src/content/dependencies/linkCommentaryIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.commentaryIndex', + 'commentaryIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js new file mode 100644 index 0000000..41ce114 --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,145 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateTextWithTooltip', + 'generateTooltip', + 'linkArtist', + 'linkExternalAsIcon', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, contribution) { + const relations = {}; + + relations.artistLink = + relation('linkArtist', contribution.who); + + relations.textWithTooltip = + relation('generateTextWithTooltip'); + + relations.tooltip = + relation('generateTooltip'); + + if (!empty(contribution.who.urls)) { + relations.artistIcons = + contribution.who.urls + .map(url => relation('linkExternalAsIcon', url)); + } + + return relations; + }, + + data(contribution) { + return { + what: contribution.what, + urls: contribution.who.urls, + }; + }, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + preventWrapping: {type: 'boolean', default: true}, + + iconMode: { + validate: v => v.is('inline', 'tooltip'), + default: 'inline' + }, + }, + + generate(data, relations, slots, {html, language}) { + const hasContribution = !!(slots.showContribution && data.what); + const hasExternalIcons = !!(slots.showIcons && relations.artistIcons); + + const parts = ['misc.artistLink']; + const options = {}; + + options.artist = + (hasExternalIcons && slots.iconMode === 'tooltip' + ? relations.textWithTooltip.slots({ + customInteractionCue: true, + + text: + relations.artistLink.slots({ + attributes: {class: 'text-with-tooltip-interaction-cue'}, + }), + + tooltip: + relations.tooltip.slots({ + attributes: + {class: ['icons', 'icons-tooltip']}, + + contentAttributes: + {[html.joinChildren]: ''}, + + content: + stitchArrays({ + icon: relations.artistIcons, + url: data.urls, + }).map(({icon, url}) => { + icon.setSlots({ + context: 'artist', + withText: true, + }); + + let platformText = + language.formatExternalLink(url, { + context: 'artist', + style: 'platform', + }); + + // This is a pretty ridiculous hack, but we currently + // don't have a way of telling formatExternalLink to *not* + // use the fallback string, which just formats the URL as + // its host/domain... so is technically detectable. + if (platformText.toString() === (new URL(url)).host) { + platformText = + language.$('misc.artistLink.noExternalLinkPlatformName'); + } + + const platformSpan = + html.tag('span', {class: 'icon-platform'}, + platformText); + + return [icon, platformSpan]; + }), + }), + }) + : relations.artistLink); + + if (hasContribution) { + parts.push('withContribution'); + options.contrib = data.what; + } + + if (hasExternalIcons && slots.iconMode === 'inline') { + parts.push('withExternalLinks'); + options.links = + html.tag('span', {class: ['icons', 'icons-inline']}, + {[html.noEdgeWhitespace]: true}, + language.formatUnitList( + relations.artistIcons + .slice(0, 4) + .map(icon => icon.slot('context', 'artist')))); + } + + const contributionPart = + language.formatString(...parts, options); + + if (!hasContribution && !hasExternalIcons) { + return contributionPart; + } + + return ( + html.tag('span', {class: 'contribution'}, + {[html.noEdgeWhitespace]: true}, + + parts.length > 1 && + slots.preventWrapping && + {class: 'nowrap'}, + + contributionPart)); + }, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 0000000..f6b47db --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,136 @@ +import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; + +export default { + extraDependencies: ['html', 'language', 'wikiData'], + + data: (url) => ({url}), + + slots: { + content: { + type: 'html', + mutable: false, + }, + + style: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkStyle, + default: 'platform', + }, + + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + + fromContent: { + type: 'boolean', + default: false, + }, + + indicateExternal: { + type: 'boolean', + default: false, + }, + + tab: { + validate: v => v.is('default', 'separate'), + default: 'default', + }, + }, + + generate(data, slots, {html, language}) { + let urlIsValid; + try { + new URL(data.url); + urlIsValid = true; + } catch (error) { + urlIsValid = false; + } + + let formattedLink; + if (urlIsValid) { + formattedLink = + language.formatExternalLink(data.url, { + style: slots.style, + context: slots.context, + }); + + // Fall back to platform if nothing matched the desired style. + if (html.isBlank(formattedLink) && slots.style !== 'platform') { + formattedLink = + language.formatExternalLink(data.url, { + style: 'platform', + context: slots.context, + }); + } + } else { + formattedLink = null; + } + + const linkAttributes = html.attributes({ + class: 'external-link', + }); + + let linkContent; + if (urlIsValid) { + linkAttributes.set('href', data.url); + + if (html.isBlank(slots.content)) { + linkContent = formattedLink; + } else { + linkContent = slots.content; + } + } else { + if (html.isBlank(slots.content)) { + linkContent = + html.tag('i', + language.$('misc.external.invalidURL.annotation')); + } else { + linkContent = + language.$('misc.external.invalidURL', { + link: slots.content, + annotation: + html.tag('i', + language.$('misc.external.invalidURL.annotation')), + }); + } + } + + if (slots.fromContent) { + linkAttributes.add('class', 'from-content'); + } + + if (urlIsValid && slots.indicateExternal) { + linkAttributes.add('class', 'indicate-external'); + + let titleText; + if (slots.tab === 'separate') { + if (html.isBlank(slots.content)) { + titleText = + language.$('misc.external.opensInNewTab.annotation'); + } else { + titleText = + language.$('misc.external.opensInNewTab', { + link: formattedLink, + annotation: + language.$('misc.external.opensInNewTab.annotation'), + }); + } + } else if (!html.isBlank(slots.content)) { + titleText = formattedLink; + } + + if (titleText) { + linkAttributes.set('title', titleText.toString()); + } + } + + if (urlIsValid && slots.tab === 'separate') { + linkAttributes.set('target', '_blank'); + } + + return html.tag('a', linkAttributes, linkContent); + }, +}; diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js new file mode 100644 index 0000000..6f37529 --- /dev/null +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -0,0 +1,51 @@ +import {isExternalLinkContext} from '#external-links'; + +export default { + extraDependencies: ['html', 'language', 'to'], + + data: (url) => ({url}), + + slots: { + context: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkContext, + default: 'generic', + }, + + withText: {type: 'boolean'}, + }, + + generate(data, slots, {html, language, to}) { + const format = style => + language.formatExternalLink(data.url, {style, context: slots.context}); + + const platformText = format('platform'); + const handleText = format('handle'); + const iconId = format('icon-id'); + + return html.tag('a', {class: 'icon'}, + {href: data.url}, + + slots.withText && + {class: 'has-text'}, + + [ + html.tag('svg', [ + !slots.withText && + html.tag('title', platformText), + + html.tag('use', { + href: to('shared.staticIcon', iconId), + }), + ]), + + slots.withText && + html.tag('span', {class: 'icon-text'}, + (html.isBlank(handleText) + ? platformText + : handleText)), + ]); + }, +}; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js new file mode 100644 index 0000000..93dd5a2 --- /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/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js new file mode 100644 index 0000000..fbb819e --- /dev/null +++ b/src/content/dependencies/linkFlashAct.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkThing'], + extraDependencies: ['html'], + + relations: (relation, flashAct) => + ({link: relation('linkThing', 'localized.flashActGallery', flashAct)}), + + data: (flashAct) => + ({name: flashAct.name}), + + generate: (data, relations, {html}) => + relations.link + .slot('content', new html.Tag(null, null, data.name)), +}; diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js new file mode 100644 index 0000000..6dd0710 --- /dev/null +++ b/src/content/dependencies/linkFlashIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.flashIndex', + 'flashIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js new file mode 100644 index 0000000..ebab1b5 --- /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/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js new file mode 100644 index 0000000..90303ed --- /dev/null +++ b/src/content/dependencies/linkGroupDynamically.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkGroupGallery', 'linkGroup'], + extraDependencies: ['pagePath'], + + relations: (relation, group) => ({ + galleryLink: relation('linkGroupGallery', group), + infoLink: relation('linkGroup', group), + }), + + generate: (relations, {pagePath}) => + (pagePath[0] === 'groupGallery' + ? relations.galleryLink + : relations.infoLink), +}; diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js new file mode 100644 index 0000000..bc3c058 --- /dev/null +++ b/src/content/dependencies/linkGroupExtra.js @@ -0,0 +1,34 @@ +import {empty} from '#sugar'; + +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 0000000..86c4a0f --- /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 0000000..ac66919 --- /dev/null +++ b/src/content/dependencies/linkListing.js @@ -0,0 +1,15 @@ +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 0000000..1bfaf46 --- /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 0000000..1fb32dd --- /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/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js new file mode 100644 index 0000000..e911a38 --- /dev/null +++ b/src/content/dependencies/linkNewsIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.newsIndex', + 'newsIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js new file mode 100644 index 0000000..34a2b85 --- /dev/null +++ b/src/content/dependencies/linkPathFromMedia.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations: (relation) => + ({link: relation('linkTemplate')}), + + data: (path) => + ({path}), + + generate: (data, relations) => + relations.link + .slot('path', ['media.path', data.path]), +}; diff --git a/src/content/dependencies/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js new file mode 100644 index 0000000..dab3ac1 --- /dev/null +++ b/src/content/dependencies/linkPathFromRoot.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations: (relation) => + ({link: relation('linkTemplate')}), + + data: (path) => + ({path}), + + generate: (data, relations) => + relations.link + .slot('path', ['shared.path', data.path]), +}; diff --git a/src/content/dependencies/linkPathFromSite.js b/src/content/dependencies/linkPathFromSite.js new file mode 100644 index 0000000..6467646 --- /dev/null +++ b/src/content/dependencies/linkPathFromSite.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations: (relation) => + ({link: relation('linkTemplate')}), + + data: (path) => + ({path}), + + generate: (data, relations) => + relations.link + .slot('path', ['localized.path', data.path]), +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js new file mode 100644 index 0000000..032af6c --- /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 0000000..d5506e6 --- /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 0000000..63cc82e --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,73 @@ +import {empty} from '#sugar'; + +import striptags from 'striptags'; + +export default { + extraDependencies: [ + 'appendIndexHTML', + 'html', + 'language', + 'to', + ], + + slots: { + href: {type: 'string'}, + path: {validate: v => v.validateArrayItems(v.isString)}, + hash: {type: 'string'}, + linkless: {type: 'boolean', default: false}, + tooltip: {type: 'string'}, + + attributes: { + type: 'attributes', + mutable: true, + }, + + content: { + type: 'html', + mutable: false, + }, + }, + + generate(slots, { + appendIndexHTML, + html, + language, + to, + }) { + const {attributes} = slots; + + if (!slots.linkless) { + let href = + (slots.href + ? encodeURI(slots.href) + : !empty(slots.path) + ? to(...slots.path) + : ''); + + if (appendIndexHTML) { + if (/^(?!https?:\/\/).+\/$/.test(href) && href.endsWith('/')) { + href += 'index.html'; + } + } + + if (slots.hash) { + href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash; + } + + attributes.add({href}); + } + + if (slots.tooltip) { + attributes.set('title', slots.tooltip); + } + + const content = + (html.isBlank(slots.content) + ? language.$('misc.missingLinkContent') + : striptags(html.resolve(slots.content, {normalize: 'string'}), { + disallowedTags: new Set(['a']), + })); + + return html.tag('a', attributes, content); + }, +} diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js new file mode 100644 index 0000000..3902f38 --- /dev/null +++ b/src/content/dependencies/linkThing.js @@ -0,0 +1,154 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateTextWithTooltip', + 'generateTooltip', + 'linkTemplate', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, _pathKey, thing) => ({ + linkTemplate: + relation('linkTemplate'), + + colorStyle: + relation('generateColorStyleAttribute', thing.color ?? null), + + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + }), + + data: (pathKey, thing) => ({ + name: thing.name, + nameShort: thing.nameShort ?? thing.shortName, + + path: + (pathKey + ? [pathKey, thing.directory] + : null), + }), + + slots: { + content: { + type: 'html', + mutable: false, + }, + + attributes: { + type: 'attributes', + mutable: true, + }, + + preferShortName: { + type: 'boolean', + default: false, + }, + + tooltipStyle: { + validate: v => v.is('none', 'auto', 'browser', 'wiki'), + default: 'auto', + }, + + color: { + validate: v => v.anyOf(v.isBoolean, v.isColor), + default: true, + }, + + colorContext: { + validate: v => v.is( + 'image-box', + 'primary-only'), + + default: 'primary-only', + }, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + anchor: {type: 'boolean', default: false}, + linkless: {type: 'boolean', default: false}, + hash: {type: 'string'}, + }, + + generate(data, relations, slots, {html, language}) { + const path = + slots.path ?? data.path; + + const linkAttributes = slots.attributes; + const wrapperAttributes = html.attributes(); + + const showShortName = + (slots.preferShortName + ? data.nameShort && data.nameShort !== data.name + : false); + + const name = + (showShortName + ? data.nameShort + : data.name); + + const showWikiTooltip = + (slots.tooltipStyle === 'auto' + ? showShortName + : slots.tooltipStyle === 'wiki'); + + const wikiTooltip = + showWikiTooltip && + relations.tooltip.slots({ + attributes: {class: 'thing-name-tooltip'}, + content: data.name, + }); + + if (slots.tooltipStyle === 'browser') { + linkAttributes.add('title', data.name); + } + + if (showWikiTooltip) { + linkAttributes.add('class', 'text-with-tooltip-interaction-cue'); + } + + const content = + (html.isBlank(slots.content) + ? language.sanitize(name) + : slots.content); + + if (slots.color !== false) { + const {colorStyle} = relations; + + colorStyle.setSlot('context', slots.colorContext); + + if (typeof slots.color === 'string') { + colorStyle.setSlot('color', slots.color); + } + + if (showWikiTooltip) { + wrapperAttributes.add(colorStyle); + } else { + linkAttributes.add(colorStyle); + } + } + + return relations.textWithTooltip.slots({ + attributes: wrapperAttributes, + customInteractionCue: true, + + text: + relations.linkTemplate.slots({ + path: slots.anchor ? [] : path, + href: slots.anchor ? '' : null, + attributes: linkAttributes, + hash: slots.hash, + linkless: slots.linkless, + content, + }), + + tooltip: + wikiTooltip ?? null, + }); + }, +} diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js new file mode 100644 index 0000000..d5d9672 --- /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/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js new file mode 100644 index 0000000..242cd4c --- /dev/null +++ b/src/content/dependencies/linkTrackDynamically.js @@ -0,0 +1,34 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['pagePath'], + + relations: (relation, track) => ({ + infoLink: relation('linkTrack', track), + }), + + data: (track) => ({ + trackDirectory: + track.directory, + + albumDirectory: + track.album.directory, + + trackHasCommentary: + !!track.commentary, + }), + + generate(data, relations, {pagePath}) { + if ( + pagePath[0] === 'albumCommentary' && + pagePath[1] === data.albumDirectory && + data.trackHasCommentary + ) { + relations.infoLink.setSlots({ + anchor: true, + hash: data.trackDirectory, + }); + } + + return relations.infoLink; + }, +}; diff --git a/src/content/dependencies/linkWikiHome.js b/src/content/dependencies/linkWikiHome.js new file mode 100644 index 0000000..d8d3d0a --- /dev/null +++ b/src/content/dependencies/linkWikiHome.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['wikiData'], + + sprawl({wikiInfo}) { + return {wikiShortName: wikiInfo.nameShort}; + }, + + relations: (relation) => + ({link: relation('linkTemplate')}), + + data: (sprawl) => + ({wikiShortName: sprawl.wikiShortName}), + + generate: (data, relations) => + relations.link.slots({ + path: ['localized.home'], + content: data.wikiShortName, + }), +}; diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js new file mode 100644 index 0000000..c83ffc9 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDate.js @@ -0,0 +1,52 @@ +import {sortChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +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 0000000..d462ad4 --- /dev/null +++ b/src/content/dependencies/listAlbumsByDateAdded.js @@ -0,0 +1,60 @@ +import {sortAlphabetically} from '#sort'; +import {chunkByProperties} from '#sugar'; + +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 0000000..c60685a --- /dev/null +++ b/src/content/dependencies/listAlbumsByDuration.js @@ -0,0 +1,52 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = sortAlphabetically(albumData.slice()); + const durations = albums.map(album => getTotalDuration(album.tracks)); + + 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 0000000..2141953 --- /dev/null +++ b/src/content/dependencies/listAlbumsByName.js @@ -0,0 +1,50 @@ +import {sortAlphabetically} from '#sort'; +import {stitchArrays} from '#sugar'; + +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 0000000..798e6c2 --- /dev/null +++ b/src/content/dependencies/listAlbumsByTracks.js @@ -0,0 +1,51 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = sortAlphabetically(albumData.slice()); + const counts = albums.map(album => album.tracks.length); + + 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/listAllAdditionalFiles.js b/src/content/dependencies/listAllAdditionalFiles.js new file mode 100644 index 0000000..a6e34b9 --- /dev/null +++ b/src/content/dependencies/listAllAdditionalFiles.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listAllAdditionalFilesTemplate'], + + relations: (relation, spec) => + ({page: relation('listAllAdditionalFilesTemplate', spec, 'additionalFiles')}), + + generate: (relations) => + relations.page.slot('stringsKey', 'other.allAdditionalFiles'), +}; diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js new file mode 100644 index 0000000..e33ad7b --- /dev/null +++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js @@ -0,0 +1,209 @@ +import {sortChronologically} from '#sort'; +import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'generateListAllAdditionalFilesChunk', + 'linkAlbum', + 'linkTrack', + 'linkAlbumAdditionalFile', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({albumData}) => ({albumData}), + + query(sprawl, spec, property) { + const albums = + sortChronologically(sprawl.albumData.slice()); + + const tracks = + albums + .map(album => album.tracks.slice()); + + // Get additional file objects from albums and their tracks. + // There's a possibility that albums and tracks don't both implement + // the same additional file fields - in this case, just treat them + // as though they do implement those fields, but don't have any + // additional files of that type. + + const albumAdditionalFileObjects = + albums + .map(album => album[property] ?? []); + + const trackAdditionalFileObjects = + tracks + .map(byAlbum => byAlbum + .map(track => track[property] ?? [])); + + // Filter out tracks that don't have any additional files. + + stitchArrays({tracks, trackAdditionalFileObjects}) + .forEach(({tracks, trackAdditionalFileObjects}) => { + filterMultipleArrays(tracks, trackAdditionalFileObjects, + (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects)); + }); + + // Filter out albums that don't have any tracks, + // nor any additional files of their own. + + filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects, + (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) => + !empty(albumAdditionalFileObjects) || + !empty(trackAdditionalFileObjects)); + + // Map additional file objects into titles and lists of file names. + + const albumAdditionalFileTitles = + albumAdditionalFileObjects + .map(byAlbum => byAlbum + .map(({title}) => title)); + + const albumAdditionalFileFiles = + albumAdditionalFileObjects + .map(byAlbum => byAlbum + .map(({files}) => files ?? [])); + + const trackAdditionalFileTitles = + trackAdditionalFileObjects + .map(byAlbum => byAlbum + .map(byTrack => byTrack + .map(({title}) => title))); + + const trackAdditionalFileFiles = + trackAdditionalFileObjects + .map(byAlbum => byAlbum + .map(byTrack => byTrack + .map(({files}) => files ?? []))); + + return { + spec, + albums, + tracks, + albumAdditionalFileTitles, + albumAdditionalFileFiles, + trackAdditionalFileTitles, + trackAdditionalFileFiles, + }; + }, + + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + + trackLinks: + query.tracks + .map(byAlbum => byAlbum + .map(track => relation('linkTrack', track))), + + albumChunks: + query.albums + .map(() => relation('generateListAllAdditionalFilesChunk')), + + trackChunks: + query.tracks + .map(byAlbum => byAlbum + .map(() => relation('generateListAllAdditionalFilesChunk'))), + + albumAdditionalFileLinks: + stitchArrays({ + album: query.albums, + files: query.albumAdditionalFileFiles, + }).map(({album, files: byAlbum}) => + byAlbum.map(files => files + .map(file => + relation('linkAlbumAdditionalFile', album, file)))), + + trackAdditionalFileLinks: + stitchArrays({ + album: query.albums, + files: query.trackAdditionalFileFiles, + }).map(({album, files: byAlbum}) => + byAlbum + .map(byTrack => byTrack + .map(files => files + .map(file => relation('linkAlbumAdditionalFile', album, file))))), + }), + + data: (query) => ({ + albumAdditionalFileTitles: query.albumAdditionalFileTitles, + trackAdditionalFileTitles: query.trackAdditionalFileTitles, + albumAdditionalFileFiles: query.albumAdditionalFileFiles, + trackAdditionalFileFiles: query.trackAdditionalFileFiles, + }), + + slots: { + stringsKey: {type: 'string'}, + }, + + generate: (data, relations, slots, {html, language}) => + relations.page.slots({ + type: 'custom', + + content: + stitchArrays({ + albumLink: relations.albumLinks, + trackLinks: relations.trackLinks, + albumChunk: relations.albumChunks, + trackChunks: relations.trackChunks, + albumAdditionalFileTitles: data.albumAdditionalFileTitles, + trackAdditionalFileTitles: data.trackAdditionalFileTitles, + albumAdditionalFileLinks: relations.albumAdditionalFileLinks, + trackAdditionalFileLinks: relations.trackAdditionalFileLinks, + albumAdditionalFileFiles: data.albumAdditionalFileFiles, + trackAdditionalFileFiles: data.trackAdditionalFileFiles, + }).map(({ + albumLink, + trackLinks, + albumChunk, + trackChunks, + albumAdditionalFileTitles, + trackAdditionalFileTitles, + albumAdditionalFileLinks, + trackAdditionalFileLinks, + albumAdditionalFileFiles, + trackAdditionalFileFiles, + }) => [ + html.tag('h3', {class: 'content-heading'}, albumLink), + + html.tag('dl', [ + albumChunk.slots({ + title: + language.$('listingPage', slots.stringsKey, 'albumFiles'), + + additionalFileTitles: albumAdditionalFileTitles, + additionalFileLinks: albumAdditionalFileLinks, + additionalFileFiles: albumAdditionalFileFiles, + + stringsKey: slots.stringsKey, + }), + + stitchArrays({ + trackLink: trackLinks, + trackChunk: trackChunks, + trackAdditionalFileTitles, + trackAdditionalFileLinks, + trackAdditionalFileFiles, + }).map(({ + trackLink, + trackChunk, + trackAdditionalFileTitles, + trackAdditionalFileLinks, + trackAdditionalFileFiles, + }) => + trackChunk.slots({ + title: trackLink, + additionalFileTitles: trackAdditionalFileTitles, + additionalFileLinks: trackAdditionalFileLinks, + additionalFileFiles: trackAdditionalFileFiles, + stringsKey: slots.stringsKey, + })), + ]), + ]), + }), +}; diff --git a/src/content/dependencies/listAllMidiProjectFiles.js b/src/content/dependencies/listAllMidiProjectFiles.js new file mode 100644 index 0000000..31a70ef --- /dev/null +++ b/src/content/dependencies/listAllMidiProjectFiles.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listAllAdditionalFilesTemplate'], + + relations: (relation, spec) => + ({page: relation('listAllAdditionalFilesTemplate', spec, 'midiProjectFiles')}), + + generate: (relations) => + relations.page.slot('stringsKey', 'other.allMidiProjectFiles'), +}; diff --git a/src/content/dependencies/listAllSheetMusicFiles.js b/src/content/dependencies/listAllSheetMusicFiles.js new file mode 100644 index 0000000..166b206 --- /dev/null +++ b/src/content/dependencies/listAllSheetMusicFiles.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listAllAdditionalFilesTemplate'], + + relations: (relation, spec) => + ({page: relation('listAllAdditionalFilesTemplate', spec, 'sheetMusicFiles')}), + + generate: (relations) => + relations.page.slot('stringsKey', 'other.allSheetMusic'), +}; diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js new file mode 100644 index 0000000..b3a5474 --- /dev/null +++ b/src/content/dependencies/listArtTagNetwork.js @@ -0,0 +1 @@ +export default {generate() {}}; diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js new file mode 100644 index 0000000..eff2dba --- /dev/null +++ b/src/content/dependencies/listArtistsByCommentaryEntries.js @@ -0,0 +1,58 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = + sortAlphabetically( + artistData.filter(artist => !artist.isAlias)); + + 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 0000000..0af586c --- /dev/null +++ b/src/content/dependencies/listArtistsByContributions.js @@ -0,0 +1,160 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {empty, filterByCount, filterMultipleArrays, stitchArrays, unique} + from '#sugar'; + +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 = + sortAlphabetically( + sprawl.artistData.filter(artist => !artist.isAlias)); + + 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, {language}) { + const listChunkIDs = ['tracks', 'artworks']; + const listTitleStringsKeys = ['trackContributors', 'artContributors']; + const listCountFunctions = ['countTracks', 'countArtworks']; + + const listArtistLinks = [ + relations.artistLinksByTrackContributions, + relations.artistLinksByArtworkContributions, + ]; + + const listArtistCounts = [ + data.countsByTrackContributions, + data.countsByArtworkContributions, + ]; + + if (data.enableFlashesAndGames) { + listChunkIDs.push('flashes'); + listTitleStringsKeys.push('flashContributors'); + listCountFunctions.push('countFlashes'); + listArtistLinks.push(relations.artistLinksByFlashContributions); + listArtistCounts.push(data.countsByFlashContributions); + } + + filterMultipleArrays( + listChunkIDs, + listTitleStringsKeys, + listCountFunctions, + listArtistLinks, + listArtistCounts, + (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) => + !empty(artistLinks)); + + return relations.page.slots({ + type: 'chunks', + + showSkipToSection: true, + chunkIDs: listChunkIDs, + + chunkTitles: + listTitleStringsKeys.map(stringsKey => ({stringsKey})), + + chunkRows: + stitchArrays({ + artistLinks: listArtistLinks, + artistCounts: listArtistCounts, + countFunction: listCountFunctions, + }).map(({artistLinks, artistCounts, countFunction}) => + stitchArrays({ + artistLink: artistLinks, + artistCount: artistCounts, + }).map(({artistLink, artistCount}) => ({ + artist: artistLink, + contributions: language[countFunction](artistCount, {unit: true}), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js new file mode 100644 index 0000000..f677d82 --- /dev/null +++ b/src/content/dependencies/listArtistsByDuration.js @@ -0,0 +1,60 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData}) { + return {artistData}; + }, + + query({artistData}, spec) { + const artists = + sortAlphabetically( + artistData.filter(artist => !artist.isAlias)); + + 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/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js new file mode 100644 index 0000000..30884d2 --- /dev/null +++ b/src/content/dependencies/listArtistsByGroup.js @@ -0,0 +1,133 @@ +import {sortAlphabetically} from '#sort'; +import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar'; +import {getArtistNumContributions} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return {artistData, wikiInfo}; + }, + + query(sprawl, spec) { + const artists = + sortAlphabetically( + sprawl.artistData.filter(artist => !artist.isAlias)); + + const groups = + sprawl.wikiInfo.divideTrackListsByGroups; + + if (empty(groups)) { + return {spec, artists}; + } + + const artistGroups = + artists.map(artist => + unique( + unique([ + ...artist.albumsAsAny, + ...artist.tracksAsAny.map(track => track.album), + ]).flatMap(album => album.groups))) + + const artistsByGroup = + groups.map(group => + artists.filter((artist, index) => artistGroups[index].includes(group))); + + filterMultipleArrays(groups, artistsByGroup, + (group, artists) => !empty(artists)); + + return {spec, groups, artistsByGroup}; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + if (query.artists) { + relations.artistLinks = + query.artists + .map(artist => relation('linkArtist', artist)); + } + + if (query.artistsByGroup) { + relations.groupLinks = + query.groups + .map(group => relation('linkGroup', group)); + + relations.artistLinksByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => relation('linkArtist', artist))); + } + + return relations; + }, + + data(query) { + const data = {}; + + if (query.artists) { + data.counts = + query.artists + .map(artist => getArtistNumContributions(artist)); + } + + if (query.artistsByGroup) { + data.groupDirectories = + query.groups + .map(group => group.directory); + + data.countsByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => getArtistNumContributions(artist))); + } + + return data; + }, + + generate(data, relations, {language}) { + return ( + (relations.artistLinksByGroup + ? relations.page.slots({ + type: 'chunks', + + showSkipToSection: true, + chunkIDs: + data.groupDirectories + .map(directory => `contributed-to-${directory}`), + + chunkTitles: + relations.groupLinks.map(groupLink => ({ + group: groupLink, + })), + + chunkRows: + stitchArrays({ + artistLinks: relations.artistLinksByGroup, + counts: data.countsByGroup, + }).map(({artistLinks, counts}) => + stitchArrays({ + link: artistLinks, + count: counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + }))), + }) + : 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/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js new file mode 100644 index 0000000..0f70957 --- /dev/null +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -0,0 +1,319 @@ +import {chunkMultipleArrays, empty, sortMultipleArrays, stitchArrays} + from '#sugar'; +import T from '#things'; + +import { + sortAlphabetically, + sortAlbumsTracksChronologically, + sortFlashesChronologically, +} from '#sort'; + +const {Album, Flash} = T; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkArtist', + 'linkFlash', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) => + ({albumData, artistData, flashData, trackData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames}), + + query(sprawl, spec) { + // + // First main step is to get the latest thing each artist has contributed + // to, and the date associated with that contribution! Some notes: + // + // * Album and track contributions are considered before flashes, so + // they'll take priority if an artist happens to have multiple contribs + // landing on the same date to both an album and a flash. + // + // * The final (album) contribution list is chunked by album, but also by + // date, because an individual album can cover a variety of dates. + // + // * If an artist has contributed both artworks and tracks to the album + // containing their latest contribution, then that will be indicated + // in an annotation, but *only if* those contributions were also on + // the same date. + // + // * If an artist made contributions to multiple albums on the same date, + // then the first of the *albums* sorted chronologically (latest first) + // is the one that will count. + // + // * Same for artists who've contributed to multiple flashes which were + // released on the same date. + // + // * The map may exclude artists none of whose contributions were dated. + // + + const artistLatestContribMap = new Map(); + + const considerDate = (artist, date, thing, contribution) => { + if (!date) { + return; + } + + if (artistLatestContribMap.has(artist)) { + const latest = artistLatestContribMap.get(artist); + if (latest.date > date) { + return; + } + + if (latest.date === date) { + if (latest.thing === thing) { + // May combine differnt contributions to the same thing and date. + latest.contribution.add(contribution); + } + + // Earlier-processed things of same date take priority. + return; + } + } + + // First entry for artist or more recent contribution than latest date. + artistLatestContribMap.set(artist, { + date, + thing, + contribution: new Set([contribution]), + }); + }; + + const getArtists = (thing, key) => thing[key].map(({who}) => who); + + const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice()); + const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice()); + const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice()); + + for (const album of albumsLatestFirst) { + for (const artist of new Set([ + ...getArtists(album, 'coverArtistContribs'), + ...getArtists(album, 'wallpaperArtistContribs'), + ...getArtists(album, 'bannerArtistContribs'), + ])) { + // Might combine later with 'track' of the same album and date. + considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork'); + } + } + + for (const track of tracksLatestFirst) { + for (const artist of getArtists(track, 'coverArtistContribs')) { + // No special effect if artist already has 'artwork' for the same album and date. + considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork'); + } + + for (const artist of new Set([ + ...getArtists(track, 'artistContribs'), + ...getArtists(track, 'contributorContribs'), + ])) { + // Might be combining with 'artwork' of the same album and date. + considerDate(artist, track.date, track.album, 'track'); + } + } + + for (const flash of flashesLatestFirst) { + for (const artist of getArtists(flash, 'contributorContribs')) { + // Won't take priority above album contributions of the same date. + considerDate(artist, flash.date, flash, 'flash'); + } + } + + // + // Next up is to sort all the processed artist information! + // + // Entries with the same album/flash and the same date go together first, + // with the following rules for sorting artists therein: + // + // * If the contributions are different, which can only happen for albums, + // then it's tracks-only first, tracks + artworks next, and artworks-only + // last. + // + // * If the contributions are the same, then sort alphabetically. + // + // Entries with different albums/flashes follow another set of rules: + // + // * Later dates come before earlier dates. + // + // * On the same date, albums come before flashes. + // + // * Things of the same type *and* date are sorted alphabetically. + // + + const artistsAlphabetically = + sortAlphabetically( + sprawl.artistData.filter(artist => !artist.isAlias)); + + const artists = + Array.from(artistLatestContribMap.keys()); + + const artistContribEntries = + Array.from(artistLatestContribMap.values()); + + const artistThings = + artistContribEntries.map(({thing}) => thing); + + const artistDates = + artistContribEntries.map(({date}) => date); + + const artistContributions = + artistContribEntries.map(({contribution}) => contribution); + + sortMultipleArrays(artistThings, artistDates, artistContributions, artists, + (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => { + if (date1 === date2 && thing1 === thing2) { + // Move artwork-only contribs after contribs with tracks. + if (!contrib1.has('track') && contrib2.has('track')) return 1; + if (!contrib2.has('track') && contrib1.has('track')) return -1; + + // Move track-only contribs before tracks with tracks and artwork. + if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1; + if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1; + + // Sort artists of the same type of contribution alphabetically, + // referring to a previous sort. + const index1 = artistsAlphabetically.indexOf(artist1); + const index2 = artistsAlphabetically.indexOf(artist2); + return index1 - index2; + } else { + // Move later dates before earlier ones. + if (date1 !== date2) return date2 - date1; + + // Move albums before flashes. + if (thing1 instanceof Album && thing2 instanceof Flash) return -1; + if (thing1 instanceof Flash && thing2 instanceof Album) return 1; + + // Sort two albums or two flashes alphabetically, referring to a + // previous sort (which was chronological but includes the correct + // ordering for things released on the same date). + const thingsLatestFirst = + (thing1 instanceof Album + ? albumsLatestFirst + : flashesLatestFirst); + const index1 = thingsLatestFirst.indexOf(thing1); + const index2 = thingsLatestFirst.indexOf(thing2); + return index2 - index1; + } + }); + + const chunks = + chunkMultipleArrays(artistThings, artistDates, artistContributions, artists, + (thing, lastThing, date, lastDate) => + thing !== lastThing || + +date !== +lastDate); + + const chunkThings = + chunks.map(([artistThings, , , ]) => artistThings[0]); + + const chunkDates = + chunks.map(([, artistDates, , ]) => artistDates[0]); + + const chunkArtistContributions = + chunks.map(([, , artistContributions, ]) => artistContributions); + + const chunkArtists = + chunks.map(([, , , artists]) => artists); + + // And one bonus step - keep track of all the artists whose contributions + // were all without date. + + const datelessArtists = + artistsAlphabetically + .filter(artist => !artists.includes(artist)); + + return { + spec, + chunkThings, + chunkDates, + chunkArtistContributions, + chunkArtists, + datelessArtists, + }; + }, + + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + chunkAlbumLinks: + query.chunkThings + .map(thing => + (thing instanceof Album + ? relation('linkAlbum', thing) + : null)), + + chunkFlashLinks: + query.chunkThings + .map(thing => + (thing instanceof Flash + ? relation('linkFlash', thing) + : null)), + + chunkArtistLinks: + query.chunkArtists + .map(artists => artists + .map(artist => relation('linkArtist', artist))), + + datelessArtistLinks: + query.datelessArtists + .map(artist => relation('linkArtist', artist)), + }), + + data: (query) => ({ + chunkDates: query.chunkDates, + chunkArtistContributions: query.chunkArtistContributions, + }), + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + stitchArrays({ + albumLink: relations.chunkAlbumLinks, + flashLink: relations.chunkFlashLinks, + date: data.chunkDates, + }).map(({albumLink, flashLink, date}) => ({ + date: language.formatDate(date), + ...(albumLink + ? {stringsKey: 'album', album: albumLink} + : {stringsKey: 'flash', flash: flashLink}), + })) + .concat( + (empty(relations.datelessArtistLinks) + ? [] + : [{stringsKey: 'dateless'}])), + + chunkRows: + stitchArrays({ + artistLinks: relations.chunkArtistLinks, + contributions: data.chunkArtistContributions, + }).map(({artistLinks, contributions}) => + stitchArrays({ + artistLink: artistLinks, + contribution: contributions, + }).map(({artistLink, contribution}) => ({ + artist: artistLink, + stringsKey: + (contribution.has('track') && contribution.has('artwork') + ? 'tracksAndArt' + : contribution.has('track') + ? 'tracks' + : contribution.has('artwork') + ? 'art' + : null), + }))) + .concat( + (empty(relations.datelessArtistLinks) + ? [] + : [ + relations.datelessArtistLinks.map(artistLink => ({ + artist: artistLink, + })), + ])), + }); + }, +}; diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js new file mode 100644 index 0000000..9321849 --- /dev/null +++ b/src/content/dependencies/listArtistsByName.js @@ -0,0 +1,48 @@ +import {sortAlphabetically} from '#sort'; +import {stitchArrays} from '#sugar'; +import {getArtistNumContributions} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl: ({artistData, wikiInfo}) => + ({artistData, wikiInfo}), + + query: (sprawl, spec) => ({ + spec, + + artists: + sortAlphabetically( + sprawl.artistData.filter(artist => !artist.isAlias)), + }), + + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }), + + data: (query) => ({ + 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 0000000..4adfb6d --- /dev/null +++ b/src/content/dependencies/listGroupsByAlbums.js @@ -0,0 +1,51 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = sortAlphabetically(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 0000000..43919be --- /dev/null +++ b/src/content/dependencies/listGroupsByCategory.js @@ -0,0 +1,76 @@ +import {stitchArrays} from '#sugar'; + +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 0000000..da2f26d --- /dev/null +++ b/src/content/dependencies/listGroupsByDuration.js @@ -0,0 +1,56 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = sortAlphabetically(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 0000000..4831931 --- /dev/null +++ b/src/content/dependencies/listGroupsByLatestAlbum.js @@ -0,0 +1,72 @@ +import {compareDates, sortChronologically} from '#sort'; +import {filterMultipleArrays, sortMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'linkAlbum', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + 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 0000000..696a49b --- /dev/null +++ b/src/content/dependencies/listGroupsByName.js @@ -0,0 +1,49 @@ +import {sortAlphabetically} from '#sort'; +import {stitchArrays} from '#sugar'; + +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 0000000..0b5e4e9 --- /dev/null +++ b/src/content/dependencies/listGroupsByTracks.js @@ -0,0 +1,55 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {accumulateSum, filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({groupData}) { + return {groupData}; + }, + + query({groupData}, spec) { + const groups = sortAlphabetically(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/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js new file mode 100644 index 0000000..ab2eca9 --- /dev/null +++ b/src/content/dependencies/listRandomPageLinks.js @@ -0,0 +1,193 @@ +import {sortChronologically} from '#sort'; +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateListingPage', + 'generateListRandomPageLinksAlbumLink', + 'linkGroup', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}), + + query(sprawl, spec) { + const query = {spec}; + + const groups = sprawl.wikiInfo.divideTrackListsByGroups; + + query.divideByGroups = !empty(groups); + + if (query.divideByGroups) { + query.groups = groups; + + query.groupAlbums = + groups + .map(group => + group.albums.filter(album => album.tracks.length > 1)); + } else { + query.undividedAlbums = + sortChronologically(sprawl.albumData.slice()) + .filter(album => album.tracks.length > 1); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + if (query.divideByGroups) { + relations.groupLinks = + query.groups + .map(group => relation('linkGroup', group)); + + relations.groupAlbumLinks = + query.groupAlbums + .map(albums => albums + .map(album => + relation('generateListRandomPageLinksAlbumLink', album))); + } else { + relations.undividedAlbumLinks = + query.undividedAlbums + .map(album => + relation('generateListRandomPageLinksAlbumLink', album)); + } + + return relations; + }, + + data(query) { + const data = {}; + + if (query.divideByGroups) { + data.groupDirectories = + query.groups + .map(group => group.directory); + } + + return data; + }, + + generate(data, relations, {html, language}) { + const miscellaneousChunkRows = [ + { + stringsKey: 'randomArtist', + + mainLink: + html.tag('a', + {href: '#', 'data-random': 'artist'}, + language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')), + + atLeastTwoContributions: + html.tag('a', + {href: '#', 'data-random': 'artist-more-than-one-contrib'}, + language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')), + }, + + {stringsKey: 'randomAlbumWholeSite'}, + {stringsKey: 'randomTrackWholeSite'}, + ]; + + const miscellaneousChunkRowAttributes = [ + null, + {href: '#', 'data-random': 'album'}, + {href: '#','data-random': 'track'}, + ]; + + return relations.page.slots({ + type: 'chunks', + + content: [ + html.tag('p', + language.$('listingPage.other.randomPages.chooseLinkLine', { + fromPart: + (relations.groupLinks + ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups') + : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')), + + browserSupportPart: + language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), + })), + + html.tag('p', {id: 'data-loading-line'}, + language.$('listingPage.other.randomPages.dataLoadingLine')), + + html.tag('p', {id: 'data-loaded-line'}, + language.$('listingPage.other.randomPages.dataLoadedLine')), + + html.tag('p', {id: 'data-error-line'}, + language.$('listingPage.other.randomPages.dataErrorLine')), + ], + + showSkipToSection: true, + + chunkIDs: + (data.groupDirectories + ? [null, ...data.groupDirectories] + : null), + + chunkTitles: [ + {stringsKey: 'misc'}, + + ... + (relations.groupLinks + ? relations.groupLinks.map(groupLink => ({ + stringsKey: 'fromGroup', + group: groupLink, + })) + : [{stringsKey: 'fromAlbum'}]), + ], + + chunkTitleAccents: [ + null, + + ... + (relations.groupLinks + ? relations.groupLinks.map(() => ({ + randomAlbum: + html.tag('a', + {href: '#', 'data-random': 'album-in-group-dl'}, + language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')), + + randomTrack: + html.tag('a', + {href: '#', 'data-random': 'track-in-group-dl'}, + language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')), + })) + : [null]), + ], + + chunkRows: [ + miscellaneousChunkRows, + + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + }))) + : [ + relations.undividedAlbumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + })), + ]), + ], + + chunkRowAttributes: [ + miscellaneousChunkRowAttributes, + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(() => null)) + : [relations.undividedAlbumLinks.map(() => null)]), + ], + }); + }, +}; diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listTagsByName.js new file mode 100644 index 0000000..d7022a5 --- /dev/null +++ b/src/content/dependencies/listTagsByName.js @@ -0,0 +1,54 @@ +import {sortAlphabetically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTag'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artTagData}) { + return {artTagData}; + }, + + query({artTagData}, spec) { + return { + spec, + + artTags: + sortAlphabetically( + artTagData + .filter(tag => !tag.isContentWarning)), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artTagLinks: + query.artTags + .map(tag => relation('linkArtTag', tag)), + }; + }, + + data(query) { + return { + counts: + query.artTags + .map(tag => tag.taggedInThings.length), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artTagLinks, + count: data.counts, + }).map(({link, count}) => ({ + tag: link, + timesUsed: language.countTimesUsed(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js new file mode 100644 index 0000000..00c700a --- /dev/null +++ b/src/content/dependencies/listTagsByUses.js @@ -0,0 +1,59 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTag'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artTagData}) { + return {artTagData}; + }, + + query({artTagData}, spec) { + const artTags = + sortAlphabetically( + artTagData + .filter(tag => !tag.isContentWarning)); + + const counts = + artTags + .map(tag => tag.taggedInThings.length); + + filterByCount(artTags, counts); + sortByCount(artTags, counts, {greatestFirst: true}); + + return {spec, artTags, counts}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artTagLinks: + query.artTags + .map(tag => relation('linkArtTag', tag)), + }; + }, + + data(query) { + return { + counts: + query.artTags + .map(tag => tag.taggedInThings.length), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artTagLinks, + count: data.counts, + }).map(({link, count}) => ({ + tag: link, + timesUsed: language.countTimesUsed(count, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js new file mode 100644 index 0000000..b240503 --- /dev/null +++ b/src/content/dependencies/listTracksByAlbum.js @@ -0,0 +1,48 @@ +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + return { + spec, + albums: albumData, + tracks: albumData.map(album => album.tracks), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + + trackLinks: + query.tracks + .map(tracks => tracks + .map(track => relation('linkTrack', track))), + }; + }, + + generate(relations) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + relations.albumLinks + .map(albumLink => ({album: albumLink})), + + listStyle: 'ordered', + + chunkRows: + relations.trackLinks + .map(trackLinks => trackLinks + .map(trackLink => ({track: trackLink}))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js new file mode 100644 index 0000000..01ce4e2 --- /dev/null +++ b/src/content/dependencies/listTracksByDate.js @@ -0,0 +1,85 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {chunkByProperties, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({trackData}) { + return {trackData}; + }, + + query({trackData}, spec) { + return { + spec, + + chunks: + chunkByProperties( + sortAlbumsTracksChronologically(trackData.slice()), + ['album', 'date']), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.chunks + .map(({album}) => relation('linkAlbum', album)), + + trackLinks: + query.chunks + .map(({chunk}) => chunk + .map(track => relation('linkTrack', track))), + }; + }, + + data(query) { + return { + dates: + query.chunks + .map(({date}) => date), + + rereleases: + query.chunks.map(({chunk}) => + chunk.map(track => + track.originalReleaseTrack !== null)), + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + stitchArrays({ + albumLink: relations.albumLinks, + date: data.dates, + }).map(({albumLink, date}) => ({ + album: albumLink, + date: language.formatDate(date), + })), + + chunkRows: + stitchArrays({ + trackLinks: relations.trackLinks, + rereleases: data.rereleases, + }).map(({trackLinks, rereleases}) => + stitchArrays({ + trackLink: trackLinks, + rerelease: rereleases, + }).map(({trackLink, rerelease}) => + (rerelease + ? {stringsKey: 'rerelease', track: trackLink} + : {track: trackLink}))), + + chunkRowAttributes: + data.rereleases.map(rereleases => + rereleases.map(rerelease => + (rerelease + ? {class: 'rerelease'} + : null))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js new file mode 100644 index 0000000..64feb4f --- /dev/null +++ b/src/content/dependencies/listTracksByDuration.js @@ -0,0 +1,51 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({trackData}) { + return {trackData}; + }, + + query({trackData}, spec) { + const tracks = sortAlphabetically(trackData.slice()); + const durations = tracks.map(track => track.duration); + + filterByCount(tracks, durations); + sortByCount(tracks, durations, {greatestFirst: true}); + + return {spec, tracks, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + trackLinks: + query.tracks + .map(track => relation('linkTrack', track)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.trackLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + track: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js new file mode 100644 index 0000000..c1ea32a --- /dev/null +++ b/src/content/dependencies/listTracksByDurationInAlbum.js @@ -0,0 +1,87 @@ +import {sortByCount, sortChronologically} from '#sort'; +import {filterByCount, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = sortChronologically(albumData.slice()); + + const tracks = + albums.map(album => + album.tracks.slice()); + + const durations = + tracks.map(tracks => + tracks.map(track => + track.duration)); + + // Filter out tracks without any duration. + // Sort at the same time, to avoid redundantly stitching again later. + const stitched = stitchArrays({tracks, durations}); + for (const {tracks, durations} of stitched) { + filterByCount(tracks, durations); + sortByCount(tracks, durations, {greatestFirst: true}); + } + + // Filter out albums which don't have at least two (remaining) tracks. + // If the album only has one track in the first place, or if only one + // has any duration, then there aren't any comparisons to be made and + // it just takes up space on the listing page. + const numTracks = tracks.map(tracks => tracks.length); + filterMultipleArrays(albums, tracks, durations, numTracks, + (album, tracks, durations, numTracks) => + numTracks >= 2); + + return {spec, albums, tracks, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + + trackLinks: + query.tracks + .map(tracks => tracks + .map(track => relation('linkTrack', track))), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + relations.albumLinks + .map(albumLink => ({album: albumLink})), + + chunkRows: + stitchArrays({ + trackLinks: relations.trackLinks, + durations: data.durations, + }).map(({trackLinks, durations}) => + stitchArrays({ + trackLink: trackLinks, + duration: durations, + }).map(({trackLink, duration}) => ({ + track: trackLink, + duration: language.formatDuration(duration), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js new file mode 100644 index 0000000..773b047 --- /dev/null +++ b/src/content/dependencies/listTracksByName.js @@ -0,0 +1,36 @@ +import {sortAlphabetically} from '#sort'; + +export default { + contentDependencies: ['generateListingPage', 'linkTrack'], + extraDependencies: ['wikiData'], + + sprawl({trackData}) { + return {trackData}; + }, + + query({trackData}, spec) { + return { + spec, + tracks: sortAlphabetically(trackData.slice()), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + trackLinks: + query.tracks + .map(track => relation('linkTrack', track)), + }; + }, + + generate(relations) { + return relations.page.slots({ + type: 'rows', + rows: + relations.trackLinks + .map(link => ({track: link})), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js new file mode 100644 index 0000000..5838ded --- /dev/null +++ b/src/content/dependencies/listTracksByTimesReferenced.js @@ -0,0 +1,52 @@ +import {sortAlbumsTracksChronologically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({trackData}) { + return {trackData}; + }, + + query({trackData}, spec) { + const tracks = sortAlbumsTracksChronologically(trackData.slice()); + const timesReferenced = tracks.map(track => track.referencedByTracks.length); + + filterByCount(tracks, timesReferenced); + sortByCount(tracks, timesReferenced, {greatestFirst: true}); + + return {spec, tracks, timesReferenced}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + trackLinks: + query.tracks + .map(track => relation('linkTrack', track)), + }; + }, + + data(query) { + return { + timesReferenced: query.timesReferenced, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.trackLinks, + timesReferenced: data.timesReferenced, + }).map(({link, timesReferenced}) => ({ + track: link, + timesReferenced: + language.countTimesReferenced(timesReferenced, {unit: true}), + })), + }); + }, +}; diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js new file mode 100644 index 0000000..8ca0d99 --- /dev/null +++ b/src/content/dependencies/listTracksInFlashesByAlbum.js @@ -0,0 +1,82 @@ +import {sortChronologically} from '#sort'; +import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query({albumData}, spec) { + const albums = sortChronologically(albumData.slice()); + + const tracks = + albums.map(album => + album.tracks.slice()); + + const flashes = + tracks.map(tracks => + tracks.map(track => + track.featuredInFlashes)); + + // Filter out tracks that aren't featured in any flashes. + // This listing doesn't perform any sorting within albums. + const stitched = stitchArrays({tracks, flashes}); + for (const {tracks, flashes} of stitched) { + filterMultipleArrays(tracks, flashes, + (tracks, flashes) => !empty(flashes)); + } + + // Filter out albums which don't have at least one remaining track. + filterMultipleArrays(albums, tracks, flashes, + (album, tracks, _flashes) => !empty(tracks)); + + return {spec, albums, tracks, flashes}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + + trackLinks: + query.tracks + .map(tracks => tracks + .map(track => relation('linkTrack', track))), + + flashLinks: + query.flashes + .map(flashesByAlbum => flashesByAlbum + .map(flashesByTrack => flashesByTrack + .map(flash => relation('linkFlash', flash)))), + }; + }, + + generate(relations, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + relations.albumLinks + .map(albumLink => ({album: albumLink})), + + chunkRows: + stitchArrays({ + trackLinks: relations.trackLinks, + flashLinks: relations.flashLinks, + }).map(({trackLinks, flashLinks}) => + stitchArrays({ + trackLink: trackLinks, + flashLinks: flashLinks, + }).map(({trackLink, flashLinks}) => ({ + track: trackLink, + flashes: language.formatConjunctionList(flashLinks), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js new file mode 100644 index 0000000..6ab954e --- /dev/null +++ b/src/content/dependencies/listTracksInFlashesByFlash.js @@ -0,0 +1,69 @@ +import {sortFlashesChronologically} from '#sort'; +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], + extraDependencies: ['wikiData'], + + sprawl({flashData}) { + return {flashData}; + }, + + query({flashData}, spec) { + const flashes = sortFlashesChronologically( + flashData + .filter(flash => !empty(flash.featuredTracks))); + + const tracks = + flashes.map(album => album.featuredTracks); + + const albums = + tracks.map(tracks => + tracks.map(track => track.album)); + + return {spec, flashes, tracks, albums}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + flashLinks: + query.flashes + .map(flash => relation('linkFlash', flash)), + + trackLinks: + query.tracks + .map(tracks => tracks + .map(track => relation('linkTrack', track))), + + albumLinks: + query.albums + .map(albums => albums + .map(album => relation('linkAlbum', album))), + }; + }, + + generate(relations) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + relations.flashLinks + .map(flashLink => ({flash: flashLink})), + + chunkRows: + stitchArrays({ + trackLinks: relations.trackLinks, + albumLinks: relations.albumLinks, + }).map(({trackLinks, albumLinks}) => + stitchArrays({ + trackLink: trackLinks, + albumLink: albumLinks, + }).map(({trackLink, albumLink}) => ({ + track: trackLink, + album: albumLink, + }))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js new file mode 100644 index 0000000..c7f42f9 --- /dev/null +++ b/src/content/dependencies/listTracksWithExtra.js @@ -0,0 +1,85 @@ +import {sortChronologically} from '#sort'; +import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({albumData}) { + return {albumData}; + }, + + query(sprawl, spec, property, valueMode) { + const albums = + sortChronologically(sprawl.albumData.slice()); + + const tracks = + albums + .map(album => + album.tracks + .filter(track => { + switch (valueMode) { + case 'truthy': return !!track[property]; + case 'array': return !empty(track[property]); + default: return false; + } + })); + + filterMultipleArrays(albums, tracks, + (album, tracks) => !empty(tracks)); + + return {spec, albums, tracks}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + albumLinks: + query.albums + .map(album => relation('linkAlbum', album)), + + trackLinks: + query.tracks + .map(tracks => tracks + .map(track => relation('linkTrack', track))), + }; + }, + + data(query) { + return { + dates: + query.albums.map(album => album.date), + }; + }, + + slots: { + hash: {type: 'string'}, + }, + + generate(data, relations, slots, {language}) { + return relations.page.slots({ + type: 'chunks', + + chunkTitles: + stitchArrays({ + albumLink: relations.albumLinks, + date: data.dates, + }).map(({albumLink, date}) => + (date + ? { + stringsKey: 'withDate', + album: albumLink, + date: language.formatDate(date), + } + : {album: albumLink})), + + chunkRows: + relations.trackLinks + .map(trackLinks => trackLinks + .map(trackLink => ({ + track: trackLink.slot('hash', slots.hash), + }))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js new file mode 100644 index 0000000..a13a76f --- /dev/null +++ b/src/content/dependencies/listTracksWithLyrics.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listTracksWithExtra'], + + relations: (relation, spec) => + ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}), + + generate: (relations) => + relations.page, +}; diff --git a/src/content/dependencies/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js new file mode 100644 index 0000000..418af4c --- /dev/null +++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listTracksWithExtra'], + + relations: (relation, spec) => + ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}), + + generate: (relations) => + relations.page.slot('hash', 'midi-project-files'), +}; diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js new file mode 100644 index 0000000..0c6761e --- /dev/null +++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js @@ -0,0 +1,9 @@ +export default { + contentDependencies: ['listTracksWithExtra'], + + relations: (relation, spec) => + ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}), + + generate: (relations) => + relations.page.slot('hash', 'sheet-music-files'), +}; diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js new file mode 100644 index 0000000..0904cde --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,595 @@ +import {bindFind} from '#find'; +import {replacerSpec, parseInput} from '#replacer'; + +import {Marked} from 'marked'; + +const commonMarkedOptions = { + headerIds: false, + mangle: false, +}; + +const multilineMarked = new Marked({ + ...commonMarkedOptions, +}); + +const inlineMarked = new Marked({ + ...commonMarkedOptions, + + renderer: { + paragraph(text) { + return text; + }, + }, +}); + +const lyricsMarked = new Marked({ + ...commonMarkedOptions, +}); + +function getPlaceholder(node, content) { + return {type: 'text', data: content.slice(node.i, node.iEnd)}; +} + +export default { + contentDependencies: [ + ...( + Object.values(replacerSpec) + .map(description => description.link) + .filter(Boolean)), + 'image', + 'linkExternal', + ], + + extraDependencies: ['html', 'language', 'to', '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 = {link: spec.link}; + + determineData: { + // No value at all: this is an index link. + if (!replacerValue || 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: 'internal-link', data}; + } + + // This will be another {type: 'tag'} node which gets processed in + // generate. Extract replacerKey and replacerValue now, since it'd + // be a pain to deal with later. + return { + ...node, + data: { + ...node.data, + replacerKey: node.data.replacerKey.data, + replacerValue: node.data.replacerValue[0].data, + }, + }; + }), + }; + }, + + data(sprawl, content) { + return { + content, + + nodes: + sprawl.nodes + .map(node => { + switch (node.type) { + // Replace internal link nodes with a stub. It'll be replaced + // (by position) with an item from relations. + // + // TODO: This should be where label and hash get passed through, + // rather than in relations... (in which case there's no need to + // handle it specially here, and we can really just return + // data.nodes = sprawl.nodes) + case 'internal-link': + return {type: 'internal-link'}; + + // Other nodes will get processed in generate. + default: + return node; + } + }), + }; + }, + + 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 { + internalLinks: + nodes + .filter(({type}) => type === 'internal-link') + .map(node => { + const {link, thing, value} = node.data; + + if (thing) { + return relationOrPlaceholder(node, link, thing); + } else if (value && value !== '-') { + return relationOrPlaceholder(node, link, value); + } else { + return relationOrPlaceholder(node, link); + } + }), + + externalLinks: + nodes + .filter(({type}) => type === 'external-link') + .map(node => { + const {href} = node.data; + + return relation('linkExternal', href); + }), + + images: + nodes + .filter(({type}) => type === 'image') + .filter(({inline}) => !inline) + .map(() => relation('image')), + }; + }, + + slots: { + mode: { + validate: v => v.is('inline', 'multiline', 'lyrics', 'single-link'), + default: 'multiline', + }, + + preferShortLinkNames: { + type: 'boolean', + default: false, + }, + + indicateExternalLinks: { + type: 'boolean', + default: true, + }, + + thumb: { + validate: v => v.is('small', 'medium', 'large'), + default: 'large', + }, + }, + + generate(data, relations, slots, {html, language, to}) { + let imageIndex = 0; + let internalLinkIndex = 0; + let externalLinkIndex = 0; + + const contentFromNodes = + data.nodes.map(node => { + switch (node.type) { + case 'text': + return {type: 'text', data: node.data}; + + case 'image': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const { + link, + style, + warnings, + width, + height, + align, + pixelate, + } = node; + + if (node.inline) { + let content = + html.tag('img', + src && {src}, + width && {width}, + height && {height}, + style && {style}, + + pixelate && + {class: 'pixelate'}); + + if (link) { + content = + html.tag('a', + {href: link}, + {target: '_blank'}, + + {title: + language.$('misc.external.opensInNewTab', { + link: + language.formatExternalLink(link, { + style: 'platform', + }), + + annotation: + language.$('misc.external.opensInNewTab.annotation'), + }).toString()}, + + content); + } + + return { + type: 'processed-image', + inline: true, + data: content, + }; + } + + const image = relations.images[imageIndex++]; + + image.setSlots({ + src, + + link: link ?? true, + warnings: warnings ?? null, + thumb: slots.thumb, + }); + + if (width || height) { + image.setSlot('dimensions', [width ?? null, height ?? null]); + } + + image.setSlot('attributes', [ + {class: 'content-image'}, + + pixelate && + {class: 'pixelate'}, + ]); + + return { + type: 'processed-image', + inline: false, + data: + html.tag('div', {class: 'content-image-container'}, + align === 'center' && + {class: 'align-center'}, + + image), + }; + } + + case 'internal-link': { + const nodeFromRelations = relations.internalLinks[internalLinkIndex++]; + if (nodeFromRelations.type === 'text') { + return {type: 'text', data: nodeFromRelations.data}; + } + + const {link, label, hash} = nodeFromRelations; + + // These are removed from the typical combined slots({})-style + // because we don't want to override slots that were already set + // by something that's wrapping the linkTemplate or linkThing + // template. + if (label) link.setSlot('content', label); + if (hash) link.setSlot('hash', hash); + + // TODO: This is obviously hacky. + let hasPreferShortNameSlot; + try { + link.getSlotDescription('preferShortName'); + hasPreferShortNameSlot = true; + } catch (error) { + hasPreferShortNameSlot = false; + } + + if (hasPreferShortNameSlot) { + link.setSlot('preferShortName', slots.preferShortLinkNames); + } + + // TODO: The same, the same. + let hasTooltipStyleSlot; + try { + link.getSlotDescription('tooltipStyle'); + hasTooltipStyleSlot = true; + } catch (error) { + hasTooltipStyleSlot = false; + } + + if (hasTooltipStyleSlot) { + link.setSlot('tooltipStyle', 'none'); + } + + return {type: 'processed-internal-link', data: link}; + } + + case 'external-link': { + const {label} = node.data; + const externalLink = relations.externalLinks[externalLinkIndex++]; + + externalLink.setSlots({ + content: label, + fromContent: true, + }); + + if (slots.indicateExternalLinks) { + externalLink.setSlots({ + indicateExternal: true, + tab: 'separate', + style: 'platform', + }); + } + + return {type: 'processed-external-link', data: externalLink}; + } + + case '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.toString()}; + } + + default: + return getPlaceholder(node, data.content); + } + }); + + // In single-link mode, return the link node exactly as is - exposing + // access to its slots. + + if (slots.mode === 'single-link') { + const link = + contentFromNodes.find(node => + node.type === 'processed-internal-link' || + node.type === 'processed-external-link'); + + if (!link) { + return html.blank(); + } + + return link.data; + } + + // Content always goes through marked (i.e. parsing as Markdown). + // This does require some attention to detail, mostly to do with line + // breaks (in multiline mode) and extracting/re-inserting non-text nodes. + + // The content of non-text nodes can end up getting mangled by marked. + // To avoid this, we replace them with mundane placeholders, then + // reinsert the content in the correct positions. This also avoids + // having to stringify tag content within this generate() function. + + const extractNonTextNodes = ({ + getTextNodeContents = node => node.data, + } = {}) => + contentFromNodes + .map((node, index) => { + if (node.type === 'text') { + return getTextNodeContents(node, index); + } + + let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`; + + if (node.type === 'processed-image' && node.inline) { + attributes += ` data-inline`; + } + + return `<span ${attributes}>${index}</span>`; + }) + .join(''); + + const reinsertNonTextNodes = (markedOutput) => { + markedOutput = markedOutput.trim(); + + const tags = []; + const regexp = /<span class="INSERT-NON-TEXT" (.*?)>([0-9]+?)<\/span>/g; + + let deleteParagraph = false; + + const addText = (text) => { + if (deleteParagraph) { + text = text.replace(/^<\/p>/, ''); + deleteParagraph = false; + } + + tags.push(text); + }; + + let match = null, parseFrom = 0; + while (match = regexp.exec(markedOutput)) { + addText(markedOutput.slice(parseFrom, match.index)); + parseFrom = match.index + match[0].length; + + const attributes = html.parseAttributes(match[1]); + + // Images that were all on their own line need to be removed from + // the surrounding <p> tag that marked generates. The HTML parser + // treats a <div> that starts inside a <p> as a Crocker-class + // misgiving, and will treat you very badly if you feed it that. + if (attributes.get('data-type') === 'processed-image') { + if (!attributes.get('data-inline')) { + tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, ''); + deleteParagraph = true; + } + } + + const nonTextNodeIndex = match[2]; + tags.push(contentFromNodes[nonTextNodeIndex].data); + } + + if (parseFrom !== markedOutput.length) { + addText(markedOutput.slice(parseFrom)); + } + + return html.tags(tags, {[html.joinChildren]: ''}); + }; + + if (slots.mode === 'inline') { + const markedInput = + extractNonTextNodes(); + + const markedOutput = + inlineMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + + // This is separated into its own function just since we're gonna reuse + // it in a minute if everything goes to heck in lyrics mode. + const transformMultiline = () => { + const markedInput = + extractNonTextNodes() + // Compress multiple line breaks into single line breaks, + // except when they're preceding or following indented + // text (by at least two spaces). + .replace(/(?<! .*)\n{2,}(?!^ )/gm, '\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which don't follow a list, quote, + // or <br> / " ", and which don't precede or follow + // indented text (by at least two spaces). + .replace(/(?<!^ *-.*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n') + // Expand line breaks which are at the end of a quote. + .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + + const markedOutput = + multilineMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + + if (slots.mode === 'multiline') { + 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(); + } + + const markedInput = + extractNonTextNodes({ + getTextNodeContents(node, index) { + // First, replace line breaks that follow text content with + // <br> tags. + let content = node.data.replace(/(?!^)\n/gm, '<br>\n'); + + // Scrap line breaks that are at the end of a verse. + content = content.replace(/<br>$(?=\n\n)/gm, ''); + + // If the node started with a line break, and it's not the + // very first node, then whatever came before it was inline. + // (This is an assumption based on text links being basically + // the only tag that shows up in lyrics.) Since this text is + // following content that was already inline, restore that + // initial line break. + if (node.data[0] === '\n' && index !== 0) { + content = '<br>' + content; + } + + return content; + }, + }); + + const markedOutput = + lyricsMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + }, +} |