diff options
Diffstat (limited to 'src/content')
257 files changed, 20503 insertions, 0 deletions
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js new file mode 100644 index 00000000..930b6f13 --- /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 00000000..68120b23 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,26 @@ +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'}, + {[html.onlyIfContent]: true}, + + 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 00000000..507b2329 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesListChunk.js @@ -0,0 +1,46 @@ +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}) => + language.encapsulate('releaseInfo.additionalFiles.entry', capsule => + html.tag('li', + html.tag('details', + html.isBlank(slots.items) && + {open: true}, + + [ + html.tag('summary', + html.tag('span', + language.$(capsule, { + title: + html.tag('b', slots.title), + }))), + + html.tag('ul', [ + html.tag('li', {class: 'entry-description'}, + {[html.onlyIfContent]: true}, + slots.description), + + (html.isBlank(slots.items) + ? html.tag('li', + language.$(capsule, 'noFilesAvailable')) + : slots.items), + ]), + ]))), +}; diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js new file mode 100644 index 00000000..c37d6bb2 --- /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/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js new file mode 100644 index 00000000..b7392dfd --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -0,0 +1,28 @@ +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'}, + {class: 'drop'}, + {[html.onlyIfContent]: true}, + + [ + html.tag('p', + {[html.onlyIfSiblings]: true}, + + language.$('misc.additionalNames.title')), + + html.tag('ul', + {[html.onlyIfContent]: true}, + + 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 00000000..e3e59a34 --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js @@ -0,0 +1,48 @@ +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + nameContent: + relation('transformContent', entry.name), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + }), + + generate: (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.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }); + } + + 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 00000000..ad17206f --- /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: ['getSizeOfMediaFile', '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, {getSizeOfMediaFile, 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 + ? getSizeOfMediaFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, location)) + : 0), + }))), + }), +}; diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js new file mode 100644 index 00000000..8c44c930 --- /dev/null +++ b/src/content/dependencies/generateAlbumArtInfoBox.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generateReleaseInfoContributionsLine'], + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + wallpaperArtistContributionsLine: + (album.wallpaperArtwork + ? relation('generateReleaseInfoContributionsLine', + album.wallpaperArtwork.artistContribs) + : null), + + bannerArtistContributionsLine: + (album.bannerArtwork + ? relation('generateReleaseInfoContributionsLine', + album.bannerArtwork.artistContribs) + : null), + }), + + generate: (relations, {html, language}) => + language.encapsulate('releaseInfo', capsule => + html.tag('div', {class: 'album-art-info'}, + {[html.onlyIfContent]: true}, + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + relations.wallpaperArtistContributionsLine?.slots({ + stringKey: capsule + '.wallpaperArtBy', + chronologyKind: 'wallpaperArt', + }), + + relations.bannerArtistContributionsLine?.slots({ + stringKey: capsule + '.bannerArtBy', + chronologyKind: 'bannerArt', + }), + ]))), +}; diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js new file mode 100644 index 00000000..e6762463 --- /dev/null +++ b/src/content/dependencies/generateAlbumArtworkColumn.js @@ -0,0 +1,38 @@ +export default { + contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'], + extraDependencies: ['html'], + + relations: (relation, album) => ({ + firstCover: + (album.hasCoverArt + ? relation('generateCoverArtwork', album.coverArtworks[0]) + : null), + + restCovers: + (album.hasCoverArt + ? album.coverArtworks.slice(1).map(artwork => + relation('generateCoverArtwork', artwork)) + : []), + + albumArtInfoBox: + relation('generateAlbumArtInfoBox', album), + }), + + generate: (relations, {html}) => + html.tags([ + relations.firstCover?.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + }), + + relations.albumArtInfoBox, + + relations.restCovers.map(cover => + cover.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + })), + ]), +}; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js new file mode 100644 index 00000000..3cc141bc --- /dev/null +++ b/src/content/dependencies/generateAlbumBanner.js @@ -0,0 +1,37 @@ +export default { + contentDependencies: ['generateBanner'], + extraDependencies: ['html', 'language'], + + relations(relation, album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + banner: relation('generateBanner'), + }; + }, + + data(album) { + if (!album.hasBannerArt) { + return {}; + } + + return { + path: ['media.albumBanner', album.directory, album.bannerFileExtension], + dimensions: album.bannerDimensions, + }; + }, + + generate(data, relations, {html, language}) { + if (!relations.banner) { + return html.blank(); + } + + return relations.banner.slots({ + path: data.path, + dimensions: data.dimensions, + alt: language.$('misc.alt.albumBanner'), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js new file mode 100644 index 00000000..1e39b47d --- /dev/null +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -0,0 +1,306 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumCommentarySidebar', + 'generateAlbumNavAccent', + 'generateAlbumSecondaryNav', + 'generateAlbumStyleRules', + 'generateCommentaryEntry', + 'generateContentHeading', + 'generateCoverArtwork', + 'generatePageLayout', + 'linkAlbum', + 'linkExternal', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(album) { + const query = {}; + + query.tracksWithCommentary = + album.tracks + .filter(({commentary}) => !empty(commentary)); + + query.thingsWithCommentary = + (empty(album.commentary) + ? query.tracksWithCommentary + : [album, ...query.tracksWithCommentary]); + + return query; + }, + + relations(relation, query, album) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.secondaryNav = + relation('generateAlbumSecondaryNav', album); + + relations.sidebar = + relation('generateAlbumCommentarySidebar', album); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album, null); + + relations.albumLink = + relation('linkAlbum', album); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + if (!empty(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('generateCoverArtwork', album.coverArtworks[0]); + } + + relations.albumCommentaryEntries = + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)); + } + + relations.trackCommentaryHeadings = + query.tracksWithCommentary + .map(() => relation('generateContentHeading')); + + relations.trackCommentaryLinks = + query.tracksWithCommentary + .map(track => relation('linkTrack', track)); + + relations.trackCommentaryListeningLinks = + query.tracksWithCommentary + .map(track => + track.urls.map(url => relation('linkExternal', url))); + + relations.trackCommentaryCovers = + query.tracksWithCommentary + .map(track => + (track.hasUniqueCoverArt + ? relation('generateCoverArtwork', track.trackArtworks[0]) + : null)); + + relations.trackCommentaryEntries = + query.tracksWithCommentary + .map(track => + track.commentary + .map(entry => relation('generateCommentaryEntry', entry))); + + return relations; + }, + + data(query, album) { + const data = {}; + + data.name = album.name; + data.color = album.color; + data.date = album.date; + + data.entryCount = + query.thingsWithCommentary + .flatMap(({commentary}) => commentary) + .length; + + data.wordCount = + query.thingsWithCommentary + .flatMap(({commentary}) => commentary) + .map(({body}) => body) + .join(' ') + .split(' ') + .length; + + data.trackCommentaryTrackDates = + query.tracksWithCommentary + .map(track => track.dateFirstReleased); + + data.trackCommentaryDirectories = + query.tracksWithCommentary + .map(track => track.directory); + + data.trackCommentaryColors = + query.tracksWithCommentary + .map(track => + (track.color === album.color + ? null + : track.color)); + + return data; + }, + + generate: (data, relations, {html, language}) => + language.encapsulate('albumCommentaryPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + album: data.name, + }), + + headingMode: 'sticky', + + color: data.color, + styleRules: [relations.albumStyleRules], + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', + {[html.joinChildren]: html.tag('br')}, + + [ + data.date && + data.entryCount >= 1 && + language.$('releaseInfo.albumReleased', { + date: + html.tag('b', + language.formatDate(data.date)), + }), + + language.encapsulate(pageCapsule, 'infoLine', workingCapsule => { + const workingOptions = {}; + + if (data.entryCount >= 1) { + workingOptions.words = + html.tag('b', + language.formatWordCount(data.wordCount, {unit: true})); + + workingOptions.entries = + html.tag('b', + language.countCommentaryEntries(data.entryCount, {unit: true})); + } + + if (data.entryCount === 0) { + workingCapsule += '.withoutCommentary'; + } + + return language.$(workingCapsule, workingOptions); + }) + ]), + + relations.albumCommentaryEntries && + language.encapsulate(pageCapsule, 'entry', entryCapsule => [ + language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule => + relations.albumCommentaryHeading.slots({ + tag: 'h3', + attributes: {id: 'album-commentary'}, + color: data.color, + + title: + language.$(titleCapsule, { + album: relations.albumCommentaryLink, + }), + + stickyTitle: + language.$(titleCapsule, 'sticky', { + album: data.name, + }), + + accent: + language.$(titleCapsule, 'accent', { + [language.onlyIfOptions]: ['listeningLinks'], + 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, + trackDate: data.trackCommentaryTrackDates, + }).map(({ + heading, + link, + listeningLinks, + directory, + cover, + entries, + color, + trackDate, + }) => + language.encapsulate(pageCapsule, 'entry', entryCapsule => [ + language.encapsulate(entryCapsule, 'title.trackCommentary', titleCapsule => + heading.slots({ + tag: 'h3', + attributes: {id: directory}, + color, + + title: + language.$(titleCapsule, { + track: link, + }), + + accent: + language.$(titleCapsule, 'accent', { + [language.onlyIfOptions]: ['listeningLinks'], + listeningLinks: + language.formatUnitList( + listeningLinks.map(link => + link.slot('tab', 'separate'))), + }), + })), + + cover?.slots({mode: 'commentary'}), + + trackDate && + trackDate !== data.date && + html.tag('p', {class: 'track-info'}, + language.$('releaseInfo.trackReleased', { + date: language.formatDate(trackDate), + })), + + 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', + }), + }, + ], + + secondaryNav: + relations.secondaryNav.slots({ + alwaysVisible: true, + }), + + leftSidebar: relations.sidebar, + })), +}; diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js new file mode 100644 index 00000000..9ecec66d --- /dev/null +++ b/src/content/dependencies/generateAlbumCommentarySidebar.js @@ -0,0 +1,73 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumSidebarTrackSection', + 'generatePageSidebar', + 'generatePageSidebarBox', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + sidebar: + relation('generatePageSidebar'), + + sidebarBox: + relation('generatePageSidebarBox'), + + albumLink: + relation('linkAlbum', album), + + trackSections: + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', + album, + null, + trackSection)), + }), + + data: (album) => ({ + albumHasCommentary: + !empty(album.commentary), + + anyTrackHasCommentary: + album.tracks.some(track => !empty(track.commentary)), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('albumCommentaryPage', pageCapsule => + relations.sidebar.slots({ + stickyMode: 'column', + boxes: [ + relations.sidebarBox.slots({ + attributes: {class: 'commentary-track-list-sidebar-box'}, + content: [ + html.tag('h1', relations.albumLink), + + html.tag('p', {[html.onlyIfContent]: true}, + language.encapsulate(pageCapsule, 'sidebar', workingCapsule => { + if (data.anyTrackHasCommentary) return html.blank(); + + if (data.albumHasCommentary) { + workingCapsule += '.noTrackCommentary'; + } else { + workingCapsule += '.noCommentary'; + } + + return language.$(workingCapsule); + })), + + data.anyTrackHasCommentary && + relations.trackSections.map(section => + section.slots({ + anchor: true, + open: true, + mode: 'commentary', + })), + ], + }), + ] + })), +} diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js new file mode 100644 index 00000000..7f152871 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js @@ -0,0 +1,90 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCoverGrid', + 'image', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + query: (album) => ({ + artworks: + (album.hasCoverArt + ? album.coverArtworks + : []), + }), + + relations: (relation, query, album) => ({ + coverGrid: + relation('generateCoverGrid'), + + albumLinks: + query.artworks.map(_artwork => + relation('linkAlbum', album)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, album) => ({ + albumName: + album.name, + + artworkLabels: + query.artworks + .map(artwork => artwork.label), + + artworkArtists: + query.artworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist.name)), + }), + + slots: { + attributes: {type: 'attributes', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('div', + {[html.onlyIfContent]: true}, + + slots.attributes, + + [ + relations.coverArtistsLine, + + relations.coverGrid.slots({ + links: + relations.albumLinks, + + names: + data.artworkLabels + .map(label => label ?? data.albumName), + + images: + stitchArrays({ + image: relations.images, + label: data.artworkLabels, + }).map(({image, label}) => + image.slots({ + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', { + name: + label ?? data.albumName, + }), + })), + + info: + data.artworkArtists.map(artists => + language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatUnitList(artists), + })), + }), + ]), +}; diff --git a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js new file mode 100644 index 00000000..7dcdf6de --- /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 00000000..ad99cb87 --- /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 00000000..2ba3b272 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -0,0 +1,167 @@ +import {stitchArrays, unique} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryAlbumGrid', + 'generateAlbumGalleryNoTrackArtworksLine', + 'generateAlbumGalleryStatsLine', + 'generateAlbumGalleryTrackGrid', + 'generateAlbumNavAccent', + 'generateAlbumSecondaryNav', + 'generateAlbumStyleRules', + 'generateIntrapageDotSwitcher', + 'generatePageLayout', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + query(album) { + const query = {}; + + const trackArtworkLabels = + album.tracks + .map(track => track.trackArtworks + .map(artwork => artwork.label)); + + const recurranceThreshold = 2; + + // This list may include null, if some artworks are not labelled! + // That's expected. + query.recurringTrackArtworkLabels = + unique(trackArtworkLabels.flat()) + .filter(label => + trackArtworkLabels + .filter(labels => labels.includes(label)) + .length >= + (label === null + ? 1 + : recurranceThreshold)); + + return query; + }, + + relations: (relation, query, album) => ({ + layout: + relation('generatePageLayout'), + + albumStyleRules: + relation('generateAlbumStyleRules', album, null), + + albumLink: + relation('linkAlbum', album), + + albumNavAccent: + relation('generateAlbumNavAccent', album, null), + + secondaryNav: + relation('generateAlbumSecondaryNav', album), + + statsLine: + relation('generateAlbumGalleryStatsLine', album), + + noTrackArtworksLine: + (album.tracks.every(track => !track.hasUniqueCoverArt) + ? relation('generateAlbumGalleryNoTrackArtworksLine') + : null), + + setSwitcher: + relation('generateIntrapageDotSwitcher'), + + albumGrid: + relation('generateAlbumGalleryAlbumGrid', album), + + trackGrids: + query.recurringTrackArtworkLabels.map(label => + relation('generateAlbumGalleryTrackGrid', album, label)), + }), + + data: (query, album) => ({ + trackGridLabels: + query.recurringTrackArtworkLabels, + + trackGridIDs: + query.recurringTrackArtworkLabels.map(label => + 'track-grid-' + + (label + ? getKebabCase(label) + : 'no-label')), + + name: + album.name, + + color: + album.color, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('albumGalleryPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + album: data.name, + }), + + headingMode: 'static', + + color: data.color, + styleRules: [relations.albumStyleRules], + + mainClasses: ['top-index'], + mainContent: [ + relations.statsLine, + + relations.albumGrid, + + relations.noTrackArtworksLine, + + data.trackGridLabels.some(value => value !== null) && + html.tag('p', {class: 'gallery-set-switcher'}, + language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule => + language.$(switcherCapsule, { + sets: + relations.setSwitcher.slots({ + initialOptionIndex: 0, + + titles: + data.trackGridLabels.map(label => + label ?? + language.$(switcherCapsule, 'unlabeledSet')), + + targetIDs: + data.trackGridIDs, + }), + }))), + + stitchArrays({ + grid: relations.trackGrids, + id: data.trackGridIDs, + }).map(({grid, id}, index) => + grid.slots({ + attributes: [ + {id}, + index >= 1 && {style: 'display: none'}, + ], + })), + ], + + 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 00000000..75bffb36 --- /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/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js new file mode 100644 index 00000000..85e7576c --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js @@ -0,0 +1,122 @@ +import {compareArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryCoverArtistsLine', + 'generateCoverGrid', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(album, label) { + const query = {}; + + query.artworks = + album.tracks.map(track => + track.trackArtworks.find(artwork => artwork.label === label) ?? + null); + + const presentArtworks = + query.artworks.filter(Boolean); + + if (presentArtworks.length > 1) { + const allArtistArrays = + presentArtworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist)); + + const allSameArtists = + allArtistArrays + .slice(1) + .every(artists => compareArrays(artists, allArtistArrays[0])); + + if (allSameArtists) { + query.artistsForAllTrackArtworks = + allArtistArrays[0]; + } + } + + return query; + }, + + relations: (relation, query, album, _label) => ({ + coverArtistsLine: + (query.artistsForAllTrackArtworks + ? relation('generateAlbumGalleryCoverArtistsLine', + query.artistsForAllTrackArtworks) + : null), + + coverGrid: + relation('generateCoverGrid'), + + albumLink: + relation('linkAlbum', album), + + trackLinks: + album.tracks + .map(track => relation('linkTrack', track)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, album, _label) => ({ + trackNames: + album.tracks + .map(track => track.name), + + trackArtworkArtists: + query.artworks.map(artwork => + (query.artistsForAllTrackArtworks + ? null + : artwork + ? artwork.artistContribs + .map(contrib => contrib.artist.name) + : null)), + }), + + slots: { + attributes: {type: 'attributes', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('div', + {[html.onlyIfContent]: true}, + + slots.attributes, + + [ + relations.coverArtistsLine, + + relations.coverGrid.slots({ + links: + relations.trackLinks, + + names: + data.trackNames, + + images: + stitchArrays({ + image: relations.images, + name: data.trackNames, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', {name}), + })), + + info: + data.trackArtworkArtists.map(artists => + language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatUnitList(artists), + })), + }), + ]), +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 00000000..d0788523 --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,238 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateAlbumAdditionalFilesList', + 'generateAlbumArtworkColumn', + 'generateAlbumBanner', + 'generateAlbumNavAccent', + 'generateAlbumReleaseInfo', + 'generateAlbumSecondaryNav', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateAlbumTrackList', + 'generateCommentaryEntry', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbumCommentary', + 'linkAlbumGallery', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + layout: + relation('generatePageLayout'), + + albumStyleRules: + relation('generateAlbumStyleRules', album, null), + + socialEmbed: + relation('generateAlbumSocialEmbed', album), + + albumNavAccent: + relation('generateAlbumNavAccent', album, null), + + secondaryNav: + relation('generateAlbumSecondaryNav', album), + + sidebar: + relation('generateAlbumSidebar', album, null), + + additionalNamesBox: + relation('generateAdditionalNamesBox', album.additionalNames), + + artworkColumn: + relation('generateAlbumArtworkColumn', album), + + banner: + (album.hasBannerArt + ? relation('generateAlbumBanner', album) + : null), + + contentHeading: + relation('generateContentHeading'), + + releaseInfo: + relation('generateAlbumReleaseInfo', album), + + galleryLink: + (album.tracks.some(t => t.hasUniqueCoverArt) + ? relation('linkAlbumGallery', album) + : null), + + commentaryLink: + ([album, ...album.tracks].some(({commentary}) => !empty(commentary)) + ? relation('linkAlbumCommentary', album) + : null), + + trackList: + relation('generateAlbumTrackList', album), + + additionalFilesList: + relation('generateAlbumAdditionalFilesList', + album, + album.additionalFiles), + + artistCommentaryEntries: + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)), + + creditSourceEntries: + album.creditSources + .map(entry => relation('generateCommentaryEntry', entry)), + }), + + data: (album) => ({ + name: + album.name, + + color: + album.color, + + dateAddedToWiki: + album.dateAddedToWiki, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('albumPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + album: data.name, + }), + + color: data.color, + headingMode: 'sticky', + styleRules: [relations.albumStyleRules], + + additionalNames: relations.additionalNamesBox, + + artworkColumnContent: + relations.artworkColumn, + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate('releaseInfo', capsule => [ + !html.isBlank(relations.additionalFilesList) && + language.$(capsule, 'additionalFiles.shortcut', { + link: html.tag('a', + {href: '#additional-files'}, + language.$(capsule, 'additionalFiles.shortcut.link')), + }), + + (relations.galleryLink && relations.commentaryLink + ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule => + language.$(capsule, { + gallery: + relations.galleryLink + .slot('content', language.$(capsule, 'gallery')), + + commentary: + relations.commentaryLink + .slot('content', language.$(capsule, 'commentary')), + })) + + : relations.galleryLink + ? language.encapsulate(capsule, 'viewGallery', capsule => + language.$(capsule, { + link: + relations.galleryLink + .slot('content', language.$(capsule, 'link')), + })) + + : relations.commentaryLink + ? language.encapsulate(capsule, 'viewCommentary', capsule => + language.$(capsule, { + link: + relations.commentaryLink + .slot('content', language.$(capsule, 'link')), + })) + + : html.blank()), + + !html.isBlank(relations.creditSourceEntries) && + language.encapsulate(capsule, 'readCreditSources', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#credit-sources'}, + language.$(capsule, 'link')), + })), + ])), + + relations.trackList, + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate('releaseInfo', capsule => [ + language.$(capsule, 'addedToWiki', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.dateAddedToWiki), + }), + ])), + + language.encapsulate('releaseInfo.additionalFiles', capsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'additional-files'}, + title: language.$(capsule, 'heading'), + }), + + relations.additionalFilesList, + ])), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'artist-commentary'}, + title: language.$('misc.artistCommentary'), + }), + + relations.artistCommentaryEntries, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'credit-sources'}, + title: language.$('misc.creditSources'), + }), + + relations.creditSourceEntries, + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + auto: 'current', + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: true, + }), + }, + ], + + 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 00000000..432c5f3d --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,142 @@ +import {atOffset, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'generateNextLink', + 'generatePreviousLink', + 'linkTrack', + 'linkAlbumCommentary', + 'linkAlbumGallery', + ], + + extraDependencies: ['html', 'language'], + + query(album, track) { + const query = {}; + + const index = + (track + ? album.tracks.indexOf(track) + : null); + + query.previousTrack = + (track + ? atOffset(album.tracks, index, -1) + : null); + + query.nextTrack = + (track + ? atOffset(album.tracks, index, +1) + : null); + + return query; + }, + + relations: (relation, query, album, _track) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + previousLink: + relation('generatePreviousLink'), + + nextLink: + relation('generateNextLink'), + + previousTrackLink: + (query.previousTrack + ? relation('linkTrack', query.previousTrack) + : null), + + nextTrackLink: + (query.nextTrack + ? relation('linkTrack', query.nextTrack) + : null), + + albumGalleryLink: + relation('linkAlbumGallery', album), + + albumCommentaryLink: + relation('linkAlbumCommentary', album), + }), + + data: (query, album, track) => ({ + hasMultipleTracks: + album.tracks.length > 1, + + commentaryPageIsStub: + [album, ...album.tracks] + .every(({commentary}) => empty(commentary)), + + 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 albumNavCapsule = language.encapsulate('albumPage.nav'); + const trackNavCapsule = language.encapsulate('trackPage.nav'); + + const previousLink = + data.isTrackPage && + relations.previousLink.slot('link', relations.previousTrackLink); + + const nextLink = + data.isTrackPage && + relations.nextLink.slot('link', relations.nextTrackLink); + + const galleryLink = + (!data.galleryIsStub || slots.currentExtra === 'gallery') && + relations.albumGalleryLink.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$(albumNavCapsule, 'gallery'), + }); + + const commentaryLink = + (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') && + relations.albumCommentaryLink.slots({ + attributes: {class: slots.currentExtra === 'commentary' && 'current'}, + content: language.$(albumNavCapsule, 'commentary'), + }); + + const randomLink = + data.hasMultipleTracks && + html.tag('a', + {id: 'random-button'}, + {href: '#', 'data-random': 'track-in-sidebar'}, + + (data.isTrackPage + ? language.$(trackNavCapsule, 'random') + : language.$(albumNavCapsule, 'randomTrack'))); + + return relations.switcher.slots({ + links: [ + slots.showTrackNavigation && + previousLink, + + slots.showTrackNavigation && + nextLink, + + slots.showExtraLinks && + galleryLink, + + slots.showExtraLinks && + commentaryLink, + + slots.showTrackNavigation && + randomLink, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js new file mode 100644 index 00000000..7586393c --- /dev/null +++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js @@ -0,0 +1,58 @@ +export default { + contentDependencies: [ + 'generateAlbumStyleRules', + 'generateBackToAlbumLink', + 'generateReferencedArtworksPage', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + page: + relation('generateReferencedArtworksPage', album.coverArtworks[0]), + + albumStyleRules: + relation('generateAlbumStyleRules', album, null), + + albumLink: + relation('linkAlbum', album), + + backToAlbumLink: + relation('generateBackToAlbumLink', album), + }), + + data: (album) => ({ + name: + album.name, + }), + + generate: (data, relations, {html, language}) => + relations.page.slots({ + title: + language.$('albumPage.title', { + album: + data.name, + }), + + styleRules: [relations.albumStyleRules], + + navLinks: [ + {auto: 'home'}, + + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + + accent: + html.tag('a', {href: ''}, + {class: 'current'}, + + language.$('referencedArtworksPage.subtitle')), + }, + ], + + navBottomRowContent: relations.backToAlbumLink, + }), +}; diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js new file mode 100644 index 00000000..d072d2f6 --- /dev/null +++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js @@ -0,0 +1,58 @@ +export default { + contentDependencies: [ + 'generateAlbumStyleRules', + 'generateBackToAlbumLink', + 'generateReferencingArtworksPage', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + page: + relation('generateReferencingArtworksPage', album.coverArtworks[0]), + + albumStyleRules: + relation('generateAlbumStyleRules', album, null), + + albumLink: + relation('linkAlbum', album), + + backToAlbumLink: + relation('generateBackToAlbumLink', album), + }), + + data: (album) => ({ + name: + album.name, + }), + + generate: (data, relations, {html, language}) => + relations.page.slots({ + title: + language.$('albumPage.title', { + album: + data.name, + }), + + styleRules: [relations.albumStyleRules], + + navLinks: [ + {auto: 'home'}, + + { + html: + relations.albumLink + .slot('attributes', {class: 'current'}), + + accent: + html.tag('a', {href: ''}, + {class: 'current'}, + + language.$('referencingArtworksPage.subtitle')), + }, + ], + + navBottomRowContent: relations.backToAlbumLink, + }), +}; diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js new file mode 100644 index 00000000..0abb412c --- /dev/null +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -0,0 +1,107 @@ +import {accumulateSum, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.artistContribs); + + relations.wallpaperArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); + + relations.bannerArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); + + relations.externalLinks = + album.urls.map(url => + relation('linkExternal', url)); + + return relations; + }, + + data(album) { + const data = {}; + + if (album.date) { + data.date = album.date; + } + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + + const durationTerms = + album.tracks + .map(track => track.duration) + .filter(value => value > 0); + + if (empty(durationTerms)) { + data.duration = null; + data.durationApproximate = null; + } else { + data.duration = accumulateSum(durationTerms); + data.durationApproximate = album.tracks.length > 1; + } + + data.numTracks = album.tracks.length; + + return data; + }, + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo', capsule => + html.tags([ + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + relations.artistContributionsLine.slots({ + stringKey: capsule + '.by', + featuringStringKey: capsule + '.by.featuring', + chronologyKind: 'album', + }), + + language.$(capsule, 'released', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.date), + }), + + language.$(capsule, 'duration', { + [language.onlyIfOptions]: ['duration'], + duration: + language.formatDuration(data.duration, { + approximate: data.durationApproximate, + }), + }), + ]), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$(capsule, 'listenOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => + link.slot('context', [ + 'album', + (data.numTracks === 0 + ? 'albumNoTracks' + : data.numTracks === 1 + ? 'albumOneTrack' + : 'albumMultipleTracks'), + ]))), + })), + ])), +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js new file mode 100644 index 00000000..bfa48f03 --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -0,0 +1,127 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumSecondaryNavGroupPart', + 'generateAlbumSecondaryNavSeriesPart', + 'generateDotSwitcherTemplate', + 'generateSecondaryNav', + ], + + extraDependencies: ['html', 'wikiData'], + + sprawl: ({groupData}) => ({ + // TODO: Series aren't their own things, so we access them weirdly. + seriesData: + groupData.flatMap(group => group.serieses), + }), + + query(sprawl, album) { + const query = {}; + + query.groups = + album.groups; + + query.groupSerieses = + query.groups + .map(group => + group.serieses + .filter(series => series.albums.includes(album))); + + query.disconnectedSerieses = + sprawl.seriesData + .filter(series => + series.albums.includes(album) && + !query.groups.includes(series.group)); + + return query; + }, + + relations: (relation, query, _sprawl, album) => ({ + secondaryNav: + relation('generateSecondaryNav'), + + // Just use a generic dot switcher here. We want the common behavior, + // but the "options" may each contain multiple links (group + series), + // so this is a different use than typical interpage dot switchers. + switcher: + relation('generateDotSwitcherTemplate'), + + groupParts: + query.groups + .map(group => + relation('generateAlbumSecondaryNavGroupPart', + group, + album)), + + seriesParts: + query.groupSerieses + .map(serieses => serieses + .map(series => + relation('generateAlbumSecondaryNavSeriesPart', + series, + album))), + + disconnectedSeriesParts: + query.disconnectedSerieses + .map(series => + relation('generateAlbumSecondaryNavSeriesPart', + series, + album)), + }), + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + + alwaysVisible: { + type: 'boolean', + default: false, + }, + }, + + generate(relations, slots, {html}) { + const groupConnectedParts = + stitchArrays({ + groupPart: relations.groupParts, + seriesParts: relations.seriesParts, + }).map(({groupPart, seriesParts}) => { + for (const part of [groupPart, ...seriesParts]) { + part.setSlot('mode', slots.mode); + } + + if (html.isBlank(seriesParts)) { + return groupPart; + } else { + return ( + html.tag('span', {class: 'group-with-series'}, + {[html.joinChildren]: ''}, + + [groupPart, ...seriesParts])); + } + }); + + const allParts = [ + ...relations.disconnectedSeriesParts, + ...groupConnectedParts, + ]; + + return relations.secondaryNav.slots({ + alwaysVisible: slots.alwaysVisible, + + attributes: [ + {class: 'album-secondary-nav'}, + + slots.mode === 'album' && + {class: 'with-previous-next'}, + ], + + content: + (slots.mode === 'album' + ? allParts + : relations.switcher.slot('options', allParts)), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js new file mode 100644 index 00000000..22dfa51c --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js @@ -0,0 +1,94 @@ +import {sortChronologically} from '#sort'; +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateSecondaryNavParentSiblingsPart', + 'linkAlbumDynamically', + 'linkGroup', + ], + + extraDependencies: ['html'], + + query(group, album) { + const query = {}; + + 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 albums = + sortChronologically( + group.albums.filter(album => album.date), + {latestFirst: true}); + + const currentIndex = + albums.indexOf(album); + + query.previousAlbum = + atOffset(albums, currentIndex, +1); + + query.nextAlbum = + atOffset(albums, currentIndex, -1); + } + + return query; + }, + + relations: (relation, query, group, _album) => ({ + parentSiblingsPart: + relation('generateSecondaryNavParentSiblingsPart'), + + groupLink: + relation('linkGroup', group), + + colorStyle: + relation('generateColorStyleAttribute', group.color), + + previousAlbumLink: + (query.previousAlbum + ? relation('linkAlbumDynamically', query.previousAlbum) + : null), + + nextAlbumLink: + (query.nextAlbum + ? relation('linkAlbumDynamically', query.nextAlbum) + : null), + }), + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + }, + + generate: (relations, slots) => + relations.parentSiblingsPart.slots({ + attributes: {class: 'group-nav-links'}, + + showPreviousNext: slots.mode === 'album', + + colorStyle: relations.colorStyle, + mainLink: relations.groupLink, + + previousLink: + (relations.previousAlbumLink + ? relations.previousAlbumLink.slots({ + linkCommentaryPages: true, + }) + : null), + + nextLink: + (relations.nextAlbumLink + ? relations.nextAlbumLink.slots({ + linkCommentaryPages: true, + }) + : null), + + stringsKey: 'albumSecondaryNav.group', + mainLinkOption: 'group', + }), +}; diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js new file mode 100644 index 00000000..16f205e3 --- /dev/null +++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js @@ -0,0 +1,94 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateSecondaryNavParentSiblingsPart', + 'linkAlbumDynamically', + 'linkGroup', + ], + + extraDependencies: ['html', 'language'], + + query(series, album) { + const query = {}; + + const albums = + series.albums; + + const currentIndex = + albums.indexOf(album); + + query.previousAlbum = + atOffset(albums, currentIndex, -1); + + query.nextAlbum = + atOffset(albums, currentIndex, +1); + + return query; + }, + + relations: (relation, query, series, _album) => ({ + parentSiblingsPart: + relation('generateSecondaryNavParentSiblingsPart'), + + groupLink: + relation('linkGroup', series.group), + + colorStyle: + relation('generateColorStyleAttribute', series.group.color), + + previousAlbumLink: + (query.previousAlbum + ? relation('linkAlbumDynamically', query.previousAlbum) + : null), + + nextAlbumLink: + (query.nextAlbum + ? relation('linkAlbumDynamically', query.nextAlbum) + : null), + }), + + data: (_query, series) => ({ + name: series.name, + }), + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'album', + }, + }, + + generate: (data, relations, slots, {language}) => + relations.parentSiblingsPart.slots({ + attributes: {class: 'series-nav-links'}, + + showPreviousNext: slots.mode === 'album', + + colorStyle: relations.colorStyle, + + mainLink: + relations.groupLink.slots({ + attributes: {class: 'series'}, + content: language.sanitize(data.name), + }), + + previousLink: + (relations.previousAlbumLink + ? relations.previousAlbumLink.slots({ + linkCommentaryPages: true, + }) + : null), + + nextLink: + (relations.nextAlbumLink + ? relations.nextAlbumLink.slots({ + linkCommentaryPages: true, + }) + : null), + + stringsKey: 'albumSecondaryNav.series', + mainLinkOption: 'series', + }), +}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 00000000..7cf689cc --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,171 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays, transposeArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumSidebarGroupBox', + 'generateAlbumSidebarSeriesBox', + 'generateAlbumSidebarTrackListBox', + 'generatePageSidebar', + 'generatePageSidebarConjoinedBox', + 'generateTrackReleaseBox', + ], + + extraDependencies: ['html', 'wikiData'], + + sprawl: ({groupData}) => ({ + // TODO: Series aren't their own things, so we access them weirdly. + seriesData: + groupData.flatMap(group => group.serieses), + }), + + query(sprawl, album, track) { + const query = {}; + + query.groups = + album.groups; + + query.groupSerieses = + query.groups + .map(group => + group.serieses + .filter(series => series.albums.includes(album))); + + query.disconnectedSerieses = + sprawl.seriesData + .filter(series => + series.albums.includes(album) && + !query.groups.includes(series.group)); + + if (track) { + const albumTrackMap = + new Map(transposeArrays([ + track.allReleases.map(t => t.album), + track.allReleases, + ])); + + const allReleaseAlbums = + sortAlbumsTracksChronologically( + Array.from(albumTrackMap.keys())); + + const currentReleaseIndex = + allReleaseAlbums.indexOf(track.album); + + const earlierReleaseAlbums = + allReleaseAlbums.slice(0, currentReleaseIndex); + + const laterReleaseAlbums = + allReleaseAlbums.slice(currentReleaseIndex + 1); + + query.earlierReleaseTracks = + earlierReleaseAlbums.map(album => albumTrackMap.get(album)); + + query.laterReleaseTracks = + laterReleaseAlbums.map(album => albumTrackMap.get(album)); + } + + return query; + }, + + relations: (relation, query, _sprawl, album, track) => ({ + sidebar: + relation('generatePageSidebar'), + + conjoinedBox: + relation('generatePageSidebarConjoinedBox'), + + trackListBox: + relation('generateAlbumSidebarTrackListBox', album, track), + + groupBoxes: + query.groups + .map(group => + relation('generateAlbumSidebarGroupBox', album, group)), + + seriesBoxes: + query.groupSerieses + .map(serieses => serieses + .map(series => + relation('generateAlbumSidebarSeriesBox', album, series))), + + disconnectedSeriesBoxes: + query.disconnectedSerieses + .map(series => + relation('generateAlbumSidebarSeriesBox', album, series)), + + earlierTrackReleaseBoxes: + (track + ? query.earlierReleaseTracks + .map(track => + relation('generateTrackReleaseBox', track)) + : null), + + laterTrackReleaseBoxes: + (track + ? query.laterReleaseTracks + .map(track => + relation('generateTrackReleaseBox', track)) + : null), + }), + + data: (_query, _sprawl, _album, track) => ({ + isAlbumPage: !track, + isTrackPage: !!track, + }), + + generate(data, relations, {html}) { + for (const box of [ + ...relations.groupBoxes, + ...relations.seriesBoxes.flat(), + ...relations.disconnectedSeriesBoxes, + ]) { + box.setSlot('mode', + data.isAlbumPage ? 'album' : 'track'); + } + + return relations.sidebar.slots({ + boxes: [ + data.isAlbumPage && [ + relations.disconnectedSeriesBoxes, + + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).map(({groupBox, seriesBoxes}) => [ + groupBox, + seriesBoxes.map(seriesBox => [ + html.tag('div', + {class: 'sidebar-box-joiner'}, + {class: 'collapsible'}), + seriesBox, + ]), + ]), + ], + + data.isTrackPage && + relations.earlierTrackReleaseBoxes, + + relations.trackListBox, + + data.isTrackPage && + relations.laterTrackReleaseBoxes, + + data.isTrackPage && + relations.conjoinedBox.slots({ + attributes: {class: 'conjoined-group-sidebar-box'}, + boxes: + ([relations.disconnectedSeriesBoxes, + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).flatMap(({groupBox, seriesBoxes}) => [ + groupBox, + ...seriesBoxes, + ]), + ]).flat() + .map(box => box.content), /* TODO: Kludge. */ + }), + ], + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js new file mode 100644 index 00000000..f3be74f7 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,126 @@ +import {sortChronologically} from '#sort'; +import {atOffset} 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}) => + language.encapsulate('albumSidebar.groupBox', boxCapsule => + relations.box.slots({ + attributes: {class: 'individual-group-sidebar-box'}, + content: [ + html.tag('h1', + language.$(boxCapsule, 'title', { + group: relations.groupLink, + })), + + slots.mode === 'album' && + relations.description + ?.slot('mode', 'multiline'), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.visitOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'group'))), + })), + + slots.mode === 'album' && + html.tag('p', {class: 'group-chronology-link'}, + {[html.onlyIfContent]: true}, + + language.$(boxCapsule, 'next', { + [language.onlyIfOptions]: ['album'], + + album: relations.nextAlbumLink, + })), + + slots.mode === 'album' && + html.tag('p', {class: 'group-chronology-link'}, + {[html.onlyIfContent]: true}, + + language.$(boxCapsule, 'previous', { + [language.onlyIfOptions]: ['album'], + + album: relations.previousAlbumLink, + })), + ], + })), +}; diff --git a/src/content/dependencies/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js new file mode 100644 index 00000000..37616cb2 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js @@ -0,0 +1,102 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageSidebarBox', + 'linkAlbum', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query(album, series) { + const query = {}; + + const albums = + series.albums; + + const index = + albums.indexOf(album); + + query.previousAlbum = + atOffset(albums, index, -1); + + query.nextAlbum = + atOffset(albums, index, +1); + + return query; + }, + + relations: (relation, query, _album, series) => ({ + box: + relation('generatePageSidebarBox'), + + groupLink: + relation('linkGroup', series.group), + + description: + relation('transformContent', series.description), + + previousAlbumLink: + (query.previousAlbum + ? relation('linkAlbum', query.previousAlbum) + : null), + + nextAlbumLink: + (query.nextAlbum + ? relation('linkAlbum', query.nextAlbum) + : null), + }), + + data: (_query, _album, series) => ({ + name: series.name, + }), + + slots: { + mode: { + validate: v => v.is('album', 'track'), + default: 'track', + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('albumSidebar.groupBox', boxCapsule => + relations.box.slots({ + attributes: {class: 'individual-series-sidebar-box'}, + content: [ + html.tag('h1', + language.$(boxCapsule, 'title', { + group: + relations.groupLink.slots({ + attributes: {class: 'series'}, + content: language.sanitize(data.name), + }), + })), + + slots.mode === 'album' && + relations.description + ?.slot('mode', 'multiline'), + + slots.mode === 'album' && + html.tag('p', {class: 'series-chronology-link'}, + {[html.onlyIfContent]: true}, + + language.$(boxCapsule, 'next', { + [language.onlyIfOptions]: ['album'], + + album: relations.nextAlbumLink, + })), + + slots.mode === 'album' && + html.tag('p', {class: 'series-chronology-link'}, + {[html.onlyIfContent]: true}, + + language.$(boxCapsule, 'previous', { + [language.onlyIfOptions]: ['album'], + + album: relations.previousAlbumLink, + })), + ], + })), +}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js new file mode 100644 index 00000000..3a244e3a --- /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 00000000..dae5fa03 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -0,0 +1,167 @@ +import {empty, stitchArrays} from '#sugar'; + +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 && + !empty(trackSection.tracks); + + data.isTrackPage = !!track; + + data.name = trackSection.name; + data.color = trackSection.color; + data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + + data.firstTrackNumber = + (data.hasTrackNumbers + ? trackSection.tracks.at(0).trackNumber + : null); + + data.lastTrackNumber = + (data.hasTrackNumbers + ? trackSection.tracks.at(-1).trackNumber + : null); + + data.trackDirectories = + trackSection.tracks + .map(track => track.directory); + + data.tracksAreMissingCommentary = + trackSection.tracks + .map(track => empty(track.commentary)); + + data.tracksAreCurrentTrack = + trackSection.tracks + .map(traaaaaaaack => traaaaaaaack === track); + + data.includesCurrentTrack = + data.tracksAreCurrentTrack.includes(true); + + 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 capsule = language.encapsulate('albumSidebar.trackList'); + + const sectionName = + html.tag('b', + (data.isDefaultTrackSection + ? language.$(capsule, 'fallbackSectionName') + : data.name)); + + let colorStyle; + if (data.color) { + const {primary} = getColors(data.color); + colorStyle = {style: `--primary-color: ${primary}`}; + } + + const trackListItems = + stitchArrays({ + trackLink: relations.trackLinks, + directory: data.trackDirectories, + isCurrentTrack: data.tracksAreCurrentTrack, + missingCommentary: data.tracksAreMissingCommentary, + }).map(({ + trackLink, + directory, + isCurrentTrack, + missingCommentary, + }) => + html.tag('li', + data.includesCurrentTrack && + isCurrentTrack && + {class: 'current'}, + + slots.mode === 'commentary' && + missingCommentary && + {class: 'no-commentary'}, + + language.$(capsule, 'item', { + track: + (slots.mode === 'commentary' && missingCommentary + ? trackLink.slots({ + linkless: true, + }) + : slots.anchor + ? trackLink.slots({ + anchor: true, + hash: directory, + }) + : 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', + language.encapsulate(capsule, 'group', groupCapsule => + language.encapsulate(groupCapsule, workingCapsule => { + const workingOptions = {group: sectionName}; + + if (data.hasTrackNumbers) { + workingCapsule += '.withRange'; + workingOptions.rangePart = + html.tag('span', {class: 'track-section-range'}, + language.$(groupCapsule, 'withRange.rangePart', { + range: + `${data.firstTrackNumber}–${data.lastTrackNumber}`, + })); + } + + return language.$(workingCapsule, workingOptions); + })))), + + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js new file mode 100644 index 00000000..e28a3fd0 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,70 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateSocialEmbed', + 'generateAlbumSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language'], + + 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.name; + data.headingGroupDirectory = firstGroup.directory; + } + + data.hasImage = album.hasCoverArt; + + if (data.hasImage) { + data.imagePath = album.coverArtworks[0].path; + } + + data.albumName = album.name; + + return data; + }, + + generate: (data, relations, {absoluteTo, language}) => + language.encapsulate('albumPage.socialEmbed', embedCapsule => + relations.socialEmbed.slots({ + title: + language.$(embedCapsule, 'title', { + album: data.albumName, + }), + + description: relations.description, + + headingContent: + (data.hasHeading + ? language.$(embedCapsule, 'heading', { + group: data.headingGroupName, + }) + : null), + + headingLink: + (data.hasHeading + ? absoluteTo('localized.groupGallery', data.headingGroupDirectory) + : null), + + imagePath: + (data.hasImage + ? data.imagePath + : null), + })), +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js new file mode 100644 index 00000000..69c39c3a --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,41 @@ +import {accumulateSum} from '#sugar'; + +export default { + extraDependencies: ['language'], + + data: (album) => ({ + duration: + accumulateSum(album.tracks, track => track.duration), + + tracks: + album.tracks.length, + + date: + album.date, + }), + + generate: (data, {language}) => + language.encapsulate('albumPage.socialEmbed.body', workingCapsule => { + const workingOptions = {}; + + if (data.duration > 0) { + workingCapsule += '.withDuration'; + workingOptions.duration = + language.formatDuration(data.duration); + } + + if (data.tracks > 0) { + workingCapsule += '.withTracks'; + workingOptions.tracks = + language.countTracks(data.tracks, {unit: true}); + } + + if (data.date) { + workingCapsule += '.withReleaseDate'; + workingOptions.date = + language.formatDate(data.date); + } + + return language.$(workingCapsule, workingOptions); + }), +}; diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js new file mode 100644 index 00000000..6bfcc62e --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,107 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + extraDependencies: ['to'], + + data(album, track) { + const data = {}; + + data.hasWallpaper = !empty(album.wallpaperArtistContribs); + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasWallpaper) { + if (!empty(album.wallpaperParts)) { + data.wallpaperMode = 'parts'; + + data.wallpaperPaths = + album.wallpaperParts.map(part => + (part.asset + ? ['media.albumWallpaperPart', album.directory, part.asset] + : null)); + + data.wallpaperStyles = + album.wallpaperParts.map(part => part.style); + } else { + data.wallpaperMode = 'one'; + data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; + data.wallpaperStyle = album.wallpaperStyle; + } + } + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + data.albumDirectory = album.directory; + + if (track) { + data.trackDirectory = track.directory; + } + + return data; + }, + + generate(data, {to}) { + const indent = parts => + (parts ?? []) + .filter(Boolean) + .join('\n') + .split('\n') + .map(line => ' '.repeat(4) + line) + .join('\n'); + + const rule = (selector, parts) => + (!empty(parts.filter(Boolean)) + ? [`${selector} {`, indent(parts), `}`] + : []); + + const oneWallpaperRule = + data.wallpaperMode === 'one' && + rule(`body::before`, [ + `background-image: url("${to(...data.wallpaperPath)}");`, + data.wallpaperStyle, + ]); + + const wallpaperPartRules = + data.wallpaperMode === 'parts' && + stitchArrays({ + path: data.wallpaperPaths, + style: data.wallpaperStyles, + }).map(({path, style}, index) => + rule(`.wallpaper-part:nth-child(${index + 1})`, [ + path && `background-image: url("${to(...path)}");`, + style, + ])); + + const nukeBasicWallpaperRule = + data.wallpaperMode === 'parts' && + rule(`body::before`, ['display: none']); + + const wallpaperRules = [ + oneWallpaperRule, + ...wallpaperPartRules || [], + nukeBasicWallpaperRule, + ]; + + const bannerRule = + data.hasBanner && + rule(`#banner img`, [ + data.bannerStyle, + ]); + + const dataRule = + rule(`:root`, [ + data.albumDirectory && + `--album-directory: ${data.albumDirectory};`, + data.trackDirectory && + `--track-directory: ${data.trackDirectory};`, + ]); + + return ( + [...wallpaperRules, bannerRule, dataRule] + .filter(Boolean) + .flat() + .join('\n')); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 00000000..0a949ded --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,206 @@ +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', + 'transformContent', + ], + + 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.trackSectionDescriptions = + album.trackSections.map(section => + relation('transformContent', section.description)); + + 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.trackSectionsStartCountingFrom = + album.trackSections + .map(section => section.startCountingFrom); + } else { + data.trackSectionsStartCountingFrom = + 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, + description: relations.trackSectionDescriptions, + items: relations.trackSectionItems, + + name: data.trackSectionNames, + duration: data.trackSectionDurations, + durationApproximate: data.trackSectionDurationsApproximate, + startCountingFrom: data.trackSectionsStartCountingFrom, + }).map(({ + heading, + description, + items, + + name, + duration, + durationApproximate, + startCountingFrom, + }) => [ + language.encapsulate('trackList.section', capsule => + heading.slots({ + tag: 'dt', + + title: + language.encapsulate(capsule, capsule => { + const options = {section: name}; + + if (duration !== 0) { + capsule += '.withDuration'; + options.duration = + language.formatDuration(duration, { + approximate: durationApproximate, + }); + } + + return language.$(capsule, options); + }), + + stickyTitle: + language.$(capsule, 'sticky', { + section: name, + }), + })), + + html.tag('dd', [ + html.tag('blockquote', + {[html.onlyIfContent]: true}, + description), + + html.tag(listTag, + data.hasTrackNumbers && + {start: startCountingFrom}, + + 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 00000000..44297c15 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,62 @@ +export default { + contentDependencies: ['generateTrackListItem'], + extraDependencies: ['html'], + + query: (track, album) => ({ + trackHasDuration: + !!track.duration, + + sectionHasDuration: + !album.trackSections + .some(section => + section.tracks.every(track => !track.duration) && + section.tracks.includes(track)), + + albumHasDuration: + album.tracks.some(track => track.duration), + }), + + relations: (relation, query, track) => ({ + item: + relation('generateTrackListItem', + track, + track.album.artistContribs), + }), + + data: (query, track, album) => ({ + trackHasDuration: query.trackHasDuration, + sectionHasDuration: query.sectionHasDuration, + albumHasDuration: query.albumHasDuration, + + colorize: + track.color !== album.color, + }), + + slots: { + collapseDurationScope: { + validate: v => + v.is('never', 'track', 'section', 'album'), + + default: 'album', + }, + }, + + generate: (data, relations, slots) => + relations.item.slots({ + showArtists: true, + + showDuration: + (slots.collapseDurationScope === 'track' + ? data.trackHasDuration + : slots.collapseDurationScope === 'section' + ? data.sectionHasDuration + : slots.collapseDurationScope === 'album' + ? data.albumHasDuration + : true), + + colorMode: + (data.colorize + ? 'line' + : 'none'), + }), +}; diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js new file mode 100644 index 00000000..80d19b5a --- /dev/null +++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js @@ -0,0 +1,153 @@ +import { + filterMultipleArrays, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + contentDependencies: ['linkArtTagDynamically'], + extraDependencies: ['html', 'language'], + + // Recursion ain't too pretty! + + query(ancestorArtTag, targetArtTag) { + const recursive = artTag => { + const artTags = + artTag.directDescendantArtTags.slice(); + + const displayBriefly = + !artTags.includes(targetArtTag) && + artTags.length > 3; + + const artTagsIncludeTargetArtTag = + artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag)); + + const numExemptArtTags = + (displayBriefly + ? artTagsIncludeTargetArtTag + .filter(includesTargetArtTag => !includesTargetArtTag) + .length + : null); + + const artTagsTimesFeaturedTotal = + artTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length); + + const sublists = + stitchArrays({ + artTag: artTags, + includesTargetArtTag: artTagsIncludeTargetArtTag, + }).map(({artTag, includesTargetArtTag}) => + (includesTargetArtTag + ? recursive(artTag) + : null)); + + if (displayBriefly) { + filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal, + (artTag, sublist) => + artTag === targetArtTag || + sublist !== null); + } else { + sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal, + (artTagA, artTagB, sublistA, sublistB) => + (sublistA && sublistB + ? 0 + : !sublistA && !sublistB + ? 0 + : sublistA + ? 1 + : -1)); + } + + return { + displayBriefly, + numExemptArtTags, + artTags, + artTagsTimesFeaturedTotal, + sublists, + }; + }; + + return {root: recursive(ancestorArtTag)}; + }, + + relations(relation, query, _ancestorArtTag, _targetArtTag) { + const recursive = ({artTags, sublists}) => ({ + artTagLinks: + artTags + .map(artTag => relation('linkArtTagDynamically', artTag)), + + sublists: + sublists + .map(sublist => (sublist ? recursive(sublist) : null)), + }); + + return {root: recursive(query.root)}; + }, + + data(query, _ancestorArtTag, targetArtTag) { + const recursive = ({ + displayBriefly, + numExemptArtTags, + artTags, + artTagsTimesFeaturedTotal, + sublists, + }) => ({ + displayBriefly, + numExemptArtTags, + artTagsTimesFeaturedTotal, + + artTagsAreTargetTag: + artTags + .map(artTag => artTag === targetArtTag), + + sublists: + sublists + .map(sublist => (sublist ? recursive(sublist) : null)), + }); + + return {root: recursive(query.root)}; + }, + + generate(data, relations, {html, language}) { + const recursive = (dataNode, relationsNode) => + html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [ + dataNode.displayBriefly && + html.tag('dt', + language.$('artTagPage.sidebar.otherTagsExempt', { + tags: + language.countArtTags(dataNode.numExemptArtTags, {unit: true}), + })), + + stitchArrays({ + isTargetTag: dataNode.artTagsAreTargetTag, + timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal, + dataSublist: dataNode.sublists, + + artTagLink: relationsNode.artTagLinks, + relationsSublist: relationsNode.sublists, + }).map(({ + isTargetTag, timesFeaturedTotal, dataSublist, + artTagLink, relationsSublist, + }) => [ + html.tag('dt', + {class: (dataSublist || isTargetTag) && 'current'}, + [ + artTagLink, + html.tag('span', {class: 'times-used'}, + language.countTimesFeatured(timesFeaturedTotal)), + ]), + + dataSublist && + html.tag('dd', + recursive(dataSublist, relationsSublist)), + ]), + ]); + + return recursive(data.root, relations.root); + }, +}; diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js new file mode 100644 index 00000000..344e7bda --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -0,0 +1,222 @@ +import {sortArtworksChronologically} from '#sort'; +import {empty, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateArtTagGalleryPageFeaturedLine', + 'generateArtTagGalleryPageShowingLine', + 'generateArtTagNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'generateQuickDescription', + 'image', + 'linkAnythingMan', + 'linkArtTagGallery', + 'linkExternal', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableListings: wikiInfo.enableListings, + }; + }, + + query(sprawl, artTag) { + const directArtworks = artTag.directlyFeaturedInArtworks; + const indirectArtworks = artTag.indirectlyFeaturedInArtworks; + const allArtworks = unique([...directArtworks, ...indirectArtworks]); + + sortArtworksChronologically(allArtworks, {latestFirst: true}); + + return {directArtworks, indirectArtworks, allArtworks}; + }, + + relations(relation, query, sprawl, artTag) { + const relations = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.navLinks = + relation('generateArtTagNavLinks', artTag); + + relations.additionalNamesBox = + relation('generateAdditionalNamesBox', artTag.additionalNames); + + relations.quickDescription = + relation('generateQuickDescription', artTag); + + relations.featuredLine = + relation('generateArtTagGalleryPageFeaturedLine'); + + relations.showingLine = + relation('generateArtTagGalleryPageShowingLine'); + + if (!empty(artTag.extraReadingURLs)) { + relations.extraReadingLinks = + artTag.extraReadingURLs + .map(url => relation('linkExternal', url)); + } + + if (!empty(artTag.directAncestorArtTags)) { + relations.ancestorLinks = + artTag.directAncestorArtTags + .map(artTag => relation('linkArtTagGallery', artTag)); + } + + if (!empty(artTag.directDescendantArtTags)) { + relations.descendantLinks = + artTag.directDescendantArtTags + .map(artTag => relation('linkArtTagGallery', artTag)); + } + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.links = + query.allArtworks + .map(artwork => relation('linkAnythingMan', artwork.thing)); + + relations.images = + query.allArtworks + .map(artwork => relation('image', artwork)); + + return relations; + }, + + data(query, sprawl, artTag) { + const data = {}; + + data.enableListings = sprawl.enableListings; + + data.name = artTag.name; + data.color = artTag.color; + + data.numArtworksIndirectly = query.indirectArtworks.length; + data.numArtworksDirectly = query.directArtworks.length; + data.numArtworksTotal = query.allArtworks.length; + + data.names = + query.allArtworks + .map(artwork => artwork.thing.name); + + data.coverArtists = + query.allArtworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist.name)); + + data.onlyFeaturedIndirectly = + query.allArtworks.map(artwork => + !query.directArtworks.includes(artwork)); + + data.hasMixedDirectIndirect = + data.onlyFeaturedIndirectly.includes(true) && + data.onlyFeaturedIndirectly.includes(false); + + return data; + }, + + generate: (data, relations, {html, language}) => + language.encapsulate('artTagGalleryPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + tag: data.name, + }), + + headingMode: 'static', + color: data.color, + + additionalNames: relations.additionalNamesBox, + + mainClasses: ['top-index'], + mainContent: [ + relations.quickDescription.slots({ + extraReadingLinks: relations.extraReadingLinks ?? null, + }), + + data.numArtworksTotal === 0 && + html.tag('p', {class: 'quick-info'}, + language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [ + language.$(capsule), + html.tag('br'), + language.$(capsule, 'callToAction'), + ])), + + data.numArtworksTotal >= 1 && + relations.featuredLine.clone() + .slots({ + showing: 'all', + count: data.numArtworksTotal, + }), + + data.hasMixedDirectIndirect && [ + relations.featuredLine.clone() + .slots({ + showing: 'direct', + count: data.numArtworksDirectly, + }), + + relations.featuredLine.clone() + .slots({ + showing: 'indirect', + count: data.numArtworksIndirectly, + }), + ], + + relations.ancestorLinks && + html.tag('p', {id: 'descends-from-line'}, + {class: 'quick-info'}, + language.$(pageCapsule, 'descendsFrom', { + tags: language.formatUnitList(relations.ancestorLinks), + })), + + relations.descendantLinks && + html.tag('p', {id: 'descendants-line'}, + {class: 'quick-info'}, + language.$(pageCapsule, 'descendants', { + tags: language.formatUnitList(relations.descendantLinks), + })), + + data.hasMixedDirectIndirect && [ + relations.showingLine.clone() + .slot('showing', 'all'), + + relations.showingLine.clone() + .slot('showing', 'direct'), + + relations.showingLine.clone() + .slot('showing', 'indirect'), + ], + + relations.coverGrid + .slots({ + links: relations.links, + images: relations.images, + names: data.names, + lazy: 12, + + classes: + data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly => + (onlyFeaturedIndirectly ? 'featured-indirectly' : '')), + + info: + data.coverArtists.map(names => + (names === null + ? null + : language.$('misc.coverGrid.details.coverArtists', { + artists: language.formatUnitList(names), + }))), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: + html.resolve( + relations.navLinks + .slot('currentExtra', 'gallery')), + })), +}; diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js new file mode 100644 index 00000000..b4620fa4 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js @@ -0,0 +1,23 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + showing: { + validate: v => v.is('all', 'direct', 'indirect'), + }, + + count: {type: 'number'}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('artTagGalleryPage', pageCapsule => + html.tag('p', {class: 'quick-info'}, + {id: `featured-${slots.showing}-line`}, + + language.$(pageCapsule, 'featuredLine', slots.showing, { + coverArts: + language.countArtworks(slots.count, { + unit: true, + }), + }))), +}; diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js new file mode 100644 index 00000000..6df4d0e5 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js @@ -0,0 +1,22 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + showing: { + validate: v => v.is('all', 'direct', 'indirect'), + }, + + count: {type: 'number'}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('artTagGalleryPage', pageCapsule => + html.tag('p', {class: 'quick-info'}, + {id: `showing-${slots.showing}-line`}, + + language.$(pageCapsule, 'showingLine', { + showing: + html.tag('a', {href: '#'}, + language.$(pageCapsule, 'showingLine', slots.showing)), + }))), +}; diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js new file mode 100644 index 00000000..9df51b77 --- /dev/null +++ b/src/content/dependencies/generateArtTagInfoPage.js @@ -0,0 +1,281 @@ +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateArtTagNavLinks', + 'generateArtTagSidebar', + 'generateContentHeading', + 'generatePageLayout', + 'linkArtTagGallery', + 'linkArtTagInfo', + 'linkExternal', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableListings: wikiInfo.enableListings, + }), + + query(sprawl, artTag) { + const query = {}; + + query.directThings = + artTag.directlyFeaturedInArtworks; + + query.indirectThings = + artTag.indirectlyFeaturedInArtworks; + + query.allThings = + unique([...query.directThings, ...query.indirectThings]); + + query.allDescendantsHaveMoreDescendants = + artTag.directDescendantArtTags + .every(descendant => !empty(descendant.directDescendantArtTags)); + + return query; + }, + + relations: (relation, query, sprawl, artTag) => ({ + layout: + relation('generatePageLayout'), + + navLinks: + relation('generateArtTagNavLinks', artTag), + + sidebar: + relation('generateArtTagSidebar', artTag), + + additionalNamesBox: + relation('generateAdditionalNamesBox', artTag.additionalNames), + + contentHeading: + relation('generateContentHeading'), + + description: + relation('transformContent', artTag.description), + + galleryLink: + (empty(query.allThings) + ? null + : relation('linkArtTagGallery', artTag)), + + extraReadingLinks: + artTag.extraReadingURLs + .map(url => relation('linkExternal', url)), + + relatedArtTagLinks: + artTag.relatedArtTags + .map(({artTag}) => relation('linkArtTagInfo', artTag)), + + directAncestorLinks: + artTag.directAncestorArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + directDescendantInfoLinks: + artTag.directDescendantArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + directDescendantGalleryLinks: + artTag.directDescendantArtTags.map(artTag => + (query.allDescendantsHaveMoreDescendants + ? null + : relation('linkArtTagGallery', artTag))), + }), + + data: (query, sprawl, artTag) => ({ + enableListings: + sprawl.enableListings, + + name: + artTag.name, + + color: + artTag.color, + + numArtworksIndirectly: + query.indirectThings.length, + + numArtworksDirectly: + query.directThings.length, + + numArtworksTotal: + query.allThings.length, + + relatedArtTagAnnotations: + artTag.relatedArtTags + .map(({annotation}) => annotation), + + directDescendantTimesFeaturedTotal: + artTag.directDescendantArtTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artTagInfoPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + tag: language.sanitize(data.name), + }), + + headingMode: 'sticky', + color: data.color, + + additionalNames: relations.additionalNamesBox, + + mainContent: [ + html.tag('p', + language.encapsulate(pageCapsule, 'featuredIn', capsule => + (data.numArtworksTotal === 0 + ? language.$(capsule, 'notFeatured') + + : data.numArtworksDirectly === 0 + ? language.$(capsule, 'indirectlyOnly', { + artworks: + language.countArtworks(data.numArtworksIndirectly, {unit: true}), + }) + + : data.numArtworksIndirectly === 0 + ? language.$(capsule, 'directlyOnly', { + artworks: + language.countArtworks(data.numArtworksDirectly, {unit: true}), + }) + + : language.$(capsule, 'directlyAndIndirectly', { + artworksDirectly: + language.countArtworks(data.numArtworksDirectly, {unit: true}), + + artworksIndirectly: + language.countArtworks(data.numArtworksIndirectly, {unit: false}), + + artworksTotal: + language.countArtworks(data.numArtworksTotal, {unit: false}), + })))), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$(pageCapsule, 'viewArtGallery', { + [language.onlyIfOptions]: ['link'], + + link: + relations.galleryLink + ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.encapsulate(pageCapsule, 'seeAlso', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['tags'], + + tags: + language.formatUnitList( + stitchArrays({ + artTagLink: relations.relatedArtTagLinks, + annotation: data.relatedArtTagAnnotations, + }).map(({artTagLink, annotation}) => + (html.isBlank(annotation) + ? artTagLink + : language.$(capsule, 'tagWithAnnotation', { + tag: artTagLink, + annotation, + })))), + }))), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + + relations.description + .slot('mode', 'multiline')), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$(pageCapsule, 'readMoreOn', { + [language.onlyIfOptions]: ['links'], + + tag: language.sanitize(data.name), + links: language.formatDisjunctionList(relations.extraReadingLinks), + })), + + language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + title: + language.$(listCapsule, { + tag: language.sanitize(data.name), + }), + }), + + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.directAncestorLinks + .map(link => + html.tag('li', + language.$(listCapsule, 'item', { + tag: link, + })))), + ])), + + language.encapsulate(pageCapsule, 'descendantTags', listCapsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + title: + language.$(listCapsule, { + tag: language.sanitize(data.name), + }), + }), + + html.tag('ul', + {[html.onlyIfContent]: true}, + + stitchArrays({ + infoLink: relations.directDescendantInfoLinks, + galleryLink: relations.directDescendantGalleryLinks, + timesFeaturedTotal: data.directDescendantTimesFeaturedTotal, + }).map(({infoLink, galleryLink, timesFeaturedTotal}) => + html.tag('li', + language.encapsulate(listCapsule, 'item', itemCapsule => + language.encapsulate(itemCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.tag = infoLink; + + if (!html.isBlank(galleryLink ?? html.blank())) { + workingCapsule += '.withGallery'; + workingOptions.gallery = + galleryLink.slot('content', + language.$(itemCapsule, 'withGallery.gallery')); + } + + if (timesFeaturedTotal >= 1) { + workingCapsule += `.withTimesUsed`; + workingOptions.timesUsed = + language.countTimesFeatured(timesFeaturedTotal, { + unit: true, + }); + } + + return language.$(workingCapsule, workingOptions); + }))))), + ])), + ], + + navLinkStyle: 'hierarchical', + navLinks: relations.navLinks.content, + + leftSidebar: + relations.sidebar, + })), +}; diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js new file mode 100644 index 00000000..9061a09f --- /dev/null +++ b/src/content/dependencies/generateArtTagNavLinks.js @@ -0,0 +1,81 @@ +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'linkArtTagInfo', + 'linkArtTagGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => + ({enableListings: wikiInfo.enableListings}), + + relations: (relation, sprawl, tag) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + mainLink: + relation('linkArtTagInfo', tag), + + infoLink: + relation('linkArtTagInfo', tag), + + galleryLink: + relation('linkArtTagGallery', tag), + }), + + data: (sprawl) => + ({enableListings: sprawl.enableListings}), + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {language}) { + if (!data.enableListings) { + return [ + {auto: 'home'}, + {auto: 'current'}, + ]; + } + + const infoLink = + relations.infoLink.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const galleryLink = + relations.galleryLink.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }); + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + html: + language.$('artTagPage.nav.tag', { + tag: relations.mainLink, + }), + + accent: + relations.switcher.slots({ + links: [ + infoLink, + galleryLink, + ], + }), + }, + ].filter(Boolean); + }, +}; diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js new file mode 100644 index 00000000..9e2f813c --- /dev/null +++ b/src/content/dependencies/generateArtTagSidebar.js @@ -0,0 +1,124 @@ +import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageSidebar', + 'generatePageSidebarBox', + 'generateArtTagAncestorDescendantMapList', + 'linkArtTagDynamically', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({artTagData}) => + ({artTagData}), + + query(sprawl, artTag) { + const baobab = artTag.ancestorArtTagBaobabTree; + const uniqueLeaves = new Set(collectTreeLeaves(baobab)); + + // Just match the order in tag data. + const furthestAncestorArtTags = + sprawl.artTagData + .filter(artTag => uniqueLeaves.has(artTag)); + + return {furthestAncestorArtTags}; + }, + + relations: (relation, query, sprawl, artTag) => ({ + sidebar: + relation('generatePageSidebar'), + + sidebarBox: + relation('generatePageSidebarBox'), + + artTagLink: + relation('linkArtTagDynamically', artTag), + + directDescendantArtTagLinks: + artTag.directDescendantArtTags + .map(descendantArtTag => + relation('linkArtTagDynamically', descendantArtTag)), + + furthestAncestorArtTagMapLists: + query.furthestAncestorArtTags + .map(ancestorArtTag => + relation('generateArtTagAncestorDescendantMapList', + ancestorArtTag, + artTag)), + }), + + data: (query, sprawl, artTag) => ({ + name: artTag.name, + + directDescendantTimesFeaturedTotal: + artTag.directDescendantArtTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length), + + furthestAncestorArtTagNames: + query.furthestAncestorArtTags + .map(ancestorArtTag => ancestorArtTag.name), + }), + + generate(data, relations, {html, language}) { + if ( + empty(relations.directDescendantArtTagLinks) && + empty(relations.furthestAncestorArtTagMapLists) + ) { + return relations.sidebar; + } + + return relations.sidebar.slots({ + boxes: [ + relations.sidebarBox.slots({ + content: [ + html.tag('h1', + relations.artTagLink), + + !empty(relations.directDescendantArtTagLinks) && + html.tag('details', {class: 'current', open: true}, [ + html.tag('summary', + html.tag('span', + html.tag('b', + language.sanitize(data.name)))), + + html.tag('ul', + stitchArrays({ + link: relations.directDescendantArtTagLinks, + timesFeaturedTotal: data.directDescendantTimesFeaturedTotal, + }).map(({link, timesFeaturedTotal}) => + html.tag('li', [ + link, + html.tag('span', {class: 'times-used'}, + language.countTimesFeatured(timesFeaturedTotal)), + ]))), + ]), + + stitchArrays({ + name: data.furthestAncestorArtTagNames, + list: relations.furthestAncestorArtTagMapLists, + }).map(({name, list}) => + html.tag('details', + { + class: 'has-tree-list', + open: + empty(relations.directDescendantArtTagLinks) && + relations.furthestAncestorArtTagMapLists.length === 1, + }, + [ + html.tag('summary', + html.tag('span', + html.tag('b', + language.sanitize(name)))), + + list, + ])), + ], + }), + ], + }); + }, +}; diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js new file mode 100644 index 00000000..a4135489 --- /dev/null +++ b/src/content/dependencies/generateArtistArtworkColumn.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, artist) => ({ + coverArtwork: + (artist.hasAvatar + ? relation('generateCoverArtwork', artist.avatarArtwork) + : null), + }), + + generate: (relations) => + relations.coverArtwork, +}; diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js new file mode 100644 index 00000000..6bdbeb23 --- /dev/null +++ b/src/content/dependencies/generateArtistCredit.js @@ -0,0 +1,180 @@ +import {compareArrays, empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistCreditWikiEditsPart', + 'linkContribution', + ], + + extraDependencies: ['html', 'language'], + + query: (creditContributions, contextContributions) => { + const query = {}; + + const featuringFilter = contribution => + contribution.annotation === 'featuring'; + + const wikiEditFilter = contribution => + contribution.annotation?.startsWith('edits for wiki'); + + const normalFilter = contribution => + !featuringFilter(contribution) && + !wikiEditFilter(contribution); + + query.normalContributions = + creditContributions.filter(normalFilter); + + query.featuringContributions = + creditContributions.filter(featuringFilter); + + query.wikiEditContributions = + creditContributions.filter(wikiEditFilter); + + const contextNormalContributions = + contextContributions.filter(normalFilter); + + // Note that the normal contributions will implicitly *always* + // "differ from context" if no context contributions are given, + // as in release info lines. + query.normalContributionsDifferFromContext = + !compareArrays( + query.normalContributions.map(({artist}) => artist), + contextNormalContributions.map(({artist}) => artist), + {checkOrder: false}); + + return query; + }, + + relations: (relation, query, _creditContributions, _contextContributions) => ({ + normalContributionLinks: + query.normalContributions + .map(contrib => relation('linkContribution', contrib)), + + featuringContributionLinks: + query.featuringContributions + .map(contrib => relation('linkContribution', contrib)), + + wikiEditsPart: + relation('generateArtistCreditWikiEditsPart', + query.wikiEditContributions), + }), + + data: (query, _creditContributions, _contextContributions) => ({ + normalContributionsDifferFromContext: + query.normalContributionsDifferFromContext, + + hasWikiEdits: + !empty(query.wikiEditContributions), + }), + + slots: { + // This string is mandatory. + normalStringKey: {type: 'string'}, + + // This string is optional. + // Without it, there's no special behavior for "featuring" credits. + normalFeaturingStringKey: {type: 'string'}, + + // This string is optional. + // Without it, "featuring" credits will always be alongside main credits. + // It won't be used if contextContributions isn't provided. + featuringStringKey: {type: 'string'}, + + additionalStringOptions: {validate: v => v.isObject}, + + showAnnotation: {type: 'boolean', default: false}, + showExternalLinks: {type: 'boolean', default: false}, + showChronology: {type: 'boolean', default: false}, + showWikiEdits: {type: 'boolean', default: false}, + + trimAnnotation: {type: 'boolean', default: false}, + + chronologyKind: {type: 'string'}, + }, + + generate(data, relations, slots, {html, language}) { + if (!slots.normalStringKey) return html.blank(); + + for (const link of [ + ...relations.normalContributionLinks, + ...relations.featuringContributionLinks, + ]) { + link.setSlots({ + showExternalLinks: slots.showExternalLinks, + showChronology: slots.showChronology, + trimAnnotation: slots.trimAnnotation, + chronologyKind: slots.chronologyKind, + }); + } + + for (const link of relations.normalContributionLinks) { + link.setSlots({ + showAnnotation: slots.showAnnotation, + }); + } + + for (const link of relations.featuringContributionLinks) { + link.setSlots({ + showAnnotation: + (slots.featuringStringKey || slots.normalFeaturingStringKey + ? false + : slots.showAnnotation), + }); + } + + if (empty(relations.normalContributionLinks)) { + return html.blank(); + } + + const artistsList = + (data.hasWikiEdits && slots.showWikiEdits + ? language.$('misc.artistLink.withEditsForWiki', { + artists: + language.formatConjunctionList(relations.normalContributionLinks), + + edits: + relations.wikiEditsPart.slots({ + showAnnotation: slots.showAnnotation, + }), + }) + : language.formatConjunctionList(relations.normalContributionLinks)); + + const featuringList = + language.formatConjunctionList(relations.featuringContributionLinks); + + const everyoneList = + language.formatConjunctionList([ + ...relations.normalContributionLinks, + ...relations.featuringContributionLinks, + ]); + + if (empty(relations.featuringContributionLinks)) { + if (data.normalContributionsDifferFromContext) { + return language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + }); + } else { + return html.blank(); + } + } + + if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) { + return language.$(slots.normalFeaturingStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + featuring: featuringList, + }); + } else if (slots.featuringStringKey) { + return language.$(slots.featuringStringKey, { + ...slots.additionalStringOptions, + artists: featuringList, + }); + } else { + return language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: everyoneList, + }); + } + }, +}; diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js new file mode 100644 index 00000000..70296e39 --- /dev/null +++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js @@ -0,0 +1,55 @@ +export default { + contentDependencies: [ + 'generateTextWithTooltip', + 'generateTooltip', + 'linkContribution', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, contributions) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + + contributionLinks: + contributions + .map(contrib => relation('linkContribution', contrib)), + }), + + slots: { + showAnnotation: {type: 'boolean', default: true}, + }, + + generate: (relations, slots, {language}) => + language.encapsulate('misc.artistLink.withEditsForWiki', capsule => + relations.textWithTooltip.slots({ + attributes: + {class: 'wiki-edits'}, + + text: + language.$(capsule, 'edits'), + + tooltip: + relations.tooltip.slots({ + attributes: + {class: 'wiki-edits-tooltip'}, + + content: + language.$(capsule, 'editsLine', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatConjunctionList( + relations.contributionLinks.map(link => + link.slots({ + showAnnotation: slots.showAnnotation, + trimAnnotation: true, + preventTooltip: true, + }))), + }), + }), + })), +}; diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js new file mode 100644 index 00000000..6a24275e --- /dev/null +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -0,0 +1,108 @@ +import {sortArtworksChronologically} from '#sort'; + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAnythingMan', + ], + + extraDependencies: ['html', 'language'], + + query: (artist) => ({ + artworks: + sortArtworksChronologically( + ([ + artist.albumCoverArtistContributions, + artist.trackCoverArtistContributions, + ]).flat() + .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`)) + .map(contrib => contrib.thing), + {latestFirst: true}), + }), + + relations: (relation, query, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + coverGrid: + relation('generateCoverGrid'), + + links: + query.artworks + .map(artwork => relation('linkAnythingMan', artwork.thing)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, artist) => ({ + name: + artist.name, + + numArtworks: + query.artworks.length, + + names: + query.artworks + .map(artwork => artwork.thing.name), + + otherCoverArtists: + query.artworks + .map(artwork => artwork.artistContribs + .filter(contrib => contrib.artist !== artist) + .map(contrib => contrib.artist.name)), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistGalleryPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + artist: data.name, + }), + + headingMode: 'static', + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {class: 'quick-info'}, + language.$(pageCapsule, 'infoLine', { + coverArts: + language.countArtworks(data.numArtworks, { + unit: true, + }), + })), + + relations.coverGrid + .slots({ + links: relations.links, + images: relations.images, + names: data.names, + + info: + data.otherCoverArtists.map(names => + language.$('misc.coverGrid.details.otherCoverArtists', { + [language.onlyIfOptions]: ['artists'], + + 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 00000000..3e0cd1d2 --- /dev/null +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -0,0 +1,234 @@ +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.mainReleaseTrack === 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}) => + language.encapsulate('artistPage.groupContributions', capsule => { + 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', + ]; + + // TODO: It feels pretty awkward that this component is the only one that + // has enough knowledge to decide if the sort button is even applicable... + const switchingSortPossible = + !empty(relations.groupLinksSortedByCount) && + !empty(relations.groupLinksSortedByDuration); + + return html.tags([ + html.tag('dt', {class: topLevelClasses}, + language.encapsulate(capsule, 'title', capsule => + (switchingSortPossible && slots.showSortButton + ? language.$(capsule, 'withSortButton', { + title: slots.title, + sort: + html.tag('a', {class: 'group-contributions-sort-button'}, + {href: '#'}, + + (slots.sort === 'count' + ? language.$(capsule, 'sorting.count') + : language.$(capsule, '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}) => + language.encapsulate(capsule, 'item', capsule => + 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.$(capsule, 'countDurationAccent', {count, duration}) + : language.$(capsule, 'countAccent', {count}))), + ])))) + + : stitchArrays({ + group: relations.groupLinksSortedByDuration, + count: getCounts(data.groupCountsSortedByDuration), + duration: + getDurations( + data.groupDurationsSortedByDuration, + data.groupDurationsApproximateSortedByDuration), + }).map(({group, count, duration}) => + language.encapsulate(capsule, 'item', capsule => + 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.$(capsule, 'durationCountAccent', {duration, count}) + : language.$(capsule, 'durationAccent', {duration}))), + ]))))))), + ]); + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js new file mode 100644 index 00000000..3a3cf8b7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,401 @@ +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistArtworkColumn', + 'generateArtistGroupContributionsInfo', + 'generateArtistInfoPageArtworksChunkedList', + 'generateArtistInfoPageCommentaryChunkedList', + 'generateArtistInfoPageFlashesChunkedList', + 'generateArtistInfoPageTracksChunkedList', + 'generateArtistNavLinks', + 'generateContentHeading', + 'generatePageLayout', + 'linkArtistGallery', + 'linkExternal', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query: (artist) => ({ + // Even if an artist has served as both "artist" (compositional) and + // "contributor" (instruments, production, etc) on the same track, that + // track only counts as one unique contribution in the list. + allTracks: + unique( + ([ + artist.trackArtistContributions, + artist.trackContributorContributions, + ]).flat() + .map(({thing}) => thing)), + + // Artworks are different, though. We intentionally duplicate album data + // objects when the artist has contributed some combination of cover art, + // wallpaper, and banner - these each count as a unique contribution. + allArtworkThings: + ([ + artist.albumCoverArtistContributions, + artist.albumWallpaperArtistContributions, + artist.albumBannerArtistContributions, + artist.trackCoverArtistContributions, + ]).flat() + .filter(({annotation}) => !annotation?.startsWith('edits for wiki')) + .map(({thing}) => thing.thing), + + // Banners and wallpapers don't show up in the artist gallery page, only + // cover art. + hasGallery: + !empty(artist.albumCoverArtistContributions) || + !empty(artist.trackCoverArtistContributions), + + aliasLinkedGroups: + artist.closelyLinkedGroups + .filter(({annotation}) => + annotation === 'alias'), + + generalLinkedGroups: + artist.closelyLinkedGroups + .filter(({annotation}) => + annotation !== 'alias'), + }), + + relations: (relation, query, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + artworkColumn: + relation('generateArtistArtworkColumn', artist), + + contentHeading: + relation('generateContentHeading'), + + contextNotes: + relation('transformContent', artist.contextNotes), + + closeGroupLinks: + query.generalLinkedGroups + .map(({group}) => relation('linkGroup', group)), + + aliasGroupLinks: + query.aliasLinkedGroups + .map(({group}) => relation('linkGroup', group)), + + visitLinks: + artist.urls + .map(url => relation('linkExternal', url)), + + tracksChunkedList: + relation('generateArtistInfoPageTracksChunkedList', artist), + + tracksGroupInfo: + relation('generateArtistGroupContributionsInfo', query.allTracks), + + artworksChunkedList: + relation('generateArtistInfoPageArtworksChunkedList', artist, false), + + editsForWikiArtworksChunkedList: + relation('generateArtistInfoPageArtworksChunkedList', artist, true), + + artworksGroupInfo: + relation('generateArtistGroupContributionsInfo', query.allArtworkThings), + + artistGalleryLink: + (query.hasGallery + ? relation('linkArtistGallery', artist) + : null), + + flashesChunkedList: + relation('generateArtistInfoPageFlashesChunkedList', artist), + + commentaryChunkedList: + relation('generateArtistInfoPageCommentaryChunkedList', artist, false), + + wikiEditorCommentaryChunkedList: + relation('generateArtistInfoPageCommentaryChunkedList', artist, true), + }), + + data: (query, artist) => ({ + name: + artist.name, + + closeGroupAnnotations: + query.generalLinkedGroups + .map(({annotation}) => annotation), + + totalTrackCount: + query.allTracks.length, + + totalDuration: + artist.totalDuration, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistPage', pageCapsule => + relations.layout.slots({ + title: data.name, + headingMode: 'sticky', + + artworkColumnContent: + relations.artworkColumn, + + mainContent: [ + html.tags([ + html.tag('p', + {[html.onlyIfSiblings]: true}, + language.$('releaseInfo.note')), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + relations.contextNotes), + ]), + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate(pageCapsule, 'closelyLinkedGroups', capsule => [ + language.encapsulate(capsule, capsule => { + const [workingCapsule, option] = + (relations.closeGroupLinks.length === 0 + ? [null, null] + : relations.closeGroupLinks.length === 1 + ? [language.encapsulate(capsule, 'one'), 'group'] + : [language.encapsulate(capsule, 'multiple'), 'groups']); + + if (!workingCapsule) return html.blank(); + + return language.$(workingCapsule, { + [option]: + language.formatUnitList( + stitchArrays({ + link: relations.closeGroupLinks, + annotation: data.closeGroupAnnotations, + }).map(({link, annotation}) => + language.encapsulate(capsule, 'group', workingCapsule => { + const workingOptions = {group: link}; + + if (annotation) { + workingCapsule += '.withAnnotation'; + workingOptions.annotation = annotation; + } + + return language.$(workingCapsule, workingOptions); + }))), + }); + }), + + language.$(capsule, 'alias', { + [language.onlyIfOptions]: ['groups'], + + groups: + language.formatConjunctionList(relations.aliasGroupLinks), + }), + ])), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.visitOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + relations.visitLinks + .map(link => link.slot('context', 'artist'))), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.encapsulate(pageCapsule, 'viewArtGallery', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['link'], + + link: + relations.artistGalleryLink?.slots({ + content: + language.$(capsule, 'link'), + }), + }))), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('misc.jumpTo.withLinks', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatUnitList([ + !html.isBlank(relations.tracksChunkedList) && + html.tag('a', + {href: '#tracks'}, + language.$(pageCapsule, 'trackList.title')), + + (!html.isBlank(relations.artworksChunkedList) || + !html.isBlank(relations.editsForWikiArtworksChunkedList)) && + html.tag('a', + {href: '#art'}, + language.$(pageCapsule, 'artList.title')), + + !html.isBlank(relations.flashesChunkedList) && + html.tag('a', + {href: '#flashes'}, + language.$(pageCapsule, 'flashList.title')), + + (!html.isBlank(relations.commentaryChunkedList) || + !html.isBlank(relations.wikiEditorCommentaryChunkedList)) && + html.tag('a', + {href: '#commentary'}, + language.$(pageCapsule, 'commentaryList.title')), + ].filter(Boolean)), + })), + + html.tags([ + relations.contentHeading.clone() + .slots({ + tag: 'h2', + attributes: {id: 'tracks'}, + title: language.$(pageCapsule, 'trackList.title'), + }), + + data.totalDuration > 0 && + html.tag('p', + {[html.onlyIfSiblings]: true}, + + language.$(pageCapsule, 'contributedDurationLine', { + artist: data.name, + duration: + language.formatDuration(data.totalDuration, { + approximate: data.totalTrackCount > 1, + unit: true, + }), + })), + + relations.tracksChunkedList.slots({ + groupInfo: + language.encapsulate(pageCapsule, 'groupContributions', capsule => [ + relations.tracksGroupInfo.clone() + .slots({ + title: language.$(capsule, 'title.music'), + showSortButton: true, + sort: 'count', + countUnit: 'tracks', + visible: true, + }), + + relations.tracksGroupInfo.clone() + .slots({ + title: language.$(capsule, 'title.music'), + showSortButton: true, + sort: 'duration', + countUnit: 'tracks', + visible: false, + }), + ]), + }), + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + tag: 'h2', + attributes: {id: 'art'}, + title: language.$(pageCapsule, 'artList.title'), + }), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.encapsulate(pageCapsule, 'viewArtGallery', capsule => + language.$(capsule, 'orBrowseList', { + [language.onlyIfOptions]: ['link'], + + link: + relations.artistGalleryLink?.slots({ + content: language.$(capsule, 'link'), + }), + }))), + + relations.artworksChunkedList + .slots({ + groupInfo: + language.encapsulate(pageCapsule, 'groupContributions', capsule => + relations.artworksGroupInfo + .slots({ + title: language.$(capsule, 'title.artworks'), + showBothColumns: false, + sort: 'count', + countUnit: 'artworks', + })), + }), + + html.tags([ + language.encapsulate(pageCapsule, 'wikiEditArtworks', capsule => + relations.contentHeading.clone() + .slots({ + tag: 'p', + + title: + language.$(capsule, {artist: data.name}), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.editsForWikiArtworksChunkedList, + ]), + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + tag: 'h2', + attributes: {id: 'flashes'}, + title: language.$(pageCapsule, 'flashList.title'), + }), + + relations.flashesChunkedList, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + tag: 'h2', + attributes: {id: 'commentary'}, + title: language.$(pageCapsule, 'commentaryList.title'), + }), + + relations.commentaryChunkedList, + + html.tags([ + language.encapsulate(pageCapsule, 'wikiEditorCommentary', capsule => + relations.contentHeading.clone() + .slots({ + tag: 'p', + + title: + language.$(capsule, {artist: data.name}), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.wikiEditorCommentaryChunkedList, + ]), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + }) + .content, + })), +}; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js new file mode 100644 index 00000000..66e4204a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js @@ -0,0 +1,50 @@ +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageArtworksChunkItem', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + relations: (relation, album, contribs) => ({ + template: + relation('generateArtistInfoPageChunk'), + + albumLink: + relation('linkAlbum', album), + + items: + contribs + .map(contrib => + relation('generateArtistInfoPageArtworksChunkItem', contrib)), + }), + + data: (_album, contribs) => ({ + dates: + contribs + .map(contrib => contrib.date), + }), + + slots: { + filterEditsForWiki: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots) => + relations.template.slots({ + mode: 'album', + albumLink: relations.albumLink, + + dates: + (slots.filterEditsForWiki + ? Array.from({length: data.dates}, () => null) + : data.dates), + + items: + relations.items.map(item => + item.slot('filterEditsForWiki', slots.filterEditsForWiki)), + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js new file mode 100644 index 00000000..2f2fe0c5 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js @@ -0,0 +1,72 @@ +export default { + contentDependencies: [ + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageOtherArtistLinks', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query: (contrib) => ({ + kind: + (contrib.isBannerArtistContribution + ? 'banner' + : contrib.isWallpaperArtistContribution + ? 'wallpaper' + : contrib.isForAlbum + ? 'album-cover' + : 'track-cover'), + }), + + relations: (relation, query, contrib) => ({ + template: + relation('generateArtistInfoPageChunkItem'), + + trackLink: + (query.kind === 'track-cover' + ? relation('linkTrack', contrib.thing.thing) + : null), + + otherArtistLinks: + relation('generateArtistInfoPageOtherArtistLinks', [contrib]), + }), + + data: (query, contrib) => ({ + kind: + query.kind, + + annotation: + contrib.annotation, + }), + + slots: { + filterEditsForWiki: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {html, language}) => + relations.template.slots({ + otherArtistLinks: relations.otherArtistLinks, + + annotation: + (slots.filterEditsForWiki + ? data.annotation?.replace(/^edits for wiki(: )?/, '') + : data.annotation), + + content: + language.encapsulate('artistPage.creditList.entry', capsule => + (data.kind === 'track-cover' + ? language.$(capsule, 'track', { + track: relations.trackLink, + }) + : html.tag('i', + language.encapsulate(capsule, 'album', capsule => + (data.kind === 'wallpaper' + ? language.$(capsule, 'wallpaperArt') + : data.kind === 'banner' + ? language.$(capsule, 'bannerArt') + : language.$(capsule, 'coverArt')))))), + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js new file mode 100644 index 00000000..75a4aa5a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -0,0 +1,72 @@ +import {sortAlbumsTracksChronologically, sortContributionsChronologically} + from '#sort'; +import {chunkByConditions, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageArtworksChunk', + ], + + query(artist, filterEditsForWiki) { + const query = {}; + + const allContributions = [ + ...artist.albumCoverArtistContributions, + ...artist.albumWallpaperArtistContributions, + ...artist.albumBannerArtistContributions, + ...artist.trackCoverArtistContributions, + ]; + + const filteredContributions = + allContributions + .filter(({annotation}) => + (filterEditsForWiki + ? annotation?.startsWith(`edits for wiki`) + : !annotation?.startsWith(`edits for wiki`))); + + sortContributionsChronologically( + filteredContributions, + sortAlbumsTracksChronologically, + {getThing: contrib => contrib.thing.thing}); + + query.contribs = + chunkByConditions(filteredContributions, [ + ({date: date1}, {date: date2}) => + +date1 !== +date2, + ({thing: {thing: thing1}}, {thing: {thing: thing2}}) => + (thing1.album ?? thing1) !== + (thing2.album ?? thing2), + ]); + + query.albums = + query.contribs + .map(contribs => contribs[0].thing.thing) + .map(thing => thing.album ?? thing); + + return query; + }, + + relations: (relation, query, _artist, _filterEditsForWiki) => ({ + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + stitchArrays({ + album: query.albums, + contribs: query.contribs, + }).map(({album, contribs}) => + relation('generateArtistInfoPageArtworksChunk', album, contribs)), + }), + + data: (_query, _artist, filterEditsForWiki) => ({ + filterEditsForWiki, + }), + + generate: (data, relations) => + relations.chunkedList.slots({ + chunks: + relations.chunks.map(chunk => + chunk.slot('filterEditsForWiki', data.filterEditsForWiki)), + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js new file mode 100644 index 00000000..fce68a7d --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -0,0 +1,114 @@ +import {empty} from '#sugar'; + +export default { + extraDependencies: ['html', 'language'], + + slots: { + mode: { + validate: v => v.is('flash', 'album'), + }, + + id: {type: 'string'}, + + albumLink: { + type: 'html', + mutable: false, + }, + + flashActLink: { + type: 'html', + mutable: false, + }, + + items: { + type: 'html', + mutable: false, + }, + + dates: { + validate: v => v.sparseArrayOf(v.isDate), + }, + + duration: {validate: v => v.isDuration}, + durationApproximate: {type: 'boolean'}, + }, + + generate(slots, {html, language}) { + let earliestDate = null; + let latestDate = null; + let onlyDate = null; + + if (!empty(slots.dates)) { + earliestDate = + slots.dates + .reduce((a, b) => a <= b ? a : b); + + latestDate = + slots.dates + .reduce((a, b) => a <= b ? b : a); + + if (+earliestDate === +latestDate) { + onlyDate = earliestDate; + } + } + + let accentedLink; + + accent: { + switch (slots.mode) { + case 'album': { + accentedLink = slots.albumLink; + + const options = {album: accentedLink}; + const parts = ['artistPage.creditList.album']; + + if (onlyDate) { + parts.push('withDate'); + options.date = language.formatDate(onlyDate); + } + + 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 (onlyDate) { + parts.push('withDate'); + options.date = language.formatDate(onlyDate); + } else if (earliestDate && latestDate) { + parts.push('withDateRange'); + options.dateRange = + language.formatDateRange(earliestDate, latestDate); + } + + accentedLink = language.formatString(...parts, options); + break; + } + } + } + + return html.tags([ + html.tag('dt', + slots.id && {id: slots.id}, + accentedLink), + + html.tag('dd', + html.tag('ul', + {class: 'offset-tooltips'}, + slots.items)), + ]); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js new file mode 100644 index 00000000..7987b642 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -0,0 +1,91 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['generateTextWithTooltip'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + }), + + slots: { + content: { + type: 'html', + mutable: false, + }, + + annotation: { + type: 'html', + mutable: false, + }, + + otherArtistLinks: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + rereleaseTooltip: { + type: 'html', + mutable: false, + }, + + firstReleaseTooltip: { + type: 'html', + mutable: false, + }, + }, + + generate: (relations, slots, {html, language}) => + language.encapsulate('artistPage.creditList.entry', entryCapsule => + html.tag('li', + slots.rerelease && {class: 'rerelease'}, + + language.encapsulate(entryCapsule, workingCapsule => { + const workingOptions = {entry: slots.content}; + + if (!html.isBlank(slots.rereleaseTooltip)) { + workingCapsule += '.rerelease'; + workingOptions.rerelease = + relations.textWithTooltip.slots({ + attributes: {class: 'rerelease'}, + text: language.$(entryCapsule, 'rerelease.term'), + tooltip: slots.rereleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + if (!html.isBlank(slots.firstReleaseTooltip)) { + workingCapsule += '.firstRelease'; + workingOptions.firstRelease = + relations.textWithTooltip.slots({ + attributes: {class: 'first-release'}, + text: language.$(entryCapsule, 'firstRelease.term'), + tooltip: slots.firstReleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + let anyAccent = false; + + if (!empty(slots.otherArtistLinks)) { + anyAccent = true; + workingCapsule += '.withArtists'; + workingOptions.artists = + language.formatConjunctionList(slots.otherArtistLinks); + } + + if (!html.isBlank(slots.annotation)) { + anyAccent = true; + workingCapsule += '.withAnnotation'; + workingOptions.annotation = slots.annotation; + } + + if (anyAccent) { + return language.$(workingCapsule, workingOptions); + } else { + return slots.content; + } + }))), +}; diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js new file mode 100644 index 00000000..e7915ab7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -0,0 +1,20 @@ +export default { + extraDependencies: ['html'], + + slots: { + groupInfo: { + type: 'html', + mutable: false, + }, + + chunks: { + type: 'html', + mutable: false, + }, + }, + + generate: (slots, {html}) => + html.tag('dl', + {[html.onlyIfContent]: true}, + [slots.groupInfo, slots.chunks]), +}; diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js new file mode 100644 index 00000000..d0c5e14e --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -0,0 +1,280 @@ +import {chunkByProperties, stitchArrays} from '#sugar'; + +import { + sortAlbumsTracksChronologically, + sortByDate, + sortEntryThingPairs, +} from '#sort'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageChunkItem', + 'linkAlbum', + 'linkFlash', + 'linkFlashAct', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query(artist, filterWikiEditorCommentary) { + 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)) + + .filter(({annotation}) => + (filterWikiEditorCommentary + ? annotation?.match(/^wiki editor/i) + : !annotation?.match(/^wiki editor/i))) + + .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, _artist, filterWikiEditorCommentary) => ({ + 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}) => + relation('transformContent', + (filterWikiEditorCommentary + ? annotation?.replace(/^wiki editor(, )?/i, '') + : annotation)))), + }), + + data: (query, _artist, _filterWikiEditorCommentary) => ({ + chunkTypes: + query.chunks + .map(({chunkType}) => chunkType), + + itemTypes: + query.chunks + .map(({chunk}) => chunk + .map(({itemType}) => itemType)), + }), + + generate: (data, relations, {html, language}) => + html.tag('dl', + {[html.onlyIfContent]: true}, + + 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, + }) => + language.encapsulate('artistPage.creditList.entry', capsule => + (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.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }), + + content: + (type === 'album' + ? html.tag('i', + language.$(capsule, 'album.commentary')) + : language.$(capsule, '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.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }) + : null), + + content: + language.$(capsule, 'flash', { + flash: link, + }), + })), + }) + : null)))), +}; diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js new file mode 100644 index 00000000..f86dead7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js @@ -0,0 +1,75 @@ +import {sortChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateTooltip', + 'linkOtherReleaseOnArtistInfoPage', + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + rereleases: + sortChronologically(track.allReleases).slice(1), + }), + + relations: (relation, query, track, artist) => ({ + tooltip: + relation('generateTooltip'), + + firstReleaseColorStyle: + relation('generateColorStyleAttribute', track.color), + + rereleaseLinks: + query.rereleases + .map(rerelease => + relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)), + }), + + data: (query, track) => ({ + firstReleaseDate: + track.dateFirstReleased ?? + track.album.date, + + rereleaseDates: + query.rereleases + .map(rerelease => + rerelease.dateFirstReleased ?? + rerelease.album.date), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistPage.creditList.entry.firstRelease', capsule => + relations.tooltip.slots({ + attributes: [ + {class: 'first-release-tooltip'}, + relations.firstReleaseColorStyle, + ], + + contentAttributes: [ + {[html.joinChildren]: html.tag('hr', {class: 'cute'})}, + ], + + content: + stitchArrays({ + rereleaseLink: relations.rereleaseLinks, + rereleaseDate: data.rereleaseDates, + }).map(({rereleaseLink, rereleaseDate}) => + html.tags([ + language.$(capsule, 'rerelease', { + album: + html.metatag('blockwrap', rereleaseLink), + }), + + html.tag('br'), + + language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, { + considerRoundingDays: true, + approximate: true, + absolute: true, + }), + ])), + })), +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js new file mode 100644 index 00000000..8aa7223a --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js @@ -0,0 +1,34 @@ +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageFlashesChunkItem', + 'linkFlashAct', + ], + + relations: (relation, flashAct, contribs) => ({ + template: + relation('generateArtistInfoPageChunk'), + + flashActLink: + relation('linkFlashAct', flashAct), + + items: + contribs + .map(contrib => + relation('generateArtistInfoPageFlashesChunkItem', contrib)), + }), + + data: (_flashAct, contribs) => ({ + dates: + contribs + .map(contrib => contrib.date), + }), + + generate: (data, relations) => + relations.template.slots({ + mode: 'flash', + flashActLink: relations.flashActLink, + dates: data.dates, + items: relations.items, + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js new file mode 100644 index 00000000..e4908bf9 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js @@ -0,0 +1,34 @@ +export default { + contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'], + + extraDependencies: ['language'], + + relations: (relation, contrib) => ({ + // Flashes and games can list multiple contributors as collaborative + // credits, but we don't display these on the artist page, since they + // 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). + + template: + relation('generateArtistInfoPageChunkItem'), + + flashLink: + relation('linkFlash', contrib.thing), + }), + + data: (contrib) => ({ + annotation: + contrib.annotation, + }), + + generate: (data, relations, {language}) => + relations.template.slots({ + annotation: data.annotation, + + content: + language.$('artistPage.creditList.entry.flash', { + flash: relations.flashLink, + }), + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js new file mode 100644 index 00000000..b347faf5 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -0,0 +1,62 @@ +import {sortContributionsChronologically, sortFlashesChronologically} + from '#sort'; +import {chunkByConditions, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageFlashesChunk', + ], + + extraDependencies: ['wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableFlashesAndGames: + wikiInfo.enableFlashesAndGames, + }), + + query(sprawl, artist) { + const query = {}; + + const allContributions = + (sprawl.enableFlashesAndGames + ? [ + ...artist.flashContributorContributions, + ] + : []); + + sortContributionsChronologically( + allContributions, + sortFlashesChronologically); + + query.contribs = + chunkByConditions(allContributions, [ + ({thing: flash1}, {thing: flash2}) => + flash1.act !== flash2.act, + ]); + + query.flashActs = + query.contribs + .map(contribs => contribs[0].thing) + .map(thing => thing.act); + + return query; + }, + + relations: (relation, query, _sprawl, _artist) => ({ + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + stitchArrays({ + flashAct: query.flashActs, + contribs: query.contribs, + }).map(({flashAct, contribs}) => + relation('generateArtistInfoPageFlashesChunk', flashAct, contribs)), + }), + + generate: (relations) => + relations.chunkedList.slots({ + chunks: relations.chunks, + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js new file mode 100644 index 00000000..dcee9c00 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -0,0 +1,30 @@ +import {unique} from '#sugar'; + +export default { + contentDependencies: ['linkArtist'], + + query(contribs) { + const associatedContributionsByOtherArtists = + contribs + .flatMap(ownContrib => + ownContrib.associatedContributions + .filter(associatedContrib => + associatedContrib.artist !== ownContrib.artist)); + + const otherArtists = + unique( + associatedContributionsByOtherArtists + .map(contrib => contrib.artist)); + + return {otherArtists}; + }, + + relations: (relation, query) => ({ + artistLinks: + query.otherArtists + .map(artist => relation('linkArtist', artist)), + }), + + generate: (relations) => + relations.artistLinks, +}; diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js new file mode 100644 index 00000000..1d849919 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js @@ -0,0 +1,61 @@ +import {sortChronologically} from '#sort'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateTooltip', + 'linkOtherReleaseOnArtistInfoPage' + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + firstRelease: + sortChronologically(track.allReleases)[0], + }), + + relations: (relation, query, track, artist) => ({ + tooltip: + relation('generateTooltip'), + + rereleaseColorStyle: + relation('generateColorStyleAttribute', track.color), + + firstReleaseLink: + relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist), + }), + + data: (query, track) => ({ + rereleaseDate: + track.dateFirstReleased ?? + track.album.date, + + firstReleaseDate: + query.firstRelease.dateFirstReleased ?? + query.firstRelease.album.date, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistPage.creditList.entry.rerelease', capsule => + relations.tooltip.slots({ + attributes: [ + {class: 'rerelease-tooltip'}, + relations.rereleaseColorStyle, + ], + + content: [ + language.$(capsule, 'firstRelease', { + album: + html.metatag('blockwrap', relations.firstReleaseLink), + }), + + html.tag('br'), + + language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, { + considerRoundingDays: true, + approximate: true, + absolute: true, + }), + ], + })), +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js new file mode 100644 index 00000000..f6d70901 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js @@ -0,0 +1,67 @@ +import {unique} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunk', + 'generateArtistInfoPageTracksChunkItem', + 'linkAlbum', + ], + + relations: (relation, artist, album, trackContribLists) => ({ + template: + relation('generateArtistInfoPageChunk'), + + albumLink: + relation('linkAlbum', album), + + // Intentional mapping here: each item may be associated with + // more than one contribution. + items: + trackContribLists.map(trackContribs => + relation('generateArtistInfoPageTracksChunkItem', + artist, + trackContribs)), + }), + + data(_artist, album, trackContribLists) { + const data = {}; + + const contribs = + trackContribLists.flat(); + + data.dates = + contribs + .map(contrib => contrib.date); + + // TODO: Duration stuff should *maybe* be in proper data logic? Maaaybe? + const durationTerms = + unique( + contribs + .filter(contrib => contrib.countInDurationTotals) + .map(contrib => contrib.thing) + .filter(track => track.isMainRelease) + .filter(track => track.duration > 0)); + + data.duration = + getTotalDuration(durationTerms); + + data.durationApproximate = + durationTerms.length > 1; + + return data; + }, + + generate: (data, relations) => + relations.template.slots({ + mode: 'album', + + albumLink: relations.albumLink, + + dates: data.dates, + duration: data.duration, + durationApproximate: data.durationApproximate, + + items: relations.items, + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js new file mode 100644 index 00000000..a42d6fee --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js @@ -0,0 +1,146 @@ +import {sortChronologically} from '#sort'; +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageFirstReleaseTooltip', + 'generateArtistInfoPageOtherArtistLinks', + 'generateArtistInfoPageRereleaseTooltip', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query (_artist, contribs) { + const query = {}; + + // TODO: Very mysterious what to do if the set of contributions is, + // in total, associated with more than one thing. No design yet. + query.track = + contribs[0].thing; + + const creditedAsArtist = + contribs + .some(contrib => contrib.isArtistContribution); + + const creditedAsContributor = + contribs + .some(contrib => contrib.isContributorContribution); + + const annotatedContribs = + contribs + .filter(contrib => contrib.annotation); + + const annotatedArtistContribs = + annotatedContribs + .filter(contrib => contrib.isArtistContribution); + + const annotatedContributorContribs = + annotatedContribs + .filter(contrib => contrib.isContributorContribution); + + // 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) + ) { + query.displayedContributions = null; + } else if ( + !empty(annotatedArtistContribs) || + !empty(annotatedContributorContribs) + ) { + query.displayedContributions = [ + ...annotatedArtistContribs, + ...annotatedContributorContribs, + ]; + } + + // It's kinda awkward to perform this chronological sort here, + // per track, rather than just reusing the one that's done to + // sort all the items on the page altogether... but then, the + // sort for the page is actually *a different* sort, on purpsoe. + // That sort is according to the dates of the contributions; + // this is according to the dates of the tracks. Those can be + // different - and it's the latter that determines whether the + // track is a rerelease! + const allReleasesChronologically = + sortChronologically(query.track.allReleases); + + query.isFirstRelease = + allReleasesChronologically[0] === query.track; + + query.isRerelease = + allReleasesChronologically[0] !== query.track; + + query.hasOtherReleases = + !empty(query.track.otherReleases); + + return query; + }, + + relations: (relation, query, artist, contribs) => ({ + template: + relation('generateArtistInfoPageChunkItem'), + + trackLink: + relation('linkTrack', query.track), + + otherArtistLinks: + relation('generateArtistInfoPageOtherArtistLinks', contribs), + + rereleaseTooltip: + (query.isRerelease + ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist) + : null), + + firstReleaseTooltip: + (query.isFirstRelease && query.hasOtherReleases + ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist) + : null), + }), + + data: (query) => ({ + duration: + query.track.duration, + + contribAnnotations: + (query.displayedContributions + ? query.displayedContributions + .map(contrib => contrib.annotation) + : null), + }), + + generate: (data, relations, {html, language}) => + relations.template.slots({ + otherArtistLinks: relations.otherArtistLinks, + rereleaseTooltip: relations.rereleaseTooltip, + firstReleaseTooltip: relations.firstReleaseTooltip, + + annotation: + (data.contribAnnotations + ? language.formatUnitList(data.contribAnnotations) + : html.blank()), + + content: + language.encapsulate('artistPage.creditList.entry.track', workingCapsule => { + const workingOptions = {track: relations.trackLink}; + + if (data.duration) { + workingCapsule += '.withDuration'; + workingOptions.duration = + language.formatDuration(data.duration); + } + + return language.$(workingCapsule, workingOptions); + }), + }), +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js new file mode 100644 index 00000000..84eb29ac --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -0,0 +1,81 @@ +import {sortAlbumsTracksChronologically, sortContributionsChronologically} + from '#sort'; +import {stitchArrays} from '#sugar'; +import {chunkArtistTrackContributions} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateArtistInfoPageChunkedList', + 'generateArtistInfoPageTracksChunk', + ], + + query(artist) { + const query = {}; + + const allContributions = [ + ...artist.trackArtistContributions, + ...artist.trackContributorContributions, + ]; + + sortContributionsChronologically( + allContributions, + sortAlbumsTracksChronologically); + + query.contribs = + chunkArtistTrackContributions(allContributions); + + query.albums = + query.contribs + .map(contribs => + contribs[0][0].thing.album); + + return query; + }, + + relations: (relation, query, artist) => ({ + chunkedList: + relation('generateArtistInfoPageChunkedList'), + + chunks: + stitchArrays({ + album: query.albums, + contribs: query.contribs, + }).map(({album, contribs}) => + relation('generateArtistInfoPageTracksChunk', + artist, + album, + contribs)), + }), + + data: (query, _artist) => ({ + albumDirectories: + query.albums + .map(album => album.directory), + + albumChunkIndices: + query.albums + .reduce(([indices, map], album) => { + if (map.has(album)) { + const n = map.get(album); + indices.push(n); + map.set(album, n + 1); + } else { + indices.push(0); + map.set(album, 1); + } + return [indices, map]; + }, [[], new Map()]) + [0], + }), + + generate: (data, relations) => + relations.chunkedList.slots({ + chunks: + stitchArrays({ + chunk: relations.chunks, + albumDirectory: data.albumDirectories, + albumChunkIndex: data.albumChunkIndices, + }).map(({chunk, albumDirectory, albumChunkIndex}) => + chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)), + }), +}; diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js new file mode 100644 index 00000000..1b4b6eca --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,94 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'linkArtist', + 'linkArtistGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableListings: + wikiInfo.enableListings, + }), + + query: (_sprawl, artist) => ({ + hasGallery: + !empty(artist.albumCoverArtistContributions) || + !empty(artist.trackCoverArtistContributions), + }), + + relations: (relation, query, _sprawl, artist) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + artistMainLink: + relation('linkArtist', artist), + + artistInfoLink: + relation('linkArtist', artist), + + artistGalleryLink: + (query.hasGallery + ? relation('linkArtistGallery', artist) + : null), + }), + + data: (_query, sprawl) => ({ + enableListings: + sprawl.enableListings, + }), + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate: (data, relations, slots, {html, language}) => [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + html: + language.$('artistPage.nav.artist', { + artist: relations.artistMainLink, + }), + + accent: + relations.switcher.slots({ + links: [ + relations.artistInfoLink.slots({ + attributes: [ + slots.currentExtra === null && + {class: 'current'}, + + {[html.onlyIfSiblings]: true}, + ], + + content: language.$('misc.nav.info'), + }), + + slots.showExtraLinks && + relations.artistGalleryLink?.slots({ + attributes: [ + slots.currentExtra === 'gallery' && + {class: 'current'}, + ], + + content: language.$('misc.nav.gallery'), + }), + ], + }), + }, + ], +}; diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js new file mode 100644 index 00000000..6648b463 --- /dev/null +++ b/src/content/dependencies/generateBackToAlbumLink.js @@ -0,0 +1,15 @@ +export default { + contentDependencies: ['linkAlbum'], + extraDependencies: ['language'], + + relations: (relation, track) => ({ + trackLink: + relation('linkAlbum', track), + }), + + generate: (relations, {language}) => + relations.trackLink.slots({ + content: language.$('albumPage.nav.backToAlbum'), + color: false, + }), +}; diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js new file mode 100644 index 00000000..8677d811 --- /dev/null +++ b/src/content/dependencies/generateBackToTrackLink.js @@ -0,0 +1,15 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['language'], + + relations: (relation, track) => ({ + trackLink: + relation('linkTrack', track), + }), + + generate: (relations, {language}) => + relations.trackLink.slots({ + content: language.$('trackPage.nav.backToTrack'), + color: false, + }), +}; diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js new file mode 100644 index 00000000..15eb08eb --- /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/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js new file mode 100644 index 00000000..03d95ac5 --- /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 00000000..c412b8f2 --- /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 00000000..5270dbe4 --- /dev/null +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -0,0 +1,91 @@ +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, + lightGhost, + bg, + bgBlack, + shadow, + } = getColors(slots.color); + + let anyContent = [ + `--primary-color: ${primary}`, + `--dark-color: ${dark}`, + `--dim-color: ${dim}`, + `--deep-color: ${deep}`, + `--deep-ghost-color: ${deepGhost}`, + `--light-ghost-color: ${lightGhost}`, + `--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 00000000..c93020f3 --- /dev/null +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -0,0 +1,112 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCommentaryEntryDate', + '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'), + + date: + relation('generateCommentaryEntryDate', entry), + }), + + slots: { + color: {validate: v => v.isColor}, + }, + + generate: (relations, slots, {html, language}) => + language.encapsulate('misc.artistCommentary.entry', entryCapsule => + html.tags([ + html.tag('p', {class: 'commentary-entry-heading'}, + slots.color && + relations.colorStyle.clone() + .slot('color', slots.color), + + !html.isBlank(relations.date) && + {class: 'dated'}, + + language.encapsulate(entryCapsule, 'title', titleCapsule => [ + html.tag('span', {class: 'commentary-entry-heading-text'}, + language.encapsulate(titleCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.artists = + html.tag('span', {class: 'commentary-entry-artists'}, + (relations.artistsContent + ? relations.artistsContent.slot('mode', 'inline') + : relations.artistLinks + ? language.formatConjunctionList(relations.artistLinks) + : language.$(titleCapsule, 'noArtists'))); + + const accent = + html.tag('span', {class: 'commentary-entry-accent'}, + {[html.onlyIfContent]: true}, + + language.encapsulate(titleCapsule, 'accent', accentCapsule => + language.encapsulate(accentCapsule, workingCapsule => { + const workingOptions = {}; + + if (relations.annotationContent) { + workingCapsule += '.withAnnotation'; + workingOptions.annotation = + relations.annotationContent.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }); + } + + if (workingCapsule === accentCapsule) { + return html.blank(); + } else { + return language.$(workingCapsule, workingOptions); + } + }))); + + if (!html.isBlank(accent)) { + workingCapsule += '.withAccent'; + workingOptions.accent = accent; + } + + return language.$(workingCapsule, workingOptions); + })), + + relations.date, + ])), + + html.tag('blockquote', {class: 'commentary-entry-body'}, + slots.color && + relations.colorStyle.clone() + .slot('color', slots.color), + + relations.bodyContent.slot('mode', 'multiline')), + ])), +}; diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js new file mode 100644 index 00000000..f1cf5cb3 --- /dev/null +++ b/src/content/dependencies/generateCommentaryEntryDate.js @@ -0,0 +1,93 @@ +export default { + contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], + extraDependencies: ['html', 'language'], + + relations: (relation, _entry) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + }), + + data: (entry) => ({ + date: entry.date, + secondDate: entry.secondDate, + dateKind: entry.dateKind, + + accessDate: entry.accessDate, + accessKind: entry.accessKind, + }), + + generate(data, relations, {html, language}) { + const titleCapsule = language.encapsulate('misc.artistCommentary.entry.title'); + + const willDisplayTooltip = + !!(data.accessKind && data.accessDate); + + const topAttributes = + {class: 'commentary-date'}; + + const time = + html.tag('time', + {[html.onlyIfContent]: true}, + + (willDisplayTooltip + ? {class: 'text-with-tooltip-interaction-cue'} + : topAttributes), + + language.encapsulate(titleCapsule, 'date', workingCapsule => { + const workingOptions = {}; + + if (!data.date) { + return html.blank(); + } + + const rangeNeeded = + data.dateKind === 'sometime' || + data.dateKind === 'throughout'; + + if (rangeNeeded && !data.secondDate) { + workingOptions.date = language.formatDate(data.date); + return language.$(workingCapsule, workingOptions); + } + + if (data.dateKind) { + workingCapsule += '.' + data.dateKind; + } + + if (data.secondDate) { + workingCapsule += '.range'; + workingOptions.dateRange = + language.formatDateRange(data.date, data.secondDate); + } else { + workingOptions.date = + language.formatDate(data.date); + } + + return language.$(workingCapsule, workingOptions); + })); + + if (willDisplayTooltip) { + return relations.textWithTooltip.slots({ + customInteractionCue: true, + + attributes: topAttributes, + text: time, + + tooltip: + relations.tooltip.slots({ + attributes: {class: 'commentary-date-tooltip'}, + + content: + language.$(titleCapsule, 'date', data.accessKind, { + date: + language.formatDate(data.accessDate), + }), + }), + }); + } else { + return time; + } + }, +} diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js new file mode 100644 index 00000000..d68ba42e --- /dev/null +++ b/src/content/dependencies/generateCommentaryIndexPage.js @@ -0,0 +1,104 @@ +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}) => + language.encapsulate('commentaryIndex', pageCapsule => + relations.layout.slots({ + title: language.$(pageCapsule, 'title'), + + headingMode: 'static', + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', language.$(pageCapsule, 'infoLine', { + words: + html.tag('b', + language.formatWordCount(data.totalWordCount, {unit: true})), + + entries: + html.tag('b', + language.countCommentaryEntries(data.totalEntryCount, {unit: true})), + })), + + language.encapsulate(pageCapsule, 'albumList', listCapsule => [ + html.tag('p', + language.$(listCapsule, 'title')), + + html.tag('ul', + stitchArrays({ + albumLink: relations.albumLinks, + wordCount: data.wordCounts, + entryCount: data.entryCounts, + }).map(({albumLink, wordCount, entryCount}) => + html.tag('li', + language.$(listCapsule, '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/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 00000000..f52bc043 --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,61 @@ +export default { + extraDependencies: ['html'], + contentDependencies: ['generateColorStyleAttribute'], + + relations: (relation) => ({ + colorStyle: relation('generateColorStyleAttribute'), + }), + + slots: { + title: { + type: 'html', + mutable: false, + }, + + stickyTitle: { + type: 'html', + mutable: false, + }, + + accent: { + type: 'html', + mutable: false, + }, + + attributes: { + type: 'attributes', + mutable: false, + }, + + color: {validate: v => v.isColor}, + + tag: { + type: 'string', + default: 'p', + }, + }, + + generate: (relations, slots, {html}) => + html.tag(slots.tag, {class: 'content-heading'}, + {tabindex: '0'}, + {[html.onlyIfSiblings]: true}, + + slots.attributes, + + slots.color && + relations.colorStyle.slot('color', slots.color), + + [ + html.tag('span', {class: 'content-heading-main-title'}, + {[html.onlyIfContent]: true}, + slots.title), + + html.tag('template', {class: 'content-heading-sticky-title'}, + {[html.onlyIfContent]: true}, + slots.stickyTitle), + + 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 00000000..d1c3de0f --- /dev/null +++ b/src/content/dependencies/generateContributionList.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html'], + + relations: (relation, contributions) => ({ + contributionLinks: + contributions + .map(contrib => relation('linkContribution', contrib)), + }), + + slots: { + chronologyKind: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.contributionLinks + .map(contributionLink => + html.tag('li', + contributionLink.slots({ + showAnnotation: true, + showExternalLinks: true, + showChronology: true, + preventWrapping: false, + chronologyKind: slots.chronologyKind, + })))), +}; diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js new file mode 100644 index 00000000..3a31014d --- /dev/null +++ b/src/content/dependencies/generateContributionTooltip.js @@ -0,0 +1,48 @@ +export default { + contentDependencies: [ + 'generateContributionTooltipChronologySection', + 'generateContributionTooltipExternalLinkSection', + 'generateTooltip', + ], + + extraDependencies: ['html'], + + relations: (relation, contribution) => ({ + tooltip: + relation('generateTooltip'), + + externalLinkSection: + relation('generateContributionTooltipExternalLinkSection', contribution), + + chronologySection: + relation('generateContributionTooltipChronologySection', contribution), + }), + + slots: { + showExternalLinks: {type: 'boolean'}, + showChronology: {type: 'boolean'}, + + chronologyKind: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + relations.tooltip.slots({ + attributes: + {class: 'contribution-tooltip'}, + + contentAttributes: { + [html.joinChildren]: + html.tag('span', {class: 'tooltip-divider'}), + }, + + content: [ + slots.showExternalLinks && + relations.externalLinkSection, + + slots.showChronology && + relations.chronologySection.slots({ + kind: slots.chronologyKind, + }), + ], + }), +}; diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js new file mode 100644 index 00000000..378c0e1c --- /dev/null +++ b/src/content/dependencies/generateContributionTooltipChronologySection.js @@ -0,0 +1,129 @@ +import Thing from '#thing'; + +function getName(thing) { + if (!thing) { + return null; + } + + const referenceType = thing.constructor[Thing.referenceType]; + + if (referenceType === 'artwork') { + return thing.thing.name; + } + + return thing.name; +} + +export default { + contentDependencies: ['linkAnythingMan'], + extraDependencies: ['html', 'language'], + + query(contribution) { + let previous = contribution; + while (previous && previous.thing === contribution.thing) { + previous = previous.previousBySameArtist; + } + + let next = contribution; + while (next && next.thing === contribution.thing) { + next = next.nextBySameArtist; + } + + return {previous, next}; + }, + + relations: (relation, query, _contribution) => ({ + previousLink: + (query.previous + ? relation('linkAnythingMan', query.previous.thing) + : null), + + nextLink: + (query.next + ? relation('linkAnythingMan', query.next.thing) + : null), + }), + + data: (query, _contribution) => ({ + previousName: + getName(query.previous?.thing), + + nextName: + getName(query.next?.thing), + }), + + slots: { + kind: { + validate: v => + v.is( + 'album', + 'bannerArt', + 'coverArt', + 'flash', + 'track', + 'trackArt', + 'trackContribution', + 'wallpaperArt'), + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('misc.artistLink.chronology', capsule => + html.tags([ + html.tags([ + relations.previousLink?.slots({ + attributes: {class: 'chronology-link'}, + content: [ + html.tag('span', {class: 'chronology-symbol'}, + language.$(capsule, 'previous.symbol')), + + html.tag('span', {class: 'chronology-text'}, + language.sanitize(data.previousName)), + ], + }), + + html.tag('span', {class: 'chronology-info'}, + {[html.onlyIfSiblings]: true}, + + language.encapsulate(capsule, 'previous.info', workingCapsule => { + const workingOptions = {}; + + if (slots.kind) { + workingCapsule += '.withKind'; + workingOptions.kind = + language.$(capsule, 'kind', slots.kind); + } + + return language.$(workingCapsule, workingOptions); + })), + ]), + + html.tags([ + relations.nextLink?.slots({ + attributes: {class: 'chronology-link'}, + content: [ + html.tag('span', {class: 'chronology-symbol'}, + language.$(capsule, 'next.symbol')), + + html.tag('span', {class: 'chronology-text'}, + language.sanitize(data.nextName)), + ], + }), + + html.tag('span', {class: 'chronology-info'}, + {[html.onlyIfSiblings]: true}, + + language.encapsulate(capsule, 'next.info', workingCapsule => { + const workingOptions = {}; + + if (slots.kind) { + workingCapsule += '.withKind'; + workingOptions.kind = + language.$(capsule, 'kind', slots.kind); + } + + return language.$(workingCapsule, workingOptions); + })) + ]), + ])), +}; diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js new file mode 100644 index 00000000..4f9a23ed --- /dev/null +++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js @@ -0,0 +1,70 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateExternalHandle', + 'generateExternalIcon', + 'generateExternalPlatform', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, contribution) => ({ + icons: + contribution.artist.urls + .map(url => relation('generateExternalIcon', url)), + + handles: + contribution.artist.urls + .map(url => relation('generateExternalHandle', url)), + + platforms: + contribution.artist.urls + .map(url => relation('generateExternalPlatform', url)), + }), + + data: (contribution) => ({ + urls: contribution.artist.urls, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('misc.artistLink', capsule => + html.tags( + stitchArrays({ + icon: relations.icons, + handle: relations.handles, + platform: relations.platforms, + url: data.urls, + }).map(({icon, handle, platform, url}) => { + for (const template of [icon, handle, platform]) { + template.setSlot('context', 'artist'); + } + + return [ + html.tag('a', {class: 'external-link'}, + {href: url}, + + [ + icon, + + html.tag('span', {class: 'external-handle'}, + (html.isBlank(handle) + ? platform + : handle)), + ]), + + html.tag('span', {class: 'external-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. + (((new URL(url)) + .host + .endsWith( + html.resolve(platform, {normalize: 'string'}))) + + ? language.$(capsule, 'noExternalLinkPlatformName') + : platform)), + ]; + }))), +}; diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js new file mode 100644 index 00000000..3a10ab20 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,121 @@ +export default { + contentDependencies: [ + 'generateCoverArtworkArtTagDetails', + 'generateCoverArtworkArtistDetails', + 'generateCoverArtworkOriginDetails', + 'generateCoverArtworkReferenceDetails', + 'image', + ], + + extraDependencies: ['html'], + + relations: (relation, artwork) => ({ + image: + relation('image', artwork), + + originDetails: + relation('generateCoverArtworkOriginDetails', artwork), + + artTagDetails: + relation('generateCoverArtworkArtTagDetails', artwork), + + artistDetails: + relation('generateCoverArtworkArtistDetails', artwork), + + referenceDetails: + relation('generateCoverArtworkReferenceDetails', artwork), + }), + + data: (artwork) => ({ + color: + artwork.thing.color ?? null, + + dimensions: + artwork.dimensions, + }), + + slots: { + alt: {type: 'string'}, + + color: { + validate: v => v.isColor, + }, + + mode: { + validate: v => v.is('primary', 'thumbnail', 'commentary'), + default: 'primary', + }, + + showOriginDetails: {type: 'boolean', default: false}, + showArtTagDetails: {type: 'boolean', default: false}, + showArtistDetails: {type: 'boolean', default: false}, + showReferenceDetails: {type: 'boolean', default: false}, + + details: { + type: 'html', + mutable: false, + }, + }, + + generate(data, relations, slots, {html}) { + const {image} = relations; + + image.setSlots({ + color: slots.color ?? data.color, + alt: slots.alt, + }); + + const square = + (data.dimensions + ? data.dimensions[0] === data.dimensions[1] + : true); + + if (square) { + image.setSlot('square', true); + } else { + image.setSlot('dimensions', data.dimensions); + } + + return ( + html.tag('div', {class: 'cover-artwork'}, + slots.mode === 'commentary' && + {class: 'commentary-art'}, + + (slots.mode === 'primary' + ? [ + relations.image.slots({ + thumb: 'medium', + reveal: true, + link: true, + }), + + slots.showOriginDetails && + relations.originDetails, + + slots.showArtTagDetails && + relations.artTagDetails, + + slots.showArtistDetails && + relations.artistDetails, + + slots.showReferenceDetails && + relations.referenceDetails, + + slots.details, + ] + : slots.mode === 'thumbnail' + ? relations.image.slots({ + thumb: 'small', + reveal: false, + link: false, + }) + : slots.mode === 'commentary' + ? relations.image.slots({ + thumb: 'medium', + reveal: true, + link: true, + lazy: true, + }) + : html.blank()))); + }, +}; diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js new file mode 100644 index 00000000..b20f599b --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js @@ -0,0 +1,50 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkArtTagGallery'], + extraDependencies: ['html'], + + query: (artwork) => ({ + linkableArtTags: + artwork.artTags + .filter(tag => !tag.isContentWarning), + }), + + relations: (relation, query, _artwork) => ({ + artTagLinks: + query.linkableArtTags + .map(tag => relation('linkArtTagGallery', tag)), + }), + + data: (query, _artwork) => { + 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); + } + } + + const preferShortName = + query.linkableArtTags + .map(artTag => !duplicateShortNames.has(artTag.nameShort)); + + return {preferShortName}; + }, + + generate: (data, relations, {html}) => + html.tag('ul', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + + {class: 'art-tag-details'}, + + stitchArrays({ + artTagLink: relations.artTagLinks, + preferShortName: data.preferShortName, + }).map(({artTagLink, preferShortName}) => + html.tag('li', + artTagLink.slot('preferShortName', preferShortName)))), +}; diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js new file mode 100644 index 00000000..3ead80ab --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js @@ -0,0 +1,25 @@ +export default { + contentDependencies: ['linkArtistGallery'], + extraDependencies: ['html', 'language'], + + relations: (relation, artwork) => ({ + artistLinks: + artwork.artistContribs + .map(contrib => contrib.artist) + .map(artist => + relation('linkArtistGallery', artist)), + }), + + generate: (relations, {html, language}) => + html.tag('p', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + + {class: 'illustrator-details'}, + + language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatConjunctionList(relations.artistLinks), + })), +}; diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js new file mode 100644 index 00000000..08a01cfe --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js @@ -0,0 +1,98 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'generateArtistCredit', + 'generateAbsoluteDatetimestamp', + 'linkAlbum', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'pagePath'], + + query: (artwork) => ({ + artworkThingType: + artwork.thing.constructor[Thing.referenceType], + }), + + relations: (relation, query, artwork) => ({ + credit: + relation('generateArtistCredit', artwork.artistContribs, []), + + source: + relation('transformContent', artwork.source), + + albumLink: + (query.artworkThingType === 'album' + ? relation('linkAlbum', artwork.thing) + : null), + + datetimestamp: + (artwork.date && artwork.date !== artwork.thing.date + ? relation('generateAbsoluteDatetimestamp', artwork.date) + : null), + }), + + + data: (query, artwork) => ({ + label: + artwork.label, + + artworkThingType: + query.artworkThingType, + }), + + generate: (data, relations, {html, language, pagePath}) => + language.encapsulate('misc.coverArtwork', capsule => + html.tag('p', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + {class: 'origin-details'}, + + [ + language.encapsulate(capsule, 'artworkBy', workingCapsule => { + const workingOptions = {}; + + if (data.label) { + workingCapsule += '.customLabel'; + workingOptions.label = data.label; + } + + if (relations.datetimestamp) { + workingCapsule += '.withYear'; + workingOptions.year = + relations.datetimestamp.slots({ + style: 'year', + tooltip: true, + }); + } + + return relations.credit.slots({ + showAnnotation: true, + showExternalLinks: true, + showChronology: true, + showWikiEdits: true, + + trimAnnotation: false, + + chronologyKind: 'coverArt', + + normalStringKey: workingCapsule, + additionalStringOptions: workingOptions, + }); + }), + + pagePath[0] === 'track' && + data.artworkThingType === 'album' && + language.$(capsule, 'trackArtFromAlbum', { + album: + relations.albumLink.slot('color', false), + }), + + language.$(capsule, 'source', { + [language.onlyIfOptions]: ['source'], + source: relations.source.slot('mode', 'inline'), + }), + ])), +}; diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js new file mode 100644 index 00000000..035ab586 --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js @@ -0,0 +1,60 @@ +export default { + contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'], + extraDependencies: ['html', 'language'], + + relations: (relation, artwork) => ({ + referencedArtworksLink: + relation('linkReferencedArtworks', artwork), + + referencingArtworksLink: + relation('linkReferencingArtworks', artwork), + }), + + data: (artwork) => ({ + referenced: + artwork.referencedArtworks.length, + + referencedBy: + artwork.referencedByArtworks.length, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo', capsule => { + const referencedText = + language.$(capsule, 'referencesArtworks', { + [language.onlyIfOptions]: ['artworks'], + + artworks: + language.countArtworks(data.referenced, { + blankIfZero: true, + unit: true, + }), + }); + + const referencingText = + language.$(capsule, 'referencedByArtworks', { + [language.onlyIfOptions]: ['artworks'], + + artworks: + language.countArtworks(data.referencedBy, { + blankIfZero: true, + unit: true, + }), + }); + + return ( + html.tag('p', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + {class: 'reference-details'}, + + [ + !html.isBlank(referencedText) && + relations.referencedArtworksLink.slot('content', referencedText), + + !html.isBlank(referencingText) && + relations.referencingArtworksLink.slot('content', referencingText), + ])); + }), +} diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js new file mode 100644 index 00000000..430f651e --- /dev/null +++ b/src/content/dependencies/generateCoverCarousel.js @@ -0,0 +1,55 @@ +import {empty, repeat, stitchArrays} from '#sugar'; +import {getCarouselLayoutForNumberOfItems} from '#wiki-data'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.strictArrayOf(v.isHTML)}, + links: {validate: v => v.strictArrayOf(v.isHTML)}, + + lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + const stitched = + stitchArrays({ + image: slots.images, + link: slots.links, + }); + + if (empty(stitched)) { + return; + } + + const layout = getCarouselLayoutForNumberOfItems(stitched.length); + + return html.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), + }), + })))), + ])), + ]); + }, +}; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js new file mode 100644 index 00000000..29ac08b7 --- /dev/null +++ b/src/content/dependencies/generateCoverGrid.js @@ -0,0 +1,90 @@ +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)}, + + // Differentiating from sparseArrayOf here - this list of classes should + // have the same length as the items above, i.e. nulls aren't going to be + // filtered out of it, but it is okay to *include* null (standing in for + // no classes for this grid item). + classes: { + validate: v => + v.strictArrayOf( + v.optional( + v.anyOf( + v.isArray, + v.isString))), + }, + + lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, + actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + }, + + generate: (relations, slots, {html, language}) => + html.tag('div', {class: 'grid-listing'}, + {[html.onlyIfContent]: true}, + + [ + stitchArrays({ + classes: slots.classes, + image: slots.images, + link: slots.links, + name: slots.names, + info: slots.info, + }).map(({classes, image, link, name, info}, index) => + link.slots({ + attributes: [ + {class: ['grid-item', 'box']}, + + (classes + ? {class: classes} + : null), + ], + + 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.$('misc.coverGrid.details.accent', { + [language.onlyIfOptions]: ['details'], + + details: 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 00000000..a92d15fc --- /dev/null +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -0,0 +1,40 @@ +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: + (html.isBlank(slots.tooltip) + ? null + : slots.tooltip.slots({ + attributes: [{class: 'datetimestamp-tooltip'}], + })), + }), +}; diff --git a/src/content/dependencies/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js new file mode 100644 index 00000000..22205922 --- /dev/null +++ b/src/content/dependencies/generateDotSwitcherTemplate.js @@ -0,0 +1,41 @@ +export default { + extraDependencies: ['html'], + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + options: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + initialOptionIndex: {type: 'number'}, + }, + + generate: (slots, {html}) => + html.tag('span', {class: 'dot-switcher'}, + {[html.onlyIfContent]: true}, + {[html.noEdgeWhitespace]: true}, + {[html.joinChildren]: ''}, + + slots.attributes, + + slots.options + .map((option, index) => + html.tag('span', + {[html.onlyIfContent]: true}, + + html.resolve(option, {normalize: 'tag'}) + .onlyIfSiblings && + {[html.onlyIfSiblings]: true}, + + index === slots.initialOptionIndex && + {class: 'current'}, + + [ + html.metatag('imaginary-sibling'), + option, + ]))), +}; diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js new file mode 100644 index 00000000..8c0368a4 --- /dev/null +++ b/src/content/dependencies/generateExternalHandle.js @@ -0,0 +1,20 @@ +import {isExternalLinkContext} from '#external-links'; + +export default { + extraDependencies: ['html', 'language'], + + data: (url) => ({url}), + + slots: { + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + }, + + generate: (data, slots, {language}) => + language.formatExternalLink(data.url, { + style: 'handle', + context: slots.context, + }), +}; diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js new file mode 100644 index 00000000..637af658 --- /dev/null +++ b/src/content/dependencies/generateExternalIcon.js @@ -0,0 +1,26 @@ +import {isExternalLinkContext} from '#external-links'; + +export default { + extraDependencies: ['html', 'language', 'to'], + + data: (url) => ({url}), + + slots: { + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + }, + + generate: (data, slots, {html, language, to}) => + html.tag('span', {class: 'external-icon'}, + html.tag('svg', + html.tag('use', { + href: + to('staticMisc.icon', + language.formatExternalLink(data.url, { + style: 'icon-id', + context: slots.context, + })), + }))), +}; diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js new file mode 100644 index 00000000..c4f63ecf --- /dev/null +++ b/src/content/dependencies/generateExternalPlatform.js @@ -0,0 +1,20 @@ +import {isExternalLinkContext} from '#external-links'; + +export default { + extraDependencies: ['html', 'language'], + + data: (url) => ({url}), + + slots: { + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + }, + + generate: (data, slots, {language}) => + language.formatExternalLink(data.url, { + style: 'platform', + context: slots.context, + }), +}; diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js new file mode 100644 index 00000000..84ab549d --- /dev/null +++ b/src/content/dependencies/generateFlashActGalleryPage.js @@ -0,0 +1,85 @@ +import striptags from 'striptags'; + +export default { + contentDependencies: [ + 'generateCoverGrid', + 'generateFlashActNavAccent', + 'generateFlashActSidebar', + 'generatePageLayout', + 'image', + 'linkFlash', + 'linkFlashAct', + 'linkFlashIndex', + ], + + extraDependencies: ['language'], + + relations: (relation, act) => ({ + layout: + relation('generatePageLayout'), + + flashIndexLink: + relation('linkFlashIndex'), + + flashActNavLink: + relation('linkFlashAct', act), + + flashActNavAccent: + relation('generateFlashActNavAccent', act), + + sidebar: + relation('generateFlashActSidebar', act, null), + + coverGrid: + relation('generateCoverGrid'), + + coverGridImages: + act.flashes + .map(flash => relation('image', flash.coverArtwork)), + + flashLinks: + act.flashes + .map(flash => relation('linkFlash', flash)), + }), + + data: (act) => ({ + name: act.name, + color: act.color, + + flashNames: + act.flashes.map(flash => flash.name), + }), + + generate: (data, relations, {language}) => + language.encapsulate('flashPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + flash: striptags(data.name), + }), + + color: data.color, + headingMode: 'static', + + mainClasses: ['flash-index'], + mainContent: [ + relations.coverGrid.slots({ + links: relations.flashLinks, + images: relations.coverGridImages, + names: data.flashNames, + lazy: 6, + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.flashIndexLink}, + {html: relations.flashActNavLink}, + ], + + 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 00000000..c4ec77b8 --- /dev/null +++ b/src/content/dependencies/generateFlashActNavAccent.js @@ -0,0 +1,64 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'generateNextLink', + 'generatePreviousLink', + 'linkFlashAct', + ], + + extraDependencies: ['wikiData'], + + sprawl: ({flashActData}) => + ({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) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + previousLink: + relation('generatePreviousLink'), + + nextLink: + relation('generateNextLink'), + + previousFlashActLink: + (query.previousFlashAct + ? relation('linkFlashAct', query.previousFlashAct) + : null), + + nextFlashActLink: + (query.nextFlashAct + ? relation('linkFlashAct', query.nextFlashAct) + : null), + }), + + generate: (relations) => + relations.switcher.slots({ + links: [ + relations.previousLink + .slot('link', relations.previousFlashActLink), + + relations.nextLink + .slot('link', relations.nextFlashActLink), + ], + }), +}; diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js new file mode 100644 index 00000000..1421dde9 --- /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 00000000..6d152c7c --- /dev/null +++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js @@ -0,0 +1,64 @@ +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', + html.tag('b', + (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 00000000..7b26ef31 --- /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', + html.tag('b', 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/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js new file mode 100644 index 00000000..5987df9e --- /dev/null +++ b/src/content/dependencies/generateFlashArtworkColumn.js @@ -0,0 +1,11 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, flash) => ({ + coverArtwork: + relation('generateCoverArtwork', flash.coverArtwork), + }), + + generate: (relations) => + relations.coverArtwork, +}; diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js new file mode 100644 index 00000000..2788406c --- /dev/null +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -0,0 +1,144 @@ +import {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(flash => relation('image', flash.coverArtwork))), + }), + + 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)), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('flashIndex', pageCapsule => + relations.layout.slots({ + title: language.$(pageCapsule, 'title'), + headingMode: 'static', + + mainClasses: ['flash-index'], + mainContent: [ + html.tags([ + html.tag('p', {class: 'quick-info'}, + {[html.onlyIfSiblings]: true}, + language.$('misc.jumpTo')), + + html.tag('ul', {class: 'quick-info'}, + {[html.onlyIfContent]: true}, + 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, + }).map(({ + colorStyle, + actLink, + anchor, + + coverGrid, + coverGridImages, + coverGridLinks, + coverGridNames, + }, index) => [ + html.tag('h2', + {id: anchor}, + colorStyle, + actLink), + + coverGrid.slots({ + links: coverGridLinks, + images: coverGridImages, + names: coverGridNames, + lazy: index === 0 ? 4 : true, + }), + ]), + ], + + 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 00000000..095e43c4 --- /dev/null +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -0,0 +1,202 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateCommentaryEntry', + 'generateContentHeading', + 'generateContributionList', + 'generateFlashActSidebar', + 'generateFlashArtworkColumn', + 'generateFlashNavAccent', + 'generatePageLayout', + 'generateTrackList', + 'linkExternal', + 'linkFlashAct', + ], + + extraDependencies: ['html', 'language'], + + query(flash) { + const query = {}; + + 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) => ({ + layout: + relation('generatePageLayout'), + + sidebar: + relation('generateFlashActSidebar', flash.act, flash), + + additionalNamesBox: + relation('generateAdditionalNamesBox', flash.additionalNames), + + externalLinks: + query.urls + .map(url => relation('linkExternal', url)), + + artworkColumn: + relation('generateFlashArtworkColumn', flash), + + contentHeading: + relation('generateContentHeading'), + + flashActLink: + relation('linkFlashAct', flash.act), + + flashNavAccent: + relation('generateFlashNavAccent', flash), + + featuredTracksList: + relation('generateTrackList', flash.featuredTracks), + + contributorContributionList: + relation('generateContributionList', flash.contributorContribs), + + artistCommentaryEntries: + flash.commentary + .map(entry => relation('generateCommentaryEntry', entry)), + + creditSourceEntries: + flash.commentary + .map(entry => relation('generateCommentaryEntry', entry)), + }), + + data: (_query, flash) => ({ + name: + flash.name, + + color: + flash.color, + + date: + flash.date, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('flashPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + flash: data.name, + }), + + color: data.color, + headingMode: 'sticky', + + additionalNames: relations.additionalNamesBox, + + artworkColumnContent: relations.artworkColumn, + + mainContent: [ + html.tag('p', + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.playOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'flash'))), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate('releaseInfo', capsule => [ + !html.isBlank(relations.artistCommentaryEntries) && + language.encapsulate(capsule, 'readCommentary', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#artist-commentary'}, + language.$(capsule, 'link')), + })), + + !html.isBlank(relations.creditSourceEntries) && + language.encapsulate(capsule, 'readCreditSources', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#credit-sources'}, + language.$(capsule, 'link')), + })), + ])), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'features'}, + title: + language.$('releaseInfo.tracksFeatured', { + flash: html.tag('i', data.name), + }), + }), + + relations.featuredTracksList, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'contributors'}, + title: language.$('releaseInfo.contributors'), + }), + + relations.contributorContributionList.slots({ + chronologyKind: 'flash', + }), + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'artist-commentary'}, + title: language.$('misc.artistCommentary'), + }), + + relations.artistCommentaryEntries, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'credit-sources'}, + title: language.$('misc.creditSources'), + }), + + relations.creditSourceEntries, + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.flashActLink.slot('color', false)}, + {auto: 'current'}, + ], + + navBottomRowContent: relations.flashNavAccent, + + leftSidebar: relations.sidebar, + })), +}; diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js new file mode 100644 index 00000000..0f5d2d6b --- /dev/null +++ b/src/content/dependencies/generateFlashNavAccent.js @@ -0,0 +1,66 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'generateNextLink', + 'generatePreviousLink', + 'linkFlash', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({flashActData}) => + ({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) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + previousLink: + relation('generatePreviousLink'), + + nextLink: + relation('generateNextLink'), + + previousFlashLink: + (query.previousFlash + ? relation('linkFlash', query.previousFlash) + : null), + + nextFlashLink: + (query.nextFlash + ? relation('linkFlash', query.nextFlash) + : null), + }), + + generate: (relations) => + relations.switcher.slots({ + links: [ + relations.previousLink + .slot('link', relations.previousFlashLink), + + relations.nextLink + .slot('link', relations.nextFlashLink), + ], + }), +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 00000000..dfd83aef --- /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 00000000..585a02b9 --- /dev/null +++ b/src/content/dependencies/generateGridActionLinks.js @@ -0,0 +1,16 @@ +export default { + extraDependencies: ['html'], + + slots: { + actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + }, + + generate: (slots, {html}) => + html.tag('div', {class: 'grid-actions'}, + {[html.onlyIfContent]: true}, + + (slots.actionLinks ?? []) + .filter(link => link && !html.isBlank(link)) + .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 00000000..d51366ca --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -0,0 +1,182 @@ +import {sortChronologically} from '#sort'; +import {empty, stitchArrays} from '#sugar'; +import {filterItemsForCarousel, getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: [ + 'generateCoverCarousel', + 'generateCoverGrid', + 'generateGroupNavLinks', + 'generateGroupSecondaryNav', + 'generateGroupSidebar', + 'generatePageLayout', + 'generateQuickDescription', + '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.coverArtworks[0])); + } + + relations.quickDescription = + relation('generateQuickDescription', group); + + relations.coverGrid = + relation('generateCoverGrid'); + + relations.gridLinks = + albums + .map(album => relation('linkAlbum', album)); + + relations.gridImages = + albums.map(album => + (album.hasCoverArt + ? relation('image', album.coverArtworks[0]) + : 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, {mainReleasesOnly: true}); + + data.gridNames = albums.map(album => album.name); + data.gridDurations = albums.map(album => getTotalDuration(album.tracks)); + data.gridNumTracks = albums.map(album => album.tracks.length); + + return data; + }, + + generate: (data, relations, {html, language}) => + language.encapsulate('groupGalleryPage', pageCapsule => + relations.layout.slots({ + title: language.$(pageCapsule, 'title', {group: data.name}), + headingMode: 'static', + + color: data.color, + + mainClasses: ['top-index'], + mainContent: [ + relations.coverCarousel + ?.slots({ + links: relations.carouselLinks, + images: relations.carouselImages, + }), + + relations.quickDescription, + + html.tag('p', {class: 'quick-info'}, + language.$(pageCapsule, '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, + name: data.gridNames, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$('misc.coverGrid.noCoverArt', { + album: name, + }), + })), + + info: + stitchArrays({ + numTracks: data.gridNumTracks, + duration: data.gridDurations, + }).map(({numTracks, duration}) => + language.$('misc.coverGrid.details.albumLength', { + 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 00000000..7b9c2afa --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -0,0 +1,179 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateGroupInfoPageAlbumsSection', + 'generateGroupNavLinks', + 'generateGroupSecondaryNav', + 'generateGroupSidebar', + 'generatePageLayout', + 'linkArtist', + 'linkExternal', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableGroupUI: + wikiInfo.enableGroupUI, + + wikiColor: + wikiInfo.color, + }), + + query: (_sprawl, group) => ({ + aliasLinkedArtists: + group.closelyLinkedArtists + .filter(({annotation}) => + annotation === 'alias'), + + generalLinkedArtists: + group.closelyLinkedArtists + .filter(({annotation}) => + annotation !== 'alias'), + }), + + relations: (relation, query, sprawl, group) => ({ + layout: + relation('generatePageLayout'), + + navLinks: + relation('generateGroupNavLinks', group), + + secondaryNav: + (sprawl.enableGroupUI + ? relation('generateGroupSecondaryNav', group) + : null), + + sidebar: + (sprawl.enableGroupUI + ? relation('generateGroupSidebar', group) + : null), + + wikiColorAttribute: + relation('generateColorStyleAttribute', sprawl.wikiColor), + + closeArtistLinks: + query.generalLinkedArtists + .map(({artist}) => relation('linkArtist', artist)), + + aliasArtistLinks: + query.aliasLinkedArtists + .map(({artist}) => relation('linkArtist', artist)), + + visitLinks: + group.urls + .map(url => relation('linkExternal', url)), + + description: + relation('transformContent', group.description), + + albumSection: + relation('generateGroupInfoPageAlbumsSection', group), + }), + + data: (query, _sprawl, group) => ({ + name: + group.name, + + color: + group.color, + + closeArtistAnnotations: + query.generalLinkedArtists + .map(({annotation}) => annotation), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('groupInfoPage', pageCapsule => + relations.layout.slots({ + title: language.$(pageCapsule, 'title', {group: data.name}), + headingMode: 'sticky', + color: data.color, + + mainContent: [ + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate(pageCapsule, 'closelyLinkedArtists', capsule => [ + language.encapsulate(capsule, capsule => { + const [workingCapsule, option] = + (relations.closeArtistLinks.length === 0 + ? [null, null] + : relations.closeArtistLinks.length === 1 + ? [language.encapsulate(capsule, 'one'), 'artist'] + : [language.encapsulate(capsule, 'multiple'), 'artists']); + + if (!workingCapsule) return html.blank(); + + return language.$(workingCapsule, { + [option]: + language.formatUnitList( + stitchArrays({ + link: relations.closeArtistLinks, + annotation: data.closeArtistAnnotations, + }).map(({link, annotation}) => + language.encapsulate(capsule, 'artist', workingCapsule => { + const workingOptions = {}; + + workingOptions.artist = + link.slots({ + attributes: [relations.wikiColorAttribute], + }); + + if (annotation) { + workingCapsule += '.withAnnotation'; + workingOptions.annotation = annotation; + } + + return language.$(workingCapsule, workingOptions); + }))), + }); + }), + + language.$(capsule, 'aliases', { + [language.onlyIfOptions]: ['aliases'], + + aliases: + language.formatConjunctionList( + relations.aliasArtistLinks.map(link => + link.slots({ + attributes: [relations.wikiColorAttribute], + }))), + }), + ])), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.visitOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + relations.visitLinks + .map(link => link.slot('context', 'group'))), + })), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + relations.description.slot('mode', 'multiline')), + + relations.albumSection, + ], + + 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/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js new file mode 100644 index 00000000..df42598d --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js @@ -0,0 +1,47 @@ +import {sortChronologically} from '#sort'; + +export default { + contentDependencies: ['generateGroupInfoPageAlbumsListItem'], + + extraDependencies: ['html'], + + query: (group) => ({ + // Typically, a latestFirst: false (default) chronological sort would be + // appropriate here, but navigation between adjacent albums in a group is a + // rather "essential" movement or relationship in the wiki, and we consider + // the sorting order of a group's gallery page (latestFirst: true) to be + // "canonical" in this regard. We exactly match its sort here, but reverse + // it, to still present earlier albums preceding later ones. + albums: + sortChronologically(group.albums.slice(), {latestFirst: true}) + .reverse(), + }), + + relations: (relation, query, group) => ({ + items: + query.albums + .map(album => + relation('generateGroupInfoPageAlbumsListItem', + album, + group)), + }), + + slots: { + hidden: { + type: 'boolean', + default: false, + }, + }, + + generate: (relations, slots, {html}) => + html.tag('ul', + {id: 'group-album-list-by-date'}, + + slots.hidden && {style: 'display: none'}, + + {[html.onlyIfContent]: true}, + + relations.items + .map(item => + item.slot('accentMode', 'groups'))), +}; diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js new file mode 100644 index 00000000..bcd5d288 --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js @@ -0,0 +1,87 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateGroupInfoPageAlbumsListItem', + ], + + extraDependencies: ['html', 'language'], + + query: (group) => ({ + closelyLinkedArtists: + group.closelyLinkedArtists + .map(({artist}) => artist), + }), + + relations: (relation, _query, group) => ({ + seriesHeadings: + group.serieses + .map(() => relation('generateContentHeading')), + + seriesItems: + group.serieses + .map(series => series.albums + .map(album => + relation('generateGroupInfoPageAlbumsListItem', + album, + group))), + }), + + data: (query, group) => ({ + seriesNames: + group.serieses + .map(series => series.name), + + seriesItemsShowArtists: + group.serieses.map(series => + (series.showAlbumArtists === 'all' + ? new Array(series.albums.length).fill(true) + : series.showAlbumArtists === 'differing' + ? series.albums.map(album => + album.artistContribs + .map(contrib => contrib.artist) + .some(artist => !query.closelyLinkedArtists.includes(artist))) + : new Array(series.albums.length).fill(false))), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('groupInfoPage.albumList', listCapsule => + html.tag('dl', + {id: 'group-album-list-by-series'}, + {class: 'group-series-list'}, + + {[html.onlyIfContent]: true}, + + stitchArrays({ + name: data.seriesNames, + itemsShowArtists: data.seriesItemsShowArtists, + heading: relations.seriesHeadings, + items: relations.seriesItems, + }).map(({ + name, + itemsShowArtists, + heading, + items, + }) => + html.tags([ + heading.slots({ + tag: 'dt', + title: + language.$(listCapsule, 'series', { + series: name, + }), + }), + + html.tag('dd', + html.tag('ul', + stitchArrays({ + item: items, + showArtists: itemsShowArtists, + }).map(({item, showArtists}) => + item.slots({ + accentMode: + (showArtists ? 'artists' : null), + })))), + ])))), +}; diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js new file mode 100644 index 00000000..99e7e8ff --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js @@ -0,0 +1,136 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateArtistCredit', + 'generateColorStyleAttribute', + 'linkAlbum', + 'linkGroup', + ], + + extraDependencies: ['html', 'language'], + + query: (album, group) => { + const otherCategory = + album.groups + .map(group => group.category) + .find(category => category !== group.category); + + const otherGroups = + album.groups + .filter(group => group.category === otherCategory); + + return {otherGroups}; + }, + + relations: (relation, query, album, _group) => ({ + colorStyle: + relation('generateColorStyleAttribute', album.color), + + albumLink: + relation('linkAlbum', album), + + datetimestamp: + (album.date + ? relation('generateAbsoluteDatetimestamp', album.date) + : null), + + artistCredit: + relation('generateArtistCredit', album.artistContribs, []), + + otherGroupLinks: + query.otherGroups + .map(group => relation('linkGroup', group)), + }), + + data: (_query, album, group) => ({ + groupName: + group.name, + + notFromThisGroup: + !group.albums.includes(album), + }), + + slots: { + accentMode: { + validate: v => v.is('groups', 'artists'), + }, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('li', + relations.colorStyle, + + language.encapsulate('groupInfoPage.albumList.item', itemCapsule => + language.encapsulate(itemCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.album = + relations.albumLink.slot('color', false); + + const yearCapsule = language.encapsulate(itemCapsule, 'withYear'); + + if (relations.datetimestamp) { + workingCapsule += '.withYear'; + workingOptions.yearAccent = + language.$(yearCapsule, 'accent', { + year: + relations.datetimestamp.slots({style: 'year', tooltip: true}), + }); + } + + const otherGroupCapsule = language.encapsulate(itemCapsule, 'withOtherGroup'); + + if ( + (slots.accentMode === 'groups' || + slots.accentMode === null) && + data.notFromThisGroup + ) { + workingCapsule += '.withOtherGroup'; + workingOptions.otherGroupAccent = + html.tag('span', {class: 'other-group-accent'}, + language.$(otherGroupCapsule, 'notFromThisGroup', { + group: + data.groupName, + })); + } else if ( + slots.accentMode === 'groups' && + !empty(relations.otherGroupLinks) + ) { + workingCapsule += '.withOtherGroup'; + workingOptions.otherGroupAccent = + html.tag('span', {class: 'other-group-accent'}, + language.$(otherGroupCapsule, 'accent', { + groups: + language.formatConjunctionList( + relations.otherGroupLinks.map(groupLink => + groupLink.slot('color', false))), + })); + } + + const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); + const {artistCredit} = relations; + + artistCredit.setSlots({ + normalStringKey: + artistCapsule + '.by', + + featuringStringKey: + artistCapsule + '.featuring', + + normalFeaturingStringKey: + artistCapsule + '.by.featuring', + }); + + if (slots.accentMode === 'artists' && !html.isBlank(artistCredit)) { + workingCapsule += '.withArtists'; + workingOptions.by = + html.tag('span', {class: 'by'}, + html.metatag('chunkwrap', {split: ','}, + html.resolve(artistCredit))); + } + + return language.$(workingCapsule, workingOptions); + }))), +}; diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js new file mode 100644 index 00000000..0b678e9d --- /dev/null +++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js @@ -0,0 +1,93 @@ +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateGroupInfoPageAlbumsListByDate', + 'generateGroupInfoPageAlbumsListBySeries', + 'generateIntrapageDotSwitcher', + 'linkGroupGallery', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, group) => ({ + contentHeading: + relation('generateContentHeading'), + + galleryLink: + relation('linkGroupGallery', group), + + albumsListByDate: + relation('generateGroupInfoPageAlbumsListByDate', group), + + albumsListBySeries: + relation('generateGroupInfoPageAlbumsListBySeries', group), + + viewSwitcher: + relation('generateIntrapageDotSwitcher'), + }), + + generate: (relations, {html, language}) => + language.encapsulate('groupInfoPage', pageCapsule => + language.encapsulate(pageCapsule, 'albumList', listCapsule => + html.tags([ + relations.contentHeading + .slots({ + tag: 'h2', + title: language.$(listCapsule, 'title'), + }), + + html.tag('p', + {[html.onlyIfSiblings]: true}, + + language.encapsulate(pageCapsule, 'viewAlbumGallery', viewAlbumGalleryCapsule => + language.encapsulate(viewAlbumGalleryCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.link = + relations.galleryLink + .slot('content', + language.$(viewAlbumGalleryCapsule, 'link')); + + if ( + !html.isBlank(relations.albumsListByDate) && + !html.isBlank(relations.albumsListBySeries) + ) { + workingCapsule += '.withViewSwitcher'; + workingOptions.viewSwitcher = + html.tag('span', {class: 'group-view-switcher'}, + language.encapsulate(pageCapsule, 'viewSwitcher', switcherCapsule => + language.$(switcherCapsule, { + options: + relations.viewSwitcher.slots({ + initialOptionIndex: 0, + + titles: [ + language.$(switcherCapsule, 'bySeries'), + language.$(switcherCapsule, 'byDate'), + ], + + targetIDs: [ + 'group-album-list-by-series', + 'group-album-list-by-date', + ], + }), + }))); + } + + return language.$(workingCapsule, workingOptions); + }))), + + ((!html.isBlank(relations.albumsListByDate) && + !html.isBlank(relations.albumsListBySeries)) + + ? [ + relations.albumsListBySeries, + relations.albumsListByDate.slot('hidden', true), + ] + + : [ + relations.albumsListBySeries, + relations.albumsListByDate, + ]), + ]))), +}; diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js new file mode 100644 index 00000000..0e4ebe8a --- /dev/null +++ b/src/content/dependencies/generateGroupNavAccent.js @@ -0,0 +1,53 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'linkGroup', + 'linkGroupGallery', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, group) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + infoLink: + relation('linkGroup', group), + + galleryLink: + (empty(group.albums) + ? null + : relation('linkGroupGallery', group)), + }), + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate: (relations, slots, {language}) => + relations.switcher.slots({ + links: [ + relations.infoLink.slots({ + attributes: [ + slots.currentExtra === null && + {class: 'current'}, + ], + + content: language.$('misc.nav.info'), + }), + + relations.galleryLink?.slots({ + attributes: [ + slots.currentExtra === 'gallery' && + {class: 'current'}, + ], + + content: language.$('misc.nav.gallery'), + }), + ], + }), +}; diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js new file mode 100644 index 00000000..bdc3ee4c --- /dev/null +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -0,0 +1,59 @@ +export default { + contentDependencies: ['generateGroupNavAccent', 'linkGroup'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({groupCategoryData, wikiInfo}) => ({ + groupCategoryData, + enableGroupUI: wikiInfo.enableGroupUI, + enableListings: wikiInfo.enableListings, + }), + + relations: (relation, _sprawl, group) => ({ + mainLink: + relation('linkGroup', group), + + accent: + relation('generateGroupNavAccent', group), + }), + + data: (sprawl, _group) => ({ + enableGroupUI: sprawl.enableGroupUI, + enableListings: sprawl.enableListings, + }), + + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate: (data, relations, slots, {language}) => + (data.enableGroupUI + ? [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + html: + language.$('groupPage.nav.group', { + group: relations.mainLink, + }), + + accent: + relations.accent + .slot('currentExtra', slots.currentExtra), + }, + ].filter(Boolean) + + : [ + {auto: 'home'}, + {auto: 'current'}, + ]), +}; diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js new file mode 100644 index 00000000..c48f3142 --- /dev/null +++ b/src/content/dependencies/generateGroupSecondaryNav.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: [ + 'generateSecondaryNav', + 'generateGroupSecondaryNavCategoryPart', + ], + + relations: (relation, group) => ({ + secondaryNav: + relation('generateSecondaryNav'), + + categoryPart: + relation('generateGroupSecondaryNavCategoryPart', group.category, group), + }), + + generate: (relations) => + relations.secondaryNav.slots({ + attributes: {class: 'nav-links-groups'}, + content: relations.categoryPart, + }), +}; diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js new file mode 100644 index 00000000..b2adb9f8 --- /dev/null +++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js @@ -0,0 +1,79 @@ +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateSecondaryNavParentSiblingsPart', + 'linkGroupDynamically', + 'linkListing', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({listingSpec, wikiInfo}) => ({ + groupsByCategoryListing: + (wikiInfo.enableListings + ? listingSpec + .find(l => l.directory === 'groups/by-category') + : null), + }), + + query(sprawl, category, group) { + const groups = category.groups; + const index = groups.indexOf(group); + + return { + previousGroup: + atOffset(groups, index, -1), + + nextGroup: + atOffset(groups, index, +1), + }; + }, + + relations: (relation, query, sprawl, category, group) => ({ + parentSiblingsPart: + relation('generateSecondaryNavParentSiblingsPart'), + + categoryLink: + (sprawl.groupsByCategoryListing + ? relation('linkListing', sprawl.groupsByCategoryListing) + : null), + + colorStyle: + relation('generateColorStyleAttribute', group.category.color), + + previousGroupLink: + (query.previousGroup + ? relation('linkGroupDynamically', query.previousGroup) + : null), + + nextGroupLink: + (query.nextGroup + ? relation('linkGroupDynamically', query.nextGroup) + : null), + }), + + data: (_query, _sprawl, category, _group) => ({ + name: category.name, + }), + + generate: (data, relations, {language}) => + relations.parentSiblingsPart.slots({ + colorStyle: relations.colorStyle, + id: true, + + mainLink: + (relations.categoryLink + ? relations.categoryLink.slots({ + content: language.sanitize(data.name), + }) + : null), + + previousLink: relations.previousGroupLink, + nextLink: relations.nextGroupLink, + + stringsKey: 'groupPage.secondaryNav.category', + mainLinkOption: 'category', + }), +}; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js new file mode 100644 index 00000000..0888cbbe --- /dev/null +++ b/src/content/dependencies/generateGroupSidebar.js @@ -0,0 +1,46 @@ +export default { + contentDependencies: [ + 'generateGroupSidebarCategoryDetails', + 'generatePageSidebar', + 'generatePageSidebarBox', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({groupCategoryData}) => ({groupCategoryData}), + + relations: (relation, sprawl, group) => ({ + sidebar: + relation('generatePageSidebar'), + + sidebarBox: + relation('generatePageSidebarBox'), + + categoryDetails: + sprawl.groupCategoryData.map(category => + relation('generateGroupSidebarCategoryDetails', category, group)), + }), + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate: (relations, slots, {html, language}) => + relations.sidebar.slots({ + boxes: [ + relations.sidebarBox.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 00000000..208ccd07 --- /dev/null +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -0,0 +1,81 @@ +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}) => + language.encapsulate('groupSidebar.groupList', capsule => + html.tag('details', + data.isCurrentCategory && + {class: 'current', open: true}, + + [ + html.tag('summary', + relations.colorStyle, + + html.tag('span', + language.$(capsule, 'category', { + category: + html.tag('b', 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.$(capsule, 'item', { + group: + (slots.currentExtra === 'gallery' + ? galleryLink ?? infoLink + : infoLink), + })))), + ])), +}; diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js new file mode 100644 index 00000000..cfb78a1b --- /dev/null +++ b/src/content/dependencies/generateImageOverlay.js @@ -0,0 +1,50 @@ +export default { + extraDependencies: ['html', 'language'], + + generate: ({html, language}) => + html.tag('div', {id: 'image-overlay-container'}, + html.tag('div', {id: 'image-overlay-content-container'}, [ + html.tag('span', {id: 'image-overlay-image-area'}, + html.tag('span', {id: 'image-overlay-image-layout'}, [ + html.tag('img', {id: 'image-overlay-image'}), + html.tag('img', {id: 'image-overlay-image-thumb'}), + ])), + + html.tag('div', {id: 'image-overlay-action-container'}, + language.encapsulate('releaseInfo.viewOriginalFile', capsule => [ + html.tag('div', {id: 'image-overlay-action-content-without-size'}, + language.$(capsule, { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$(capsule, 'link')), + })), + + html.tag('div', {id: 'image-overlay-action-content-with-size'}, [ + language.$(capsule, 'withSize', { + link: + html.tag('a', {class: 'image-overlay-view-original'}, + language.$(capsule, '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.$(capsule, 'sizeWarning')), + ]), + ])), + ])), +}; diff --git a/src/content/dependencies/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js new file mode 100644 index 00000000..5a33444e --- /dev/null +++ b/src/content/dependencies/generateInterpageDotSwitcher.js @@ -0,0 +1,31 @@ +export default { + contentDependencies: ['generateDotSwitcherTemplate'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + template: + relation('generateDotSwitcherTemplate'), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + links: { + validate: v => v.strictArrayOf(v.isHTML), + }, + }, + + generate: (relations, slots) => + relations.template.slots({ + attributes: [ + {class: 'interpage'}, + slots.attributes, + ], + + // TODO: Do something to set a class on a link to the current page?? + options: slots.links, + }), +}; diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js new file mode 100644 index 00000000..1d58367d --- /dev/null +++ b/src/content/dependencies/generateIntrapageDotSwitcher.js @@ -0,0 +1,49 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateDotSwitcherTemplate'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + template: + relation('generateDotSwitcherTemplate'), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + initialOptionIndex: {type: 'number'}, + + titles: { + validate: v => v.strictArrayOf(v.isHTML), + }, + + targetIDs: { + validate: v => v.strictArrayOf(v.isString), + }, + }, + + generate: (relations, slots, {html, language}) => + relations.template.slots({ + attributes: [ + {class: 'intrapage'}, + slots.attributes, + ], + + initialOptionIndex: slots.initialOptionIndex, + + options: + stitchArrays({ + title: slots.titles, + targetID: slots.targetIDs, + }).map(({title, targetID}) => + html.tag('a', {href: '#'}, + {'data-target-id': targetID}, + {[html.onlyIfContent]: true}, + + language.sanitize(title))), + }), +}; diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js new file mode 100644 index 00000000..deb8c4ea --- /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, + }) => + language.encapsulate('listingPage', slots.stringsKey, 'file', capsule => + (additionalFileLinks.length === 1 + ? html.tag('li', + additionalFileLinks[0].slots({ + content: + language.$(capsule, { + title: additionalFileTitle, + }), + })) + + : additionalFileLinks.length === 0 + ? html.tag('li', + language.$(capsule, 'withNoFiles', { + title: additionalFileTitle, + })) + + : html.tag('li', {class: 'has-details'}, + html.tag('details', [ + html.tag('summary', + html.tag('span', + language.$(capsule, 'withMultipleFiles', { + title: + html.tag('b', 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.$(capsule, { + title: additionalFileFile, + }), + })))), + ]))))))), + ]); + }, +}; diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js new file mode 100644 index 00000000..b3560aca --- /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 00000000..78622e6e --- /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', + html.tag('b', targetTitle))), + + listingLinkList, + ]))); + + case 'content': + return ( + html.tag('dl', + stitchArrays({ + targetTitle: targetTitles, + listingLinkList: listingLinkLists, + }).map(({targetTitle, listingLinkList}) => [ + html.tag('dt', {class: 'content-heading'}, + targetTitle), + + html.tag('dd', + listingLinkList), + ]))); + } + }, +}; diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js new file mode 100644 index 00000000..5f9a99a9 --- /dev/null +++ b/src/content/dependencies/generateListingPage.js @@ -0,0 +1,288 @@ +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)); + } else { + relations.sameTargetListingLinks = []; + } + + relations.seeAlsoLinks = + (!empty(listing.seeAlso) + ? listing.seeAlso + .map(listing => relation('linkListing', listing)) + : []); + + return relations; + }, + + data(listing) { + return { + stringsKey: listing.stringsKey, + + targetStringsKey: listing.target.stringsKey, + + sameTargetListingStringsKeys: + listing.target.listings + .map(listing => listing.stringsKey), + + sameTargetListingsCurrentIndex: + listing.target.listings + .indexOf(listing), + }; + }, + + 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: [ + html.tag('p', + {[html.onlyIfContent]: true}, + language.$('listingPage.listingsFor', { + [language.onlyIfOptions]: ['listings'], + + target: + language.$('listingPage.target', data.targetStringsKey), + + listings: + language.formatUnitList( + stitchArrays({ + link: relations.sameTargetListingLinks, + stringsKey: data.sameTargetListingStringsKeys, + }).map(({link, stringsKey}, index) => + html.tag('span', + index === data.sameTargetListingsCurrentIndex && + {class: 'current'}, + + link.slots({ + attributes: {class: 'nowrap'}, + content: language.$('listingPage', stringsKey, 'title.short'), + })))), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + language.$('listingPage.seeAlso', { + [language.onlyIfOptions]: ['listings'], + 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', + attributes: [id && {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 00000000..aeac05cf --- /dev/null +++ b/src/content/dependencies/generateListingSidebar.js @@ -0,0 +1,37 @@ +export default { + contentDependencies: [ + 'generateListingIndexList', + 'generatePageSidebar', + 'generatePageSidebarBox', + 'linkListingIndex', + ], + + extraDependencies: ['html'], + + relations: (relation, currentListing) => ({ + sidebar: + relation('generatePageSidebar'), + + sidebarBox: + relation('generatePageSidebarBox'), + + listingIndexLink: + relation('linkListingIndex'), + + listingIndexList: + relation('generateListingIndexList', currentListing), + }), + + generate: (relations, {html}) => + relations.sidebar.slots({ + boxes: [ + relations.sidebarBox.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 00000000..b57ebe15 --- /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/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js new file mode 100644 index 00000000..4f9c22f1 --- /dev/null +++ b/src/content/dependencies/generateLyricsEntry.js @@ -0,0 +1,25 @@ +export default { + contentDependencies: [ + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + content: + relation('transformContent', entry.body), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (relations, slots, {html}) => + html.tag('div', {class: 'lyrics-entry'}, + slots.attributes, + + relations.content.slot('mode', 'lyrics')), +}; diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js new file mode 100644 index 00000000..f6b719a9 --- /dev/null +++ b/src/content/dependencies/generateLyricsSection.js @@ -0,0 +1,81 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateIntrapageDotSwitcher', + 'generateLyricsEntry', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + switcher: + relation('generateIntrapageDotSwitcher'), + + entries: + entries + .map(entry => relation('generateLyricsEntry', entry)), + + annotations: + entries + .map(entry => entry.annotation) + .map(annotation => relation('transformContent', annotation)), + }), + + data: (entries) => ({ + ids: + Array.from( + {length: entries.length}, + (_, index) => 'lyrics-entry-' + index), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo.lyrics', capsule => + html.tags([ + relations.heading + .slots({ + attributes: {id: 'lyrics'}, + title: language.$(capsule), + }), + + html.tag('p', {class: 'lyrics-switcher'}, + {[html.onlyIfContent]: true}, + + language.$(capsule, 'switcher', { + [language.onlyIfOptions]: ['entries'], + + entries: + relations.switcher.slots({ + initialOptionIndex: 0, + + titles: + relations.annotations.map(annotation => + annotation.slots({ + mode: 'inline', + textOnly: true, + })), + + targetIDs: + data.ids, + }), + })), + + stitchArrays({ + entry: relations.entries, + id: data.ids, + }).map(({entry, id}, index) => + entry.slots({ + attributes: [ + {id}, + + index >= 1 && + {style: 'display: none'}, + ], + })), + ])), +}; diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js new file mode 100644 index 00000000..5d168e41 --- /dev/null +++ b/src/content/dependencies/generateNewsEntryNavAccent.js @@ -0,0 +1,40 @@ +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'generateNextLink', + 'generatePreviousLink', + 'linkNewsEntry', + ], + + relations: (relation, previousEntry, nextEntry) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + previousLink: + relation('generatePreviousLink'), + + nextLink: + relation('generateNextLink'), + + previousEntryLink: + (previousEntry + ? relation('linkNewsEntry', previousEntry) + : null), + + nextEntryLink: + (nextEntry + ? relation('linkNewsEntry', nextEntry) + : null), + }), + + generate: (relations) => + relations.switcher.slots({ + links: [ + relations.previousLink + .slot('link', relations.previousEntryLink), + + relations.nextLink + .slot('link', relations.nextEntryLink), + ], + }), +}; diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js new file mode 100644 index 00000000..4abd87d1 --- /dev/null +++ b/src/content/dependencies/generateNewsEntryPage.js @@ -0,0 +1,105 @@ +import {sortChronologically} from '#sort'; +import {atOffset} from '#sugar'; + +export default { + contentDependencies: [ + 'generateNewsEntryNavAccent', + 'generateNewsEntryReadAnotherLinks', + 'generatePageLayout', + '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) => ({ + layout: + relation('generatePageLayout'), + + content: + relation('transformContent', newsEntry.content), + + newsIndexLink: + relation('linkNewsIndex'), + + readAnotherLinks: + relation('generateNewsEntryReadAnotherLinks', + newsEntry, + query.previousEntry, + query.nextEntry), + + navAccent: + relation('generateNewsEntryNavAccent', + query.previousEntry, + query.nextEntry), + }), + + data: (query, sprawl, newsEntry) => ({ + 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}) => + language.encapsulate('newsEntryPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + entry: data.name, + }), + + headingMode: 'sticky', + + mainClasses: ['long-content'], + mainContent: [ + html.tag('p', + language.$(pageCapsule, 'published', { + date: language.formatDate(data.date), + })), + + relations.content, + relations.readAnotherLinks, + ], + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.newsIndexLink}, + { + auto: 'current', + accent: relations.navAccent, + }, + ], + })), +}; diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js new file mode 100644 index 00000000..d978b0e4 --- /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 00000000..02964ce8 --- /dev/null +++ b/src/content/dependencies/generateNewsIndexPage.js @@ -0,0 +1,94 @@ +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}) => + language.encapsulate('newsIndex', pageCapsule => + relations.layout.slots({ + title: language.$(pageCapsule, '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}) => + language.encapsulate(pageCapsule, 'entry', entryCapsule => + html.tag('article', {id: directory}, [ + html.tag('h2', [ + html.tag('time', language.formatDate(date)), + entryLink, + ]), + + content, + + viewRestLink + ?.slot('content', language.$(entryCapsule, 'viewRest')), + ]))), + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + })), +}; diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js new file mode 100644 index 00000000..2e48cd2b --- /dev/null +++ b/src/content/dependencies/generateNextLink.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['generatePreviousNextLink'], + + relations: (relation) => ({ + link: + relation('generatePreviousNextLink'), + }), + + generate: (relations) => + relations.link.slots({ + direction: 'next', + }), +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 00000000..070c7c82 --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,790 @@ +import {openAggregate} from '#aggregate'; +import {atOffset, empty, repeat} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateImageOverlay', + 'generatePageSidebar', + 'generateSearchSidebarBox', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'getColors', + 'html', + 'language', + 'pagePath', + 'pagePathStringFromRoot', + 'to', + 'wikiData', + ], + + sprawl: ({wikiInfo}) => ({ + enableSearch: wikiInfo.enableSearch, + footerContent: wikiInfo.footerContent, + wikiColor: wikiInfo.color, + wikiName: wikiInfo.nameShort, + canonicalBase: wikiInfo.canonicalBase, + }), + + data: (sprawl) => ({ + wikiColor: sprawl.wikiColor, + wikiName: sprawl.wikiName, + canonicalBase: sprawl.canonicalBase, + }), + + relations(relation, sprawl) { + const relations = {}; + + relations.footerLocalizationLinks = + relation('generateFooterLocalizationLinks'); + + relations.stickyHeadingContainer = + relation('generateStickyHeadingContainer'); + + relations.sidebar = + relation('generatePageSidebar'); + + if (sprawl.enableSearch) { + relations.searchBox = + relation('generateSearchSidebarBox'); + } + + if (sprawl.footerContent) { + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + } + + relations.colorStyleRules = + relation('generateColorStyleRules'); + + relations.imageOverlay = + relation('generateImageOverlay'); + + return relations; + }, + + slots: { + title: { + type: 'html', + mutable: false, + }, + + showWikiNameInTitle: { + validate: v => v.is(true, false, 'auto'), + default: 'auto', + }, + + subtitle: { + type: 'html', + mutable: false, + }, + + showSearch: { + type: 'boolean', + default: true, + }, + + additionalNames: { + type: 'html', + mutable: false, + }, + + artworkColumnContent: { + 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, { + getColors, + html, + language, + pagePath, + pagePathStringFromRoot, + 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 oEmbedJSONHref = + (hasSocialEmbed && data.canonicalBase + ? data.canonicalBase + + pagePathStringFromRoot + + 'oembed.json' + : null); + + const canonicalHref = + (data.canonicalBase + ? data.canonicalBase + pagePathStringFromRoot + : null); + + const firstItemInArtworkColumn = + html.smooth(slots.artworkColumnContent) + .content[0]; + + const primaryCover = + (firstItemInArtworkColumn && + html.resolve(firstItemInArtworkColumn, {normalize: 'tag'}) + .attributes.has('class', 'cover-artwork') + ? firstItemInArtworkColumn + : null); + + 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: primaryCover, + }), + + relations.stickyHeadingContainer.clone().slots({ + rootAttributes: {inert: true}, + }), + ] + : html.tag('h1', titleContentsHTML)); + + // TODO: There could be neat interactions with the sticky heading here, + // but for now subtitle is totally separate. + const subtitleHTML = + (html.isBlank(slots.subtitle) + ? null + : html.tag('h2', {class: 'page-subtitle'}, + language.sanitize(slots.subtitle))); + + 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}, + + !html.isBlank(subtitleHTML) && + {class: 'has-subtitle'}, + + [ + titleHTML, + + html.tag('div', {id: 'artwork-column'}, + {[html.onlyIfContent]: true}, + {class: 'isolate-tooltip-z-indexing'}, + + slots.artworkColumnContent), + + subtitleHTML, + + 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, entries) => { + let content; + + if (cur.html) { + content = cur.html; + } else { + const attributes = html.attributes(); + let title; + + switch (cur.auto) { + case 'home': + title = data.wikiName; + attributes.set('href', to('localized.home')); + break; + case 'current': + title = slots.title; + attributes.set('href', ''); + break; + case null: + case undefined: + title = cur.title; + attributes.set('href', to(...cur.path)); + break; + } + + content = html.tag('a', attributes, title); + } + + const showAsCurrent = + cur.current || + cur.auto === 'current' || + (slots.navLinkStyle === 'hierarchical' && + i === slots.navLinks.length - 1); + + const navLink = + html.tag('span', {class: 'nav-link'}, + showAsCurrent && + {class: 'current'}, + + [ + html.tag('span', {class: 'nav-link-content'}, + content), + + html.tag('span', {class: 'nav-link-accent'}, + {[html.noEdgeWhitespace]: true}, + {[html.onlyIfContent]: true}, + + language.$('misc.navAccent', { + [language.onlyIfOptions]: ['links'], + links: cur.accent, + })), + ]); + + if (slots.navLinkStyle === 'index') { + return navLink; + } + + const prev = + atOffset(entries, i, -1); + + if ( + prev && + prev.releaseRestToWrapTogether !== true && + (prev.releaseRestToWrapTogether === false || + prev.auto === 'home') + ) { + return navLink; + } else { + return html.metatag('blockwrap', navLink); + } + })), + + html.tag('div', {class: 'nav-bottom-row'}, + {[html.onlyIfContent]: true}, + + language.$('misc.navAccent', { + [language.onlyIfOptions]: ['links'], + links: slots.navBottomRowContent, + })), + + html.tag('div', {class: 'nav-content'}, + {[html.onlyIfContent]: true}, + slots.navContent), + ]); + + const getSidebar = (side, id, needed) => { + const sidebar = + (html.isBlank(slots[side]) + ? (needed + ? relations.sidebar.clone() + : html.blank()) + : slots[side]); + + if (html.isBlank(sidebar) && !needed) { + return sidebar; + } + + return sidebar.slots({ + attributes: + sidebar + .getSlotValue('attributes') + .with({id}), + }); + } + + const willShowSearch = + slots.showSearch && relations.searchBox; + + let showingSidebarLeft; + let showingSidebarRight; + let sidebarsInContentColumn = false; + + const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch); + const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false); + + if (willShowSearch) { + if (html.isBlank(leftSidebar)) { + sidebarsInContentColumn = true; + showingSidebarLeft = true; + } + + leftSidebar.setSlot( + 'boxes', + html.tags([ + relations.searchBox, + leftSidebar.getSlotValue('boxes'), + ])); + } + + const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar)); + const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar)); + + showingSidebarLeft ??= hasSidebarLeft; + showingSidebarRight ??= hasSidebarRight; + + const processSkippers = skipperList => + skipperList + .filter(({condition, id}) => + (condition === undefined + ? hasID(id) + : condition)) + + .map(({id, string}) => + html.tag('span', {class: 'skipper'}, + {'data-for': id}, + + 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'}, + {id: 'credit-sources', string: 'creditSources'}, + ])), + ]); + + const styleRulesCSS = + html.resolve(slots.styleRules, {normalize: 'string'}); + + const fallbackBackgroundStyleRule = + (styleRulesCSS.match(/body::before[^}]*background-image:/) + ? '' + : `body::before {\n` + + ` background-image: url("${to('media.path', 'bg.jpg')}");\n` + + `}`); + + const numWallpaperParts = + html.resolve(slots.styleRules, {normalize: 'string'}) + .match(/\.wallpaper-part:nth-child/g) + ?.length ?? 0; + + const wallpaperPartsHTML = + html.tag('div', {class: 'wallpaper-parts'}, + {[html.onlyIfContent]: true}, + + repeat(numWallpaperParts, () => + html.tag('div', {class: 'wallpaper-part'}))); + + const layoutHTML = [ + navHTML, + + slots.bannerPosition === 'top' && + slots.banner, + + slots.secondaryNav, + + html.tag('div', {class: 'layout-columns'}, [ + 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-thumb': to('thumb.root')}, + {'data-rebase-lib': to('staticLib.root')}, + {'data-rebase-data': to('data.root')}, + + [ + // developersComment, + + html.tag('head', [ + html.tag('title', + language.encapsulate('misc.pageTitle', workingCapsule => { + const workingOptions = {}; + + workingOptions.title = slots.title; + + if (!html.isBlank(slots.subtitle)) { + workingCapsule += '.withSubtitle'; + workingOptions.subtitle = slots.subtitle; + } + + const showWikiName = + (slots.showWikiNameInTitle === true + ? true + : slots.showWikiNameInTitle === 'auto' + ? html.isBlank(slots.subtitle) + : false); + + if (showWikiName) { + workingCapsule += '.withWikiName'; + workingOptions.wikiName = data.wikiName; + } + + return language.$(workingCapsule, workingOptions); + })), + + 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}))), + */ + + canonicalHref && + html.tag('link', { + rel: 'canonical', + href: canonicalHref, + }), + + /* + ...( + localizedCanonical + .map(({lang, href}) => html.tag('link', { + rel: 'alternate', + hreflang: lang, + href, + }))), + */ + + hasSocialEmbed && + slots.socialEmbed + .clone() + .slot('mode', 'html'), + + oEmbedJSONHref && + html.tag('link', { + type: 'application/json+oembed', + href: oEmbedJSONHref, + }), + + html.tag('link', { + rel: 'stylesheet', + href: to('staticCSS.path', 'site.css'), + }), + + html.tag('style', [ + relations.colorStyleRules + .slot('color', slots.color ?? data.wikiColor), + + fallbackBackgroundStyleRule, + slots.styleRules, + ]), + + html.tag('script', { + src: to('staticLib.path', 'chroma-js/chroma.min.js'), + }), + + html.tag('script', { + blocking: 'render', + src: to('staticJS.path', 'lazy-loading.js'), + }), + + html.tag('script', { + blocking: 'render', + type: 'module', + src: to('staticJS.path', 'client/index.js'), + }), + ]), + + html.tag('body', + [ + wallpaperPartsHTML, + + html.tag('div', {id: 'page-container'}, + showingSidebarLeft && + {class: 'showing-sidebar-left'}, + + showingSidebarRight && + {class: 'showing-sidebar-right'}, + + sidebarsInContentColumn && + {class: 'sidebars-in-content-column'}, + + [ + skippersHTML, + layoutHTML, + ]), + + // infoCardHTML, + relations.imageOverlay, + ]), + ]) + ]).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 00000000..d3b55580 --- /dev/null +++ b/src/content/dependencies/generatePageSidebar.js @@ -0,0 +1,90 @@ +export default { + extraDependencies: ['html'], + + slots: { + // Attributes to apply to the whole sidebar. This be added to the + // containing sidebar-column, arr - specify attributes on each section if + // that's more suitable. + attributes: { + type: 'attributes', + mutable: false, + }, + + // Content boxes to line up vertically 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. + // + // '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('column', 'static'), + default: 'static', + }, + + // 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, + }, + + // Provide to include all the HTML for the sidebar in place as usual, + // but start it out totally invisible. This is mainly so client-side + // JavaScript can show the sidebar if it needs to (and has a target + // to slot its own content into). If there are no boxes and this + // option *isn't* provided, then the sidebar will just be blank. + initiallyHidden: { + type: 'boolean', + default: false, + }, + }, + + generate(slots, {html}) { + const attributes = + html.attributes({class: [ + 'sidebar-column', + 'sidebar-multiple', + ]}); + + attributes.add(slots.attributes); + + if (slots.wide) { + attributes.add('class', 'wide'); + } + + if (slots.stickyMode !== 'static') { + attributes.add('class', `sticky-${slots.stickyMode}`); + } + + const {content: boxes} = html.smooth(slots.boxes); + + const allBoxesCollapsible = + boxes.every(box => + html.resolve(box) + .attributes + .has('class', 'collapsible')); + + if (allBoxesCollapsible) { + attributes.add('class', 'all-boxes-collapsible'); + } + + if (slots.initiallyHidden) { + attributes.add('class', 'initially-hidden'); + } + + if (html.isBlank(slots.boxes) && !slots.initiallyHidden) { + return html.blank(); + } else { + return html.tag('div', attributes, slots.boxes); + } + }, +}; diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js new file mode 100644 index 00000000..26b30494 --- /dev/null +++ b/src/content/dependencies/generatePageSidebarBox.js @@ -0,0 +1,30 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: { + type: 'html', + mutable: false, + }, + + attributes: { + type: 'attributes', + mutable: false, + }, + + collapsible: { + type: 'boolean', + default: true, + }, + }, + + generate: (slots, {html}) => + html.tag('div', {class: 'sidebar'}, + {[html.onlyIfContent]: true}, + + slots.collapsible && + {class: 'collapsible'}, + + slots.attributes, + slots.content), +}; diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js new file mode 100644 index 00000000..7974c707 --- /dev/null +++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js @@ -0,0 +1,38 @@ +// 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', {class: 'cute'}), + ]), + }), +}; diff --git a/src/content/dependencies/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js new file mode 100644 index 00000000..775367f9 --- /dev/null +++ b/src/content/dependencies/generatePreviousLink.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['generatePreviousNextLink'], + + relations: (relation) => ({ + link: + relation('generatePreviousNextLink'), + }), + + generate: (relations) => + relations.link.slots({ + direction: 'previous', + }), +}; diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js new file mode 100644 index 00000000..afae1228 --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLink.js @@ -0,0 +1,58 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + link: { + type: 'html', + mutable: true, + }, + + direction: { + validate: v => v.is('previous', 'next'), + }, + + id: { + type: 'boolean', + default: true, + }, + + showWithoutLink: { + type: 'boolean', + default: true, + }, + }, + + generate(slots, {html, language}) { + if (!slots.direction) { + return html.blank(); + } + + const attributes = html.attributes(); + + if (slots.id) { + attributes.set('id', `${slots.direction}-button`); + } + + if (html.isBlank(slots.link)) { + if (slots.showWithoutLink) { + return ( + html.tag('a', {class: 'inert-previous-next-link'}, + attributes, + language.$('misc.nav', slots.direction))); + } else { + return html.blank(); + } + } + + return html.resolve(slots.link, { + slots: { + tooltipStyle: 'browser', + color: false, + attributes, + + content: + language.$('misc.nav', slots.direction), + } + }); + }, +}; diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js new file mode 100644 index 00000000..e144503e --- /dev/null +++ b/src/content/dependencies/generateQuickDescription.js @@ -0,0 +1,134 @@ +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + query: (thing) => ({ + hasDescription: + !!thing.description, + + hasLongerDescription: + thing.description && + thing.descriptionShort && + thing.descriptionShort !== thing.description, + }), + + relations: (relation, query, thing) => ({ + description: + (query.hasLongerDescription || !thing.description + ? null + : relation('transformContent', thing.description)), + + descriptionShort: + (query.hasLongerDescription + ? relation('transformContent', thing.descriptionShort) + : null), + + descriptionLong: + (query.hasLongerDescription + ? relation('transformContent', thing.description) + : null), + }), + + data: (query) => ({ + hasDescription: query.hasDescription, + hasLongerDescription: query.hasLongerDescription, + }), + + slots: { + extraReadingLinks: { + validate: v => v.sparseArrayOf(v.isHTML), + }, + }, + + generate(data, relations, slots, {html, language}) { + const prefix = 'misc.quickDescription'; + + const actionsWithoutLongerDescription = + (data.hasLongerDescription + ? null + : slots.extraReadingLinks + ? language.$(prefix, 'readMore', { + links: + language.formatDisjunctionList(slots.extraReadingLinks), + }) + : null); + + const wrapExpandCollapseLink = (expandCollapse, content) => + html.tag('a', {class: `${expandCollapse}-link`}, + {href: '#'}, + content); + + const actionsWhenCollapsed = + (data.hasLongerDescription && slots.extraReadingLinks + ? language.$(prefix, 'expandDescription.orReadMore', { + links: + language.formatDisjunctionList(slots.extraReadingLinks), + expand: + wrapExpandCollapseLink('expand', + language.$(prefix, 'expandDescription.orReadMore.expand')), + }) + : data.hasLongerDescription + ? language.$(prefix, 'expandDescription', { + expand: + wrapExpandCollapseLink('expand', + language.$(prefix, 'expandDescription.expand')), + }) + : null); + + const actionsWhenExpanded = + (data.hasLongerDescription && slots.extraReadingLinks + ? language.$(prefix, 'collapseDescription.orReadMore', { + links: + language.formatDisjunctionList(slots.extraReadingLinks), + collapse: + wrapExpandCollapseLink('collapse', + language.$(prefix, 'collapseDescription.orReadMore.collapse')), + }) + : data.hasLongerDescription + ? language.$(prefix, 'collapseDescription', { + collapse: + wrapExpandCollapseLink('collapse', + language.$(prefix, 'collapseDescription.collapse')), + }) + : null); + + const wrapActions = (attributes, children) => + html.tag('p', {class: 'quick-description-actions'}, + {[html.onlyIfContent]: true}, + attributes, + + children); + + const wrapContent = (attributes, content) => + html.tag('blockquote', {class: 'description-content'}, + {[html.onlyIfContent]: true}, + attributes, + + content?.slot('mode', 'multiline')); + + return ( + html.tag('div', {class: 'quick-description'}, + {[html.onlyIfContent]: true}, + + data.hasLongerDescription && + {class: 'collapsed'}, + + !data.hasLongerDescription && + !slots.extraReadingLinks && + {class: 'has-content-only'}, + + !data.hasDescription && + slots.extraReadingLinks && + {class: 'has-external-links-only'}, + + [ + wrapContent(null, relations.description), + wrapContent({class: 'short'}, relations.descriptionShort), + wrapContent({class: 'long'}, relations.descriptionLong), + + wrapActions(null, actionsWithoutLongerDescription), + wrapActions({class: 'when-collapsed'}, actionsWhenCollapsed), + wrapActions({class: 'when-expanded'}, actionsWhenExpanded), + ])); + }, +}; diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js new file mode 100644 index 00000000..154b4762 --- /dev/null +++ b/src/content/dependencies/generateReferencedArtworksPage.js @@ -0,0 +1,100 @@ +export default { + contentDependencies: [ + 'generateCoverArtwork', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAnythingMan', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, artwork) => ({ + layout: + relation('generatePageLayout'), + + cover: + relation('generateCoverArtwork', artwork), + + coverGrid: + relation('generateCoverGrid'), + + links: + artwork.referencedArtworks.map(({artwork}) => + relation('linkAnythingMan', artwork.thing)), + + images: + artwork.referencedArtworks.map(({artwork}) => + relation('image', artwork)), + }), + + data: (artwork) => ({ + color: + artwork.thing.color, + + count: + artwork.referencedArtworks.length, + + names: + artwork.referencedArtworks + .map(({artwork}) => artwork.thing.name), + + coverArtistNames: + artwork.referencedArtworks + .map(({artwork}) => + artwork.artistContribs + .map(contrib => contrib.artist.name)), + }), + + slots: { + styleRules: {type: 'html', mutable: false}, + + title: {type: 'html', mutable: false}, + + navLinks: {validate: v => v.isArray}, + navBottomRowContent: {type: 'html', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('referencedArtworksPage', pageCapsule => + relations.layout.slots({ + title: slots.title, + subtitle: language.$(pageCapsule, 'subtitle'), + + color: data.color, + styleRules: slots.styleRules, + + artworkColumnContent: + relations.cover.slots({ + showArtistDetails: true, + }), + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {class: 'quick-info'}, + language.$(pageCapsule, 'statsLine', { + artworks: + language.countArtworks(data.count, { + unit: true, + }), + })), + + relations.coverGrid.slots({ + links: relations.links, + images: relations.images, + names: data.names, + + info: + data.coverArtistNames.map(names => + language.$('misc.coverGrid.details.coverArtists', { + artists: + language.formatUnitList(names), + })), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: slots.navLinks, + navBottomRowContent: slots.navBottomRowContent, + })), +}; diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js new file mode 100644 index 00000000..55977b37 --- /dev/null +++ b/src/content/dependencies/generateReferencingArtworksPage.js @@ -0,0 +1,100 @@ +export default { + contentDependencies: [ + 'generateCoverArtwork', + 'generateCoverGrid', + 'generatePageLayout', + 'image', + 'linkAnythingMan', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, artwork) => ({ + layout: + relation('generatePageLayout'), + + cover: + relation('generateCoverArtwork', artwork), + + coverGrid: + relation('generateCoverGrid'), + + links: + artwork.referencedByArtworks.map(({artwork}) => + relation('linkAnythingMan', artwork.thing)), + + images: + artwork.referencedByArtworks.map(({artwork}) => + relation('image', artwork)), + }), + + data: (artwork) => ({ + color: + artwork.thing.color, + + count: + artwork.referencedByArtworks.length, + + names: + artwork.referencedByArtworks + .map(({artwork}) => artwork.thing.name), + + coverArtistNames: + artwork.referencedByArtworks + .map(({artwork}) => + artwork.artistContribs + .map(contrib => contrib.artist.name)), + }), + + slots: { + styleRules: {type: 'html', mutable: false}, + + title: {type: 'html', mutable: false}, + + navLinks: {validate: v => v.isArray}, + navBottomRowContent: {type: 'html', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('referencingArtworksPage', pageCapsule => + relations.layout.slots({ + title: slots.title, + subtitle: language.$(pageCapsule, 'subtitle'), + + color: data.color, + styleRules: slots.styleRules, + + artworkColumnContent: + relations.cover.slots({ + showArtistDetails: true, + }), + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {class: 'quick-info'}, + language.$(pageCapsule, 'statsLine', { + artworks: + language.countArtworks(data.count, { + unit: true, + }), + })), + + relations.coverGrid.slots({ + links: relations.links, + images: relations.images, + names: data.names, + + info: + data.coverArtistNames.map(names => + language.$('misc.coverGrid.details.coverArtists', { + artists: + language.formatUnitList(names), + })), + }), + ], + + navLinkStyle: 'hierarchical', + navLinks: slots.navLinks, + navBottomRowContent: slots.navBottomRowContent, + })), +}; diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js new file mode 100644 index 00000000..a997de0e --- /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 00000000..016e0a2c --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -0,0 +1,31 @@ +export default { + contentDependencies: ['generateArtistCredit'], + extraDependencies: ['html'], + + relations: (relation, contributions) => ({ + credit: + relation('generateArtistCredit', contributions, []), + }), + + slots: { + stringKey: {type: 'string'}, + featuringStringKey: {type: 'string'}, + + chronologyKind: {type: 'string'}, + }, + + generate: (relations, slots) => + relations.credit.slots({ + showAnnotation: true, + showExternalLinks: true, + showChronology: true, + showWikiEdits: true, + + trimAnnotation: false, + + chronologyKind: slots.chronologyKind, + + normalStringKey: slots.stringKey, + normalFeaturingStringKey: slots.featuringStringKey, + }), +}; diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js new file mode 100644 index 00000000..188a678f --- /dev/null +++ b/src/content/dependencies/generateSearchSidebarBox.js @@ -0,0 +1,62 @@ +export default { + contentDependencies: ['generatePageSidebarBox'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + sidebarBox: + relation('generatePageSidebarBox'), + }), + + generate: (relations, {html, language}) => + language.encapsulate('misc.search', capsule => + relations.sidebarBox.slots({ + attributes: {class: 'wiki-search-sidebar-box'}, + collapsible: false, + + content: [ + html.tag('label', {class: 'wiki-search-label'}, + html.tag('input', {class: 'wiki-search-input'}, + {type: 'search'}, + + { + placeholder: + language.$(capsule, 'placeholder').toString(), + })), + + html.tag('template', {class: 'wiki-search-preparing-string'}, + language.$(capsule, 'preparing')), + + html.tag('template', {class: 'wiki-search-loading-data-string'}, + language.$(capsule, 'loadingData')), + + html.tag('template', {class: 'wiki-search-searching-string'}, + language.$(capsule, 'searching')), + + html.tag('template', {class: 'wiki-search-failed-string'}, + language.$(capsule, 'failed')), + + html.tag('template', {class: 'wiki-search-no-results-string'}, + language.$(capsule, 'noResults')), + + html.tag('template', {class: 'wiki-search-current-result-string'}, + language.$(capsule, 'currentResult')), + + html.tag('template', {class: 'wiki-search-end-search-string'}, + language.$(capsule, 'endSearch')), + + language.encapsulate(capsule, 'resultKind', capsule => [ + html.tag('template', {class: 'wiki-search-album-result-kind-string'}, + language.$(capsule, 'album')), + + html.tag('template', {class: 'wiki-search-artist-result-kind-string'}, + language.$(capsule, 'artist')), + + html.tag('template', {class: 'wiki-search-group-result-kind-string'}, + language.$(capsule, 'group')), + + html.tag('template', {class: 'wiki-search-tag-result-kind-string'}, + language.$(capsule, 'artTag')), + ]), + ], + })), +}; diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js new file mode 100644 index 00000000..9ce7ce9b --- /dev/null +++ b/src/content/dependencies/generateSecondaryNav.js @@ -0,0 +1,30 @@ +export default { + extraDependencies: ['html'], + + slots: { + content: { + type: 'html', + mutable: false, + }, + + attributes: { + type: 'attributes', + mutable: false, + }, + + alwaysVisible: { + type: 'boolean', + default: false, + }, + }, + + generate: (slots, {html}) => + html.tag('nav', {id: 'secondary-nav'}, + {[html.onlyIfContent]: true}, + slots.attributes, + + slots.alwaysVisible && + {class: 'always-visible'}, + + slots.content), +}; diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js new file mode 100644 index 00000000..f204f1fb --- /dev/null +++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js @@ -0,0 +1,115 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateInterpageDotSwitcher', + 'generateNextLink', + 'generatePreviousLink', + 'linkAlbumDynamically', + 'linkGroup', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + previousLink: + relation('generatePreviousLink'), + + nextLink: + relation('generateNextLink'), + }), + + slots: { + showPreviousNext: { + type: 'boolean', + default: true, + }, + + id: { + type: 'boolean', + default: false, + }, + + attributes: { + type: 'attributes', + mutable: false, + }, + + colorStyle: { + type: 'html', + mutable: true, + }, + + mainLink: { + type: 'html', + mutable: true, + }, + + previousLink: { + type: 'html', + mutable: false, + }, + + nextLink: { + type: 'html', + mutable: false, + }, + + stringsKey: { + type: 'string', + }, + + mainLinkOption: { + type: 'string', + }, + }, + + generate: (relations, slots, {html, language}) => + html.tag('span', + {[html.onlyIfContent]: true}, + {[html.noEdgeWhitespace]: true}, + + slots.attributes, + + !html.isBlank(slots.colorStyle) && + slots.colorStyle + .slot('context', 'primary-only'), + + language.encapsulate(slots.stringsKey, workingCapsule => { + const workingOptions = { + [language.onlyIfOptions]: [slots.mainLinkOption], + }; + + workingOptions[slots.mainLinkOption] = + (html.isBlank(slots.mainLink) + ? null + : slots.mainLink + .slot('color', false)); + + if (slots.showPreviousNext) addPreviousNext: { + if (html.isBlank(slots.previousLink) && html.isBlank(slots.nextLink)) { + break addPreviousNext; + } + + workingCapsule += '.withPreviousNext'; + workingOptions.previousNext = + relations.switcher.slots({ + links: [ + relations.previousLink.slots({ + id: slots.id, + link: slots.previousLink, + }), + + relations.nextLink.slots({ + id: slots.id, + link: slots.nextLink, + }), + ], + }); + } + + return language.$(workingCapsule, workingOptions); + })), +}; diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js new file mode 100644 index 00000000..513ea518 --- /dev/null +++ b/src/content/dependencies/generateSocialEmbed.js @@ -0,0 +1,70 @@ +export default { + extraDependencies: ['absoluteTo', '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: {validate: v => v.strictArrayOf(v.isString)}, + }, + + generate(data, slots, {absoluteTo, 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: absoluteTo(...slots.imagePath), + }), + ]); + + case 'json': + return JSON.stringify({ + author_name: + (slots.headingContent + ? html.resolve( + language.$('misc.socialEmbed.heading', { + wikiName: data.shortWikiName, + heading: slots.headingContent, + }), + {normalize: 'string'}) + : 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 00000000..226152c7 --- /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 00000000..ec3062a3 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,59 @@ +export default { + extraDependencies: ['html'], + + slots: { + rootAttributes: { + type: 'attributes', + mutable: false, + }, + + title: { + type: 'html', + mutable: false, + }, + + cover: { + type: 'html', + mutable: true, + }, + }, + + generate: (slots, {html}) => html.tags([ + html.tag('div', {class: 'content-sticky-heading-root'}, + slots.rootAttributes, + + !html.isBlank(slots.cover) && + {class: 'has-cover'}, + + html.tag('div', {class: 'content-sticky-heading-anchor'}, + 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', [ + html.tag('span', {class: 'reference-collapsed-heading'}, + {inert: true}, + + slots.title.clone()), + + slots.title, + ]), + + html.tag('div', {class: 'content-sticky-heading-cover-container'}, + {[html.onlyIfContent]: true}, + + html.tag('div', {class: 'content-sticky-heading-cover'}, + {[html.onlyIfContent]: true}, + + (html.isBlank(slots.cover) + ? html.blank() + : 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 00000000..49ce1f61 --- /dev/null +++ b/src/content/dependencies/generateTextWithTooltip.js @@ -0,0 +1,71 @@ +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.onlyIfContent]: true, + [html.joinChildren]: '', + [html.noEdgeWhitespace]: true, + class: 'text-with-tooltip', + }); + } + + const textPart = + (hasTooltip && slots.customInteractionCue + ? html.tag('span', {class: 'hoverable'}, + {[html.onlyIfContent]: true}, + + slots.text) + + : hasTooltip + ? html.tag('span', {class: 'hoverable'}, + {[html.onlyIfContent]: true}, + + html.tag('span', {class: 'text-with-tooltip-interaction-cue'}, + {[html.onlyIfContent]: true}, + + 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 00000000..b09ee230 --- /dev/null +++ b/src/content/dependencies/generateTooltip.js @@ -0,0 +1,34 @@ +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}, + {[html.onlyIfContent]: true}, + {[html.onlyIfSiblings]: true}, + slots.attributes, + + html.tag('span', {class: 'tooltip-content'}, + {[html.noEdgeWhitespace]: true}, + {[html.onlyIfContent]: true}, + slots.contentAttributes, + + slots.content)), +}; diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js new file mode 100644 index 00000000..e3041d3a --- /dev/null +++ b/src/content/dependencies/generateTrackArtistCommentarySection.js @@ -0,0 +1,157 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCommentaryEntry', + 'generateContentHeading', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + otherSecondaryReleasesWithCommentary: + track.otherReleases + .filter(track => !track.isMainRelease) + .filter(track => !empty(track.commentary)), + }), + + relations: (relation, query, track) => ({ + contentHeading: + relation('generateContentHeading'), + + mainReleaseTrackLink: + (track.isSecondaryRelease + ? relation('linkTrack', track.mainReleaseTrack) + : null), + + mainReleaseArtistCommentaryEntries: + (track.isSecondaryRelease + ? track.mainReleaseTrack.commentary + .map(entry => relation('generateCommentaryEntry', entry)) + : null), + + thisReleaseAlbumLink: + relation('linkAlbum', track.album), + + artistCommentaryEntries: + track.commentary + .map(entry => relation('generateCommentaryEntry', entry)), + + otherReleaseTrackLinks: + query.otherSecondaryReleasesWithCommentary + .map(track => relation('linkTrack', track)), + }), + + data: (query, track) => ({ + name: + track.name, + + isSecondaryRelease: + track.isSecondaryRelease, + + mainReleaseName: + (track.isSecondaryRelease + ? track.mainReleaseTrack.name + : null), + + mainReleaseAlbumName: + (track.isSecondaryRelease + ? track.mainReleaseTrack.album.name + : null), + + mainReleaseAlbumColor: + (track.isSecondaryRelease + ? track.mainReleaseTrack.album.color + : null), + + otherReleaseAlbumNames: + query.otherSecondaryReleasesWithCommentary + .map(track => track.album.name), + + otherReleaseAlbumColors: + query.otherSecondaryReleasesWithCommentary + .map(track => track.album.color), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('misc.artistCommentary', capsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'artist-commentary'}, + title: language.$('misc.artistCommentary'), + }), + + data.isSecondaryRelease && + html.tags([ + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfSiblings]: true}, + + language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => { + const workingOptions = {}; + + workingOptions.album = + relations.mainReleaseTrackLink.slots({ + content: + data.mainReleaseAlbumName, + + color: + data.mainReleaseAlbumColor, + }); + + if (data.name !== data.mainReleaseName) { + workingCapsule += '.namedDifferently'; + workingOptions.name = + html.tag('i', data.mainReleaseName); + } + + return language.$(workingCapsule, workingOptions); + })), + + relations.mainReleaseArtistCommentaryEntries, + ]), + + html.tags([ + data.isSecondaryRelease && + !html.isBlank(relations.mainReleaseArtistCommentaryEntries) && + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfSiblings]: true}, + + language.$(capsule, 'info.releaseSpecific', { + album: + relations.thisReleaseAlbumLink, + })), + + relations.artistCommentaryEntries, + ]), + + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfContent]: true}, + + language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => { + const workingOptions = {}; + + workingOptions[language.onlyIfOptions] = ['albums']; + + workingOptions.albums = + language.formatUnitList( + stitchArrays({ + trackLink: relations.otherReleaseTrackLinks, + albumName: data.otherReleaseAlbumNames, + albumColor: data.otherReleaseAlbumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))); + + if (!html.isBlank(relations.artistCommentaryEntries)) { + workingCapsule += '.withMainCommentary'; + } + + return language.$(workingCapsule, workingOptions); + })), + ])), +}; diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js new file mode 100644 index 00000000..f06d735b --- /dev/null +++ b/src/content/dependencies/generateTrackArtworkColumn.js @@ -0,0 +1,33 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + extraDependencies: ['html'], + + relations: (relation, track) => ({ + albumCover: + (!track.hasUniqueCoverArt && track.album.hasCoverArt + ? relation('generateCoverArtwork', track.album.coverArtworks[0]) + : null), + + trackCovers: + (track.hasUniqueCoverArt + ? track.trackArtworks.map(artwork => + relation('generateCoverArtwork', artwork)) + : []), + }), + + generate: (relations, {html}) => + html.tags([ + relations.albumCover?.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + }), + + relations.trackCovers.map(cover => + cover.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + })), + ]), +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 00000000..ca6f82b9 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,435 @@ +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateAlbumAdditionalFilesList', + 'generateAlbumNavAccent', + 'generateAlbumSecondaryNav', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateCommentaryEntry', + 'generateContentHeading', + 'generateContributionList', + 'generateLyricsSection', + 'generatePageLayout', + 'generateTrackArtistCommentarySection', + 'generateTrackArtworkColumn', + 'generateTrackInfoPageFeaturedByFlashesList', + 'generateTrackInfoPageOtherReleasesList', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'generateTrackNavLinks', + 'generateTrackReleaseInfo', + 'generateTrackSocialEmbed', + 'linkAlbum', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + mainReleaseTrack: + (track.isMainRelease + ? track + : track.mainReleaseTrack), + }), + + relations: (relation, query, track) => ({ + layout: + relation('generatePageLayout'), + + albumStyleRules: + relation('generateAlbumStyleRules', track.album, track), + + socialEmbed: + relation('generateTrackSocialEmbed', track), + + navLinks: + relation('generateTrackNavLinks', track), + + albumNavAccent: + relation('generateAlbumNavAccent', track.album, track), + + secondaryNav: + relation('generateAlbumSecondaryNav', track.album), + + sidebar: + relation('generateAlbumSidebar', track.album, track), + + additionalNamesBox: + relation('generateAdditionalNamesBox', track.additionalNames), + + artworkColumn: + relation('generateTrackArtworkColumn', track), + + contentHeading: + relation('generateContentHeading'), + + releaseInfo: + relation('generateTrackReleaseInfo', track), + + otherReleasesList: + relation('generateTrackInfoPageOtherReleasesList', track), + + contributorContributionList: + relation('generateContributionList', track.contributorContribs), + + referencedTracksList: + relation('generateTrackList', track.referencedTracks), + + sampledTracksList: + relation('generateTrackList', track.sampledTracks), + + referencedByTracksList: + relation('generateTrackListDividedByGroups', + query.mainReleaseTrack.referencedByTracks), + + sampledByTracksList: + relation('generateTrackListDividedByGroups', + query.mainReleaseTrack.sampledByTracks), + + flashesThatFeatureList: + relation('generateTrackInfoPageFeaturedByFlashesList', track), + + lyricsSection: + relation('generateLyricsSection', track.lyrics), + + sheetMusicFilesList: + relation('generateAlbumAdditionalFilesList', + track.album, + track.sheetMusicFiles), + + midiProjectFilesList: + relation('generateAlbumAdditionalFilesList', + track.album, + track.midiProjectFiles), + + additionalFilesList: + relation('generateAlbumAdditionalFilesList', + track.album, + track.additionalFiles), + + artistCommentarySection: + relation('generateTrackArtistCommentarySection', track), + + creditSourceEntries: + track.creditSources + .map(entry => relation('generateCommentaryEntry', entry)), + }), + + data: (_query, track) => ({ + name: + track.name, + + color: + track.color, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('trackPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + track: data.name, + }), + + headingMode: 'sticky', + + additionalNames: relations.additionalNamesBox, + + color: data.color, + styleRules: [relations.albumStyleRules], + + artworkColumnContent: + relations.artworkColumn, + + mainContent: [ + relations.releaseInfo, + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + language.encapsulate('releaseInfo', capsule => [ + !html.isBlank(relations.sheetMusicFilesList) && + language.encapsulate(capsule, 'sheetMusicFiles.shortcut', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#sheet-music-files'}, + language.$(capsule, 'link')), + })), + + !html.isBlank(relations.midiProjectFilesList) && + language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#midi-project-files'}, + language.$(capsule, 'link')), + })), + + !html.isBlank(relations.additionalFilesList) && + language.encapsulate(capsule, 'additionalFiles.shortcut', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#midi-project-files'}, + language.$(capsule, 'link')), + })), + + !html.isBlank(relations.artistCommentarySection) && + language.encapsulate(capsule, 'readCommentary', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#artist-commentary'}, + language.$(capsule, 'link')), + })), + + !html.isBlank(relations.creditSourceEntries) && + language.encapsulate(capsule, 'readCreditSources', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#credit-sources'}, + language.$(capsule, 'link')), + })), + ])), + + relations.otherReleasesList, + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'contributors'}, + title: language.$('releaseInfo.contributors'), + }), + + relations.contributorContributionList.slots({ + chronologyKind: 'trackContribution', + }), + ]), + + html.tags([ + language.encapsulate('releaseInfo.tracksReferenced', capsule => + relations.contentHeading.clone() + .slots({ + attributes: {id: 'references'}, + + title: + language.$(capsule, { + track: + html.tag('i', data.name), + }), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.referencedTracksList, + ]), + + html.tags([ + language.encapsulate('releaseInfo.tracksSampled', capsule => + relations.contentHeading.clone() + .slots({ + attributes: {id: 'samples'}, + + title: + language.$(capsule, { + track: + html.tag('i', data.name), + }), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.sampledTracksList, + ]), + + language.encapsulate('releaseInfo.tracksThatReference', capsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'referenced-by'}, + + title: + language.$(capsule, { + track: html.tag('i', data.name), + }), + + stickyTitle: + language.$(capsule, 'sticky'), + }), + + relations.referencedByTracksList + .slots({ + headingString: capsule, + }), + ])), + + language.encapsulate('releaseInfo.tracksThatSample', capsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'sampled-by'}, + + title: + language.$(capsule, { + track: html.tag('i', data.name), + }), + + stickyTitle: + language.$(capsule, 'sticky'), + }), + + relations.sampledByTracksList + .slots({ + headingString: capsule, + }), + ])), + + html.tags([ + language.encapsulate('releaseInfo.flashesThatFeature', capsule => + relations.contentHeading.clone() + .slots({ + attributes: {id: 'featured-in'}, + + title: + language.$(capsule, { + track: html.tag('i', data.name), + }), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.flashesThatFeatureList, + ]), + + relations.lyricsSection, + + // html.tags([ + // relations.contentHeading.clone() + // .slots({ + // attributes: {id: 'lyrics'}, + // title: language.$('releaseInfo.lyrics'), + // }), + + // html.tag('blockquote', + // {[html.onlyIfContent]: true}, + // relations.lyrics.slot('mode', 'lyrics')), + // ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'sheet-music-files'}, + title: language.$('releaseInfo.sheetMusicFiles.heading'), + }), + + relations.sheetMusicFilesList, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'midi-project-files'}, + title: language.$('releaseInfo.midiProjectFiles.heading'), + }), + + relations.midiProjectFilesList, + ]), + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'additional-files'}, + title: language.$('releaseInfo.additionalFiles.heading'), + }), + + relations.additionalFilesList, + ]), + + relations.artistCommentarySection, + + html.tags([ + relations.contentHeading.clone() + .slots({ + attributes: {id: 'credit-sources'}, + title: language.$('misc.creditSources'), + }), + + relations.creditSourceEntries, + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: html.resolve(relations.navLinks), + + navBottomRowContent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + }), + + secondaryNav: + relations.secondaryNav + .slot('mode', 'track'), + + 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/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js new file mode 100644 index 00000000..61654512 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js @@ -0,0 +1,63 @@ +import {sortFlashesChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkFlash', 'linkTrack'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableFlashesAndGames: + wikiInfo.enableFlashesAndGames, + }), + + query: (sprawl, track) => ({ + sortedFeatures: + (sprawl.enableFlashesAndGames + ? sortFlashesChronologically( + track.allReleases.flatMap(track => + track.featuredInFlashes.map(flash => ({ + flash, + track, + + // These properties are only used for the sort. + act: flash.act, + date: flash.date, + })))) + : []), + }), + + relations: (relation, query, _sprawl, track) => ({ + flashLinks: + query.sortedFeatures + .map(({flash}) => relation('linkFlash', flash)), + + trackLinks: + query.sortedFeatures + .map(({track: directlyFeaturedTrack}) => + (directlyFeaturedTrack === track + ? null + : directlyFeaturedTrack.name === track.name + ? null + : relation('linkTrack', directlyFeaturedTrack))), + }), + + generate: (relations, {html, language}) => + html.tag('ul', + {[html.onlyIfContent]: true}, + + stitchArrays({ + flashLink: relations.flashLinks, + trackLink: relations.trackLinks, + }).map(({flashLink, trackLink}) => { + const attributes = html.attributes(); + const parts = ['releaseInfo.flashesThatFeature.item']; + const options = {flash: flashLink}; + + if (trackLink) { + parts.push('asDifferentRelease'); + options.track = trackLink; + } + + return html.tag('li', attributes, language.$(...parts, options)); + })), +}; diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js new file mode 100644 index 00000000..ebd76577 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js @@ -0,0 +1,42 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + trackLinks: + track.otherReleases + .map(track => relation('linkTrack', track)), + }), + + data: (track) => ({ + albumNames: + track.otherReleases + .map(track => track.album.name), + + albumColors: + track.otherReleases + .map(track => track.album.color), + }), + + generate: (data, relations, {html, language}) => + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$('releaseInfo.alsoReleasedOn', { + [language.onlyIfOptions]: ['albums'], + + albums: + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + albumColor: data.albumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))), + })), +}; diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js new file mode 100644 index 00000000..53a32536 --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,28 @@ +export default { + contentDependencies: ['generateTrackListItem'], + extraDependencies: ['html'], + + relations: (relation, tracks) => ({ + items: + tracks + .map(track => relation('generateTrackListItem', track, [])), + }), + + slots: { + colorMode: { + validate: v => v.is('none', 'track', 'line'), + default: 'track', + }, + }, + + generate: (relations, slots, {html}) => + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.items.map(item => + item.slots({ + showArtists: true, + showDuration: false, + colorMode: slots.colorMode, + }))), +}; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js new file mode 100644 index 00000000..230868d6 --- /dev/null +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -0,0 +1,145 @@ +import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateTrackList', + 'linkGroup', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + divideTrackListsByGroups: + wikiInfo.divideTrackListsByGroups, + }), + + query(sprawl, tracks) { + const dividingGroups = sprawl.divideTrackListsByGroups; + + const groupings = new Map(); + const ungroupedTracks = []; + + // Entry order matters! Add blank lists for each group + // in the order that those groups are provided. + for (const group of dividingGroups) { + groupings.set(group, []); + } + + for (const track of tracks) { + const firstMatchingGroup = + dividingGroups.find(group => group.albums.includes(track.album)); + + if (firstMatchingGroup) { + groupings.get(firstMatchingGroup).push(track); + } else { + ungroupedTracks.push(track); + } + } + + const groups = Array.from(groupings.keys()); + const groupedTracks = Array.from(groupings.values()); + + // Drop the empty lists, so just the groups which + // at least a single track matched are left. + filterMultipleArrays( + groups, + groupedTracks, + (_group, tracks) => !empty(tracks)); + + return {groups, groupedTracks, ungroupedTracks}; + }, + + relations: (relation, query, sprawl, tracks) => ({ + flatList: + (empty(sprawl.divideTrackListsByGroups) + ? relation('generateTrackList', tracks) + : null), + + contentHeading: + relation('generateContentHeading'), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + + groupedTrackLists: + query.groupedTracks + .map(tracks => relation('generateTrackList', tracks)), + + ungroupedTrackList: + (empty(query.ungroupedTracks) + ? null + : relation('generateTrackList', query.ungroupedTracks)), + }), + + data: (query, _sprawl, _tracks) => ({ + groupNames: + query.groups + .map(group => group.name), + }), + + slots: { + headingString: { + type: 'string', + }, + }, + + generate: (data, relations, slots, {html, language}) => + relations.flatList ?? + + html.tag('dl', + {[html.onlyIfContent]: true}, + + language.encapsulate('trackList', listCapsule => [ + stitchArrays({ + groupName: data.groupNames, + groupLink: relations.groupLinks, + trackList: relations.groupedTrackLists, + }).map(({ + groupName, + groupLink, + trackList, + }) => [ + language.encapsulate(listCapsule, 'fromGroup', capsule => + (slots.headingString + ? relations.contentHeading.clone().slots({ + tag: 'dt', + + title: + language.$(capsule, { + group: groupLink + }), + + stickyTitle: + language.$(slots.headingString, 'sticky', 'fromGroup', { + group: groupName, + }), + }) + : html.tag('dt', + language.$(capsule, { + group: groupLink + })))), + + html.tag('dd', trackList), + ]), + + relations.ungroupedTrackList && [ + language.encapsulate(listCapsule, 'fromOther', capsule => + (slots.headingString + ? relations.contentHeading.clone().slots({ + tag: 'dt', + + title: + language.$(capsule), + + stickyTitle: + language.$(slots.headingString, 'sticky', 'fromOther'), + }) + : html.tag('dt', + language.$(capsule)))), + + html.tag('dd', relations.ungroupedTrackList), + ], + ])), +}; diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js new file mode 100644 index 00000000..887b6f03 --- /dev/null +++ b/src/content/dependencies/generateTrackListItem.js @@ -0,0 +1,106 @@ +export default { + contentDependencies: [ + 'generateArtistCredit', + 'generateColorStyleAttribute', + 'generateTrackListMissingDuration', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, track, contextContributions) => ({ + trackLink: + relation('linkTrack', track), + + credit: + relation('generateArtistCredit', + track.artistContribs, + contextContributions), + + colorStyle: + relation('generateColorStyleAttribute', track.color), + + missingDuration: + (track.duration + ? null + : relation('generateTrackListMissingDuration')), + }), + + data: (track, _contextContributions) => ({ + duration: + track.duration ?? 0, + + trackHasDuration: + !!track.duration, + }), + + slots: { + // showArtists enables showing artists *at all.* It doesn't take precedence + // over behavior which automatically collapses (certain) artists because of + // provided context contributions. + showArtists: { + type: 'boolean', + default: true, + }, + + // If true and the track doesn't have a duration, a missing-duration cue + // will be displayed instead. + showDuration: { + type: 'boolean', + default: false, + }, + + colorMode: { + validate: v => v.is('none', 'track', 'line'), + default: 'track', + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('trackList.item', itemCapsule => + html.tag('li', + slots.colorMode === 'line' && + relations.colorStyle.slot('context', 'primary-only'), + + language.encapsulate(itemCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.track = + relations.trackLink + .slot('color', slots.colorMode === 'track'); + + if (slots.showDuration) { + workingCapsule += '.withDuration'; + workingOptions.duration = + (data.trackHasDuration + ? language.$(itemCapsule, 'withDuration.duration', { + duration: + language.formatDuration(data.duration), + }) + : relations.missingDuration); + } + + const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); + + relations.credit.setSlots({ + normalStringKey: + artistCapsule + '.by', + + featuringStringKey: + artistCapsule + '.featuring', + + normalFeaturingStringKey: + artistCapsule + '.by.featuring', + }); + + if (!html.isBlank(relations.credit)) { + workingCapsule += '.withArtists'; + workingOptions.by = + html.tag('span', {class: 'by'}, + html.metatag('chunkwrap', {split: ','}, + html.resolve(relations.credit))); + } + + return language.$(workingCapsule, workingOptions); + }))), +}; diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js new file mode 100644 index 00000000..b5917982 --- /dev/null +++ b/src/content/dependencies/generateTrackListMissingDuration.js @@ -0,0 +1,35 @@ +export default { + contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + }), + + generate: (relations, {html, language}) => + language.encapsulate('trackList.item.withDuration', itemCapsule => + language.encapsulate(itemCapsule, 'duration', durationCapsule => + relations.textWithTooltip.slots({ + attributes: {class: 'missing-duration'}, + customInteractionCue: true, + + text: + language.$(durationCapsule, { + duration: + html.tag('span', {class: 'text-with-tooltip-interaction-cue'}, + language.$(durationCapsule, 'missing')), + }), + + tooltip: + relations.tooltip.slots({ + attributes: {class: 'missing-duration-tooltip'}, + + content: + language.$(durationCapsule, 'missing.info'), + }), + }))), +}; diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js new file mode 100644 index 00000000..6a8b7c64 --- /dev/null +++ b/src/content/dependencies/generateTrackNavLinks.js @@ -0,0 +1,64 @@ +export default { + contentDependencies: ['linkAlbum', 'linkTrack'], + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + albumLink: + relation('linkAlbum', track.album), + + trackLink: + relation('linkTrack', track), + }), + + data: (track) => ({ + hasTrackNumbers: + track.album.hasTrackNumbers, + + trackNumber: + track.trackNumber, + }), + + slots: { + currentExtra: { + validate: v => v.is('referenced-art', 'referencing-art'), + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('trackPage.nav', navCapsule => [ + {auto: 'home'}, + + {html: relations.albumLink.slot('color', false)}, + + { + html: + language.encapsulate(navCapsule, 'track', workingCapsule => { + const workingOptions = {}; + + workingOptions.track = + relations.trackLink + .slot('attributes', {class: 'current'}); + + if (data.hasTrackNumbers) { + workingCapsule += '.withNumber'; + workingOptions.number = data.trackNumber; + } + + return language.$(workingCapsule, workingOptions); + }), + + accent: + html.tag('a', + {[html.onlyIfContent]: true}, + + {href: ''}, + {class: 'current'}, + + (slots.currentExtra === 'referenced-art' + ? language.$('referencedArtworksPage.subtitle') + : slots.currentExtra === 'referencing-art' + ? language.$('referencingArtworksPage.subtitle') + : null)), + }, + ]), +}; diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js new file mode 100644 index 00000000..93438c5b --- /dev/null +++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js @@ -0,0 +1,47 @@ +export default { + contentDependencies: [ + 'generateAlbumStyleRules', + 'generateBackToTrackLink', + 'generateReferencedArtworksPage', + 'generateTrackNavLinks', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + page: + relation('generateReferencedArtworksPage', track.trackArtworks[0]), + + albumStyleRules: + relation('generateAlbumStyleRules', track.album, track), + + navLinks: + relation('generateTrackNavLinks', track), + + backToTrackLink: + relation('generateBackToTrackLink', track), + }), + + data: (track) => ({ + name: + track.name, + }), + + generate: (data, relations, {html, language}) => + relations.page.slots({ + title: + language.$('trackPage.title', { + track: + data.name, + }), + + styleRules: [relations.albumStyleRules], + + navLinks: + html.resolve( + relations.navLinks + .slot('currentExtra', 'referenced-art')), + + navBottomRowContent: relations.backToTrackLink, + }), +}; diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js new file mode 100644 index 00000000..e9818bad --- /dev/null +++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js @@ -0,0 +1,47 @@ +export default { + contentDependencies: [ + 'generateAlbumStyleRules', + 'generateBackToTrackLink', + 'generateReferencingArtworksPage', + 'generateTrackNavLinks', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + page: + relation('generateReferencingArtworksPage', track.trackArtworks[0]), + + albumStyleRules: + relation('generateAlbumStyleRules', track.album, track), + + navLinks: + relation('generateTrackNavLinks', track), + + backToTrackLink: + relation('generateBackToTrackLink', track), + }), + + data: (track) => ({ + name: + track.name, + }), + + generate: (data, relations, {html, language}) => + relations.page.slots({ + title: + language.$('trackPage.title', { + track: + data.name, + }), + + styleRules: [relations.albumStyleRules], + + navLinks: + html.resolve( + relations.navLinks + .slot('currentExtra', 'referencing-art')), + + navBottomRowContent: relations.backToTrackLink, + }), +}; diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js new file mode 100644 index 00000000..ef02e2b9 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseBox.js @@ -0,0 +1,46 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generatePageSidebarBox', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + box: + relation('generatePageSidebarBox'), + + colorStyle: + relation('generateColorStyleAttribute', track.album.color), + + trackLink: + relation('linkTrack', track), + }), + + data: (track) => ({ + albumName: + track.album.name, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('albumSidebar.releaseBox', boxCapsule => + relations.box.slots({ + attributes: [ + {class: 'track-release-sidebar-box'}, + relations.colorStyle, + ], + + content: [ + html.tag('h1', + language.$(boxCapsule, 'title', { + album: + relations.trackLink.slots({ + color: false, + content: + language.sanitize(data.albumName), + }), + })), + ], + })), +}; diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js new file mode 100644 index 00000000..54e462c7 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -0,0 +1,82 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateReleaseInfoContributionsLine', + 'linkExternal', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, track) { + const relations = {}; + + relations.artistContributionLinks = + relation('generateReleaseInfoContributionsLine', track.artistContribs); + + 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.date + ) { + data.coverArtDate = track.coverArtDate; + } + + return data; + }, + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo', capsule => + html.tags([ + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + relations.artistContributionLinks.slots({ + stringKey: capsule + '.by', + featuringStringKey: capsule + '.by.featuring', + chronologyKind: 'track', + }), + + language.$(capsule, 'released', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.date), + }), + + language.$(capsule, 'duration', { + [language.onlyIfOptions]: ['duration'], + duration: language.formatDuration(data.duration), + }), + ]), + + html.tag('p', + language.encapsulate(capsule, 'listenOn', capsule => + (relations.externalLinks + ? language.$(capsule, { + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'track'))), + }) + : language.$(capsule, 'noLinks', { + name: + html.tag('i', data.name), + })))), + ])), +}; diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js new file mode 100644 index 00000000..7cb37af2 --- /dev/null +++ b/src/content/dependencies/generateTrackSocialEmbed.js @@ -0,0 +1,68 @@ +export default { + contentDependencies: [ + 'generateSocialEmbed', + 'generateTrackSocialEmbedDescription', + ], + + extraDependencies: ['absoluteTo', 'language'], + + 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}) => + language.encapsulate('trackPage.socialEmbed', embedCapsule => + relations.socialEmbed.slots({ + title: + language.$(embedCapsule, 'title', { + track: data.trackName, + }), + + description: + relations.description, + + headingContent: + language.$(embedCapsule, 'heading', { + album: data.albumName, + }), + + headingLink: + absoluteTo('localized.album', data.albumDirectory), + + imagePath: + (data.imageSource === 'album' + ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension] + : data.imageSource === 'track' + ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension] + : null), + })), +}; diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js new file mode 100644 index 00000000..4706aa26 --- /dev/null +++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js @@ -0,0 +1,39 @@ +import {empty} from '#sugar'; + +export default { + extraDependencies: ['html', 'language'], + + data: (track) => ({ + artistNames: + track.artistContribs + .map(contrib => contrib.artist.name), + + coverArtistNames: + track.coverArtistContribs + .map(contrib => contrib.artist.name), + }), + + generate: (data, {html, language}) => + language.encapsulate('trackPage.socialEmbed.body', baseCapsule => + language.encapsulate(baseCapsule, workingCapsule => { + const workingOptions = {}; + + if (!empty(data.artistNames)) { + workingCapsule += '.withArtists'; + workingOptions.artists = + language.formatConjunctionList(data.artistNames); + } + + if (!empty(data.coverArtistNames)) { + workingCapsule += '.withCoverArtists'; + workingOptions.coverArtists = + language.formatConjunctionList(data.coverArtistNames); + } + + if (workingCapsule === baseCapsule) { + return html.blank(); + } else { + return language.$(workingCapsule, workingOptions); + } + })), +}; diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js new file mode 100644 index 00000000..c11aadc7 --- /dev/null +++ b/src/content/dependencies/generateUnsafeMunchy.js @@ -0,0 +1,10 @@ +export default { + extraDependencies: ['html'], + + slots: { + contentSource: {type: 'string'}, + }, + + generate: (slots, {html}) => + new html.Tag(null, null, slots.contentSource), +}; diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js new file mode 100644 index 00000000..9f501099 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageActionsRow.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateGridActionLinks', 'transformContent'], + + relations: (relation, row) => ({ + template: + relation('generateGridActionLinks'), + + links: + row.actionLinks + .map(content => relation('transformContent', content)), + }), + + generate: (relations) => + relations.template.slots({ + actionLinks: + relations.links + .map(contents => + contents + .slot('mode', 'single-link') + .content), + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js new file mode 100644 index 00000000..b45bfc19 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'], + + relations: (relation, row) => ({ + coverCarousel: + relation('generateCoverCarousel'), + + links: + row.albums + .map(album => relation('linkAlbum', album)), + + images: + row.albums + .map(album => relation('image', album.coverArtworks[0])), + }), + + generate: (relations) => + relations.coverCarousel.slots({ + links: relations.links, + images: relations.images, + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js new file mode 100644 index 00000000..a00136ba --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js @@ -0,0 +1,78 @@ +import {empty, stitchArrays} from '#sugar'; +import {getNewAdditions, getNewReleases} from '#wiki-data'; + +export default { + contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}, row) { + const sprawl = {}; + + 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) => ({ + coverGrid: + relation('generateCoverGrid'), + + links: + sprawl.albums + .map(album => relation('linkAlbum', album)), + + images: + sprawl.albums + .map(album => + relation('image', + (album.hasCoverArt + ? album.coverArtworks[0] + : null))), + }), + + data: (sprawl, _row) => ({ + names: + sprawl.albums + .map(album => album.name), + }), + + generate: (data, relations, {language}) => + relations.coverGrid.slots({ + links: relations.links, + names: data.names, + + images: + stitchArrays({ + image: relations.images, + name: data.names, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$('misc.coverGrid.noCoverArt', { + album: name, + }), + })), + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js new file mode 100644 index 00000000..83a27695 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageNewsBox.js @@ -0,0 +1,86 @@ +import {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}) => + language.encapsulate('homepage.news', boxCapsule => + relations.box.slots({ + attributes: {class: 'latest-news-sidebar-box'}, + collapsible: false, + + content: [ + html.tag('h1', + {[html.onlyIfSiblings]: true}, + language.$(boxCapsule, 'title')), + + stitchArrays({ + date: data.entryDates, + content: relations.entryContents, + mainLink: relations.entryMainLinks, + readMoreLink: relations.entryReadMoreLinks, + }).map(({ + date, + content, + mainLink, + readMoreLink, + }, index) => + language.encapsulate(boxCapsule, 'entry', entryCapsule => + 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.$(entryCapsule, 'viewRest'), + })), + ]))), + ], + })), +}; diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js new file mode 100644 index 00000000..8c09a007 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepagePage.js @@ -0,0 +1,97 @@ +export default { + contentDependencies: [ + 'generatePageLayout', + 'generatePageSidebar', + 'generatePageSidebarBox', + 'generateWikiHomepageNewsBox', + 'generateWikiHomepageSection', + 'transformContent', + ], + + extraDependencies: ['wikiData'], + + sprawl: ({wikiInfo}) => ({ + wikiName: + wikiInfo.name, + + enableNews: + wikiInfo.enableNews, + }), + + relations: (relation, sprawl, homepageLayout) => ({ + layout: + relation('generatePageLayout'), + + sidebar: + relation('generatePageSidebar'), + + customSidebarBox: + relation('generatePageSidebarBox'), + + customSidebarContent: + relation('transformContent', homepageLayout.sidebarContent), + + newsSidebarBox: + (sprawl.enableNews + ? relation('generateWikiHomepageNewsBox') + : null), + + customNavLinkContents: + homepageLayout.navbarLinks + .map(content => relation('transformContent', content)), + + sections: + homepageLayout.sections + .map(section => relation('generateWikiHomepageSection', section)), + }), + + data: (sprawl) => ({ + wikiName: + sprawl.wikiName, + }), + + generate: (data, relations) => + relations.layout.slots({ + title: data.wikiName, + showWikiNameInTitle: false, + + mainClasses: ['top-index'], + headingMode: 'static', + + mainContent: [ + relations.sections, + ], + + leftSidebar: + relations.sidebar.slots({ + wide: true, + + boxes: [ + relations.customSidebarBox.slots({ + attributes: {class: 'custom-content-sidebar-box'}, + collapsible: false, + + 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/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js new file mode 100644 index 00000000..49a474da --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageSection.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateWikiHomepageActionsRow', + 'generateWikiHomepageAlbumCarouselRow', + 'generateWikiHomepageAlbumGridRow', + ], + + extraDependencies: ['html'], + + relations: (relation, homepageSection) => ({ + colorStyle: + relation('generateColorStyleAttribute', homepageSection.color), + + rows: + homepageSection.rows.map(row => + (row.type === 'actions' + ? relation('generateWikiHomepageActionsRow', row) + : row.type === 'album carousel' + ? relation('generateWikiHomepageAlbumCarouselRow', row) + : row.type === 'album grid' + ? relation('generateWikiHomepageAlbumGridRow', row) + : null)), + }), + + data: (homepageSection) => ({ + name: + homepageSection.name, + }), + + generate: (data, relations, {html}) => + html.tag('section', + relations.colorStyle, + + [ + html.tag('h2', data.name), + relations.rows, + ]), +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js new file mode 100644 index 00000000..bf47b14f --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,374 @@ +import {logWarn} from '#cli'; +import {empty} from '#sugar'; + +export default { + extraDependencies: [ + 'checkIfImagePathHasCachedThumbnails', + 'getDimensionsOfImagePath', + 'getSizeOfMediaFile', + 'getThumbnailEqualOrSmaller', + 'getThumbnailsAvailableForDimensions', + 'html', + 'language', + 'missingImagePaths', + 'to', + ], + + contentDependencies: ['generateColorStyleAttribute'], + + relations: (relation, _artwork) => ({ + colorStyle: + relation('generateColorStyleAttribute'), + }), + + data: (artwork) => ({ + path: + (artwork + ? artwork.path + : null), + + warnings: + (artwork + ? artwork.artTags + .filter(artTag => artTag.isContentWarning) + .map(artTag => artTag.name) + : null), + + dimensions: + (artwork + ? artwork.dimensions + : null), + }), + + slots: { + thumb: {type: 'string'}, + + reveal: {type: 'boolean', default: true}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + + link: { + validate: v => v.anyOf(v.isBoolean, v.isString), + default: false, + }, + + color: {validate: v => v.isColor}, + + // Added to the .image-container. + attributes: { + type: 'attributes', + mutable: false, + }, + + // Added to the <img> itself. + alt: {type: 'string'}, + + // Specify 'src' or 'path', or the path will be used from the artwork. + // If none of the above is present, the message in missingSourceContent + // will be displayed instead. + + src: {type: 'string'}, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + missingSourceContent: { + type: 'html', + mutable: false, + }, + + // These will also be used from the artwork if not specified as slots. + + warnings: { + validate: v => v.looseArrayOf(v.isString), + }, + + dimensions: { + validate: v => v.isDimensions, + }, + }, + + generate(data, relations, slots, { + checkIfImagePathHasCachedThumbnails, + getDimensionsOfImagePath, + getSizeOfMediaFile, + getThumbnailEqualOrSmaller, + getThumbnailsAvailableForDimensions, + html, + language, + missingImagePaths, + to, + }) { + const originalSrc = + (slots.src + ? slots.src + : slots.path + ? to(...slots.path) + : data.path + ? to(...data.path) + : ''); + + // 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); + + const willLink = + !isMissingImageFile && + (typeof slots.link === 'string' || slots.link); + + const warnings = slots.warnings ?? data.warnings; + const dimensions = slots.dimensions ?? data.dimensions; + + const willReveal = + slots.reveal && + originalSrc && + !isMissingImageFile && + !empty(warnings); + + const imgAttributes = html.attributes([ + {class: 'image'}, + + slots.alt && {alt: slots.alt}, + + dimensions && + dimensions[0] && + {width: dimensions[0]}, + + dimensions && + dimensions[1] && + {height: 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('staticMisc.path', 'warning.svg')}), + + html.tag('br'), + + html.tag('span', {class: 'reveal-warnings'}, + language.$('misc.contentWarnings.warnings', { + warnings: language.formatUnitList(warnings), + })), + + 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 fileSize = + (willLink && mediaSrc + ? getSizeOfMediaFile(mediaSrc) + : null); + + imgAttributes.add([ + fileSize && + {'data-original-size': fileSize}, + + {'data-dimensions': originalDimensions.join('x')}, + + !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'}, + slots.square && + {class: 'square-content'}, + + wrapped); + + wrapped = + html.tag('div', {class: 'image-container'}, + slots.square && + {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 00000000..a5009804 --- /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 00000000..36b0d13a --- /dev/null +++ b/src/content/dependencies/linkAlbum.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.album', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js new file mode 100644 index 00000000..39e7111e --- /dev/null +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -0,0 +1,24 @@ +export default { + contentDependencies: ['linkTemplate'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(album, file) { + return { + albumDirectory: album.directory, + file, + }; + }, + + generate(data, relations) { + return relations.linkTemplate + .slots({ + path: ['media.albumAdditionalFile', data.albumDirectory, data.file], + content: data.file, + }); + }, +}; diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js new file mode 100644 index 00000000..ab519fd6 --- /dev/null +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumCommentary', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js new file mode 100644 index 00000000..45f8c2a9 --- /dev/null +++ b/src/content/dependencies/linkAlbumDynamically.js @@ -0,0 +1,61 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'linkAlbumCommentary', + 'linkAlbumGallery', + 'linkAlbum', + ], + + extraDependencies: ['html', 'pagePath'], + + relations: (relation, album) => ({ + galleryLink: + relation('linkAlbumGallery', album), + + infoLink: + relation('linkAlbum', album), + + commentaryLink: + relation('linkAlbumCommentary', album), + }), + + data: (album) => ({ + albumDirectory: + album.directory, + + albumHasCommentary: + !empty(album.commentary), + }), + + slots: { + linkCommentaryPages: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {pagePath}) => + // When linking to an album *from* an album commentary page, + // if the link is to the *same* album, then the effective target + // of the link is really the album's commentary, so scroll to it. + (pagePath[0] === 'albumCommentary' && + pagePath[1] === data.albumDirectory && + data.albumHasCommentary + ? relations.infoLink.slots({ + anchor: true, + hash: 'album-commentary', + }) + + // When linking to *another* album from an album commentary page, + // the target is (by default) still just the album (its info page). + // But this can be customized per-link! + : pagePath[0] === 'albumCommentary' && + slots.linkCommentaryPages + ? relations.commentaryLink + + : 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 00000000..e3f30a29 --- /dev/null +++ b/src/content/dependencies/linkAlbumGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumGallery', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js new file mode 100644 index 00000000..ba51b5e3 --- /dev/null +++ b/src/content/dependencies/linkAlbumReferencedArtworks.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js new file mode 100644 index 00000000..4d5e799d --- /dev/null +++ b/src/content/dependencies/linkAlbumReferencingArtworks.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js new file mode 100644 index 00000000..e408c1b2 --- /dev/null +++ b/src/content/dependencies/linkAnythingMan.js @@ -0,0 +1,28 @@ +export default { + contentDependencies: [ + 'linkAlbum', + 'linkArtwork', + 'linkFlash', + 'linkTrack', + ], + + query: (thing) => ({ + referenceType: thing.constructor[Symbol.for('Thing.referenceType')], + }), + + relations: (relation, query, thing) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbum', thing) + : query.referenceType === 'artwork' + ? relation('linkArtwork', thing) + : query.referenceType === 'flash' + ? relation('linkFlash', thing) + : query.referenceType === 'track' + ? relation('linkTrack', thing) + : null), + }), + + generate: (relations) => + relations.link, +}; diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js new file mode 100644 index 00000000..964258e1 --- /dev/null +++ b/src/content/dependencies/linkArtTagDynamically.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'], + extraDependencies: ['pagePath'], + + relations: (relation, artTag) => ({ + galleryLink: relation('linkArtTagGallery', artTag), + infoLink: relation('linkArtTagInfo', artTag), + }), + + generate: (relations, {pagePath}) => + (pagePath[0] === 'artTagInfo' + ? relations.infoLink + : relations.galleryLink), +}; diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js new file mode 100644 index 00000000..a92b69c1 --- /dev/null +++ b/src/content/dependencies/linkArtTagGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.artTagGallery', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTagInfo.js b/src/content/dependencies/linkArtTagInfo.js new file mode 100644 index 00000000..409cb3c0 --- /dev/null +++ b/src/content/dependencies/linkArtTagInfo.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.artTagInfo', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js new file mode 100644 index 00000000..718ee6fa --- /dev/null +++ b/src/content/dependencies/linkArtist.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artist', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js new file mode 100644 index 00000000..66dc172d --- /dev/null +++ b/src/content/dependencies/linkArtistGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistGallery', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js new file mode 100644 index 00000000..8cd6f359 --- /dev/null +++ b/src/content/dependencies/linkArtwork.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['linkAlbum', 'linkTrack'], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Symbol.for('Thing.referenceType')], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbum', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrack', artwork.thing) + : null), + }), + + generate: (relations) => + relations.link, +}; diff --git a/src/content/dependencies/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js new file mode 100644 index 00000000..5568ff84 --- /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 00000000..c658d461 --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,85 @@ +export default { + contentDependencies: [ + 'generateContributionTooltip', + 'generateTextWithTooltip', + 'linkArtist', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, contribution) => ({ + artistLink: + relation('linkArtist', contribution.artist), + + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateContributionTooltip', contribution), + }), + + data: (contribution) => ({ + annotation: contribution.annotation, + urls: contribution.artist.urls, + }), + + slots: { + showAnnotation: {type: 'boolean', default: false}, + showExternalLinks: {type: 'boolean', default: false}, + showChronology: {type: 'boolean', default: false}, + + trimAnnotation: {type: 'boolean', default: false}, + + preventWrapping: {type: 'boolean', default: true}, + preventTooltip: {type: 'boolean', default: false}, + + chronologyKind: {type: 'string'}, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('span', {class: 'contribution'}, + {[html.noEdgeWhitespace]: true}, + + slots.preventWrapping && + {class: 'nowrap'}, + + language.encapsulate('misc.artistLink', workingCapsule => { + const workingOptions = {}; + + // Filling slots early is necessary to actually give the tooltip + // content. Otherwise, the coming-up html.isBlank() always reports + // the tooltip as blank! + relations.tooltip.setSlots({ + showExternalLinks: slots.showExternalLinks, + showChronology: slots.showChronology, + chronologyKind: slots.chronologyKind, + }); + + workingOptions.artist = + (html.isBlank(relations.tooltip) || slots.preventTooltip + ? relations.artistLink + : relations.textWithTooltip.slots({ + customInteractionCue: true, + + text: + relations.artistLink.slots({ + attributes: {class: 'text-with-tooltip-interaction-cue'}, + }), + + tooltip: + relations.tooltip, + })); + + const annotation = + (slots.trimAnnotation + ? data.annotation?.replace(/^edits for wiki(: )?/, '') + : data.annotation); + + if (slots.showAnnotation && annotation) { + workingCapsule += '.withContribution'; + workingOptions.contrib = annotation; + } + + return language.formatString(workingCapsule, workingOptions); + })), +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 00000000..073c821e --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,151 @@ +import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; + +export default { + extraDependencies: ['html', 'language', 'wikiData'], + + data: (url) => ({url}), + + slots: { + content: { + type: 'html', + mutable: false, + }, + + suffixNormalContent: { + 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'); + } + + if (!html.isBlank(slots.suffixNormalContent)) { + linkContent = + html.tags([ + linkContent, + + html.tag('span', {class: 'normal-content'}, + slots.suffixNormalContent), + ], {[html.joinChildren]: ''}); + } + + return html.tag('a', linkAttributes, linkContent); + }, +}; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js new file mode 100644 index 00000000..93dd5a28 --- /dev/null +++ b/src/content/dependencies/linkFlash.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, flash) => + ({link: relation('linkThing', 'localized.flash', flash)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js new file mode 100644 index 00000000..82c23325 --- /dev/null +++ b/src/content/dependencies/linkFlashAct.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateUnsafeMunchy', 'linkThing'], + + relations: (relation, flashAct) => ({ + unsafeMunchy: + relation('generateUnsafeMunchy'), + + link: + relation('linkThing', 'localized.flashActGallery', flashAct), + }), + + data: (flashAct) => ({ + name: flashAct.name, + }), + + generate: (data, relations) => + relations.link.slots({ + content: + relations.unsafeMunchy + .slot('contentSource', data.name), + }), +}; diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js new file mode 100644 index 00000000..6dd0710e --- /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/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js new file mode 100644 index 00000000..b77ca65a --- /dev/null +++ b/src/content/dependencies/linkFlashSide.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['linkFlashAct'], + + relations: (relation, flashSide) => ({ + link: + relation('linkFlashAct', flashSide.acts[0]), + }), + + data: (flashSide) => ({ + name: + flashSide.name, + + color: + flashSide.color, + }), + + generate: (data, relations) => + relations.link.slots({ + content: data.name, + color: data.color, + }), +}; diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js new file mode 100644 index 00000000..ebab1b5b --- /dev/null +++ b/src/content/dependencies/linkGroup.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupInfo', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js new file mode 100644 index 00000000..90303ed1 --- /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 00000000..bc3c0580 --- /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 00000000..86c4a0f3 --- /dev/null +++ b/src/content/dependencies/linkGroupGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupGallery', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js new file mode 100644 index 00000000..ac66919a --- /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 00000000..1bfaf46e --- /dev/null +++ b/src/content/dependencies/linkListingIndex.js @@ -0,0 +1,12 @@ +export default { + contentDependencies: ['linkStationaryIndex'], + + relations: (relation) => + ({link: + relation( + 'linkStationaryIndex', + 'localized.listingIndex', + 'listingIndex.title')}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js new file mode 100644 index 00000000..1fb32dd9 --- /dev/null +++ b/src/content/dependencies/linkNewsEntry.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, newsEntry) => + ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js new file mode 100644 index 00000000..e911a384 --- /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/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js new file mode 100644 index 00000000..ec856631 --- /dev/null +++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js @@ -0,0 +1,62 @@ +import {sortAlbumsTracksChronologically, sortContributionsChronologically} + from '#sort'; +import {chunkArtistTrackContributions} from '#wiki-data'; + +export default { + contentDependencies: ['generateColorStyleAttribute'], + extraDependencies: ['html', 'language'], + + query(track, artist) { + const relevantInfoPageChunkingContributions = + track.allReleases + .flatMap(release => [ + ...release.artistContribs, + ...release.contributorContribs, + ]) + .filter(c => c.artist === artist); + + sortContributionsChronologically( + relevantInfoPageChunkingContributions, + sortAlbumsTracksChronologically); + + const contributionChunks = + chunkArtistTrackContributions(relevantInfoPageChunkingContributions); + + const trackChunks = + contributionChunks + .map(chunksInAlbum => chunksInAlbum + .map(chunksInTrack => chunksInTrack[0].thing)); + + const trackChunksForThisAlbum = + trackChunks + .filter(tracks => tracks[0].album === track.album); + + const containingChunkIndex = + trackChunksForThisAlbum + .findIndex(tracks => tracks.includes(track)); + + return {containingChunkIndex}; + }, + + relations: (relation, _query, track, _artist) => ({ + colorStyle: + relation('generateColorStyleAttribute', track.album.color), + }), + + data: (query, track, _artist) => ({ + albumName: + track.album.name, + + albumDirectory: + track.album.directory, + + containingChunkIndex: + query.containingChunkIndex, + }), + + generate: (data, relations, {html, language}) => + html.tag('a', + {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`}, + relations.colorStyle.slot('context', 'primary-only'), + language.sanitize(data.albumName)), +}; diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js new file mode 100644 index 00000000..d71c69f8 --- /dev/null +++ b/src/content/dependencies/linkPathFromMedia.js @@ -0,0 +1,64 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['linkTemplate'], + + extraDependencies: [ + 'checkIfImagePathHasCachedThumbnails', + 'getDimensionsOfImagePath', + 'getSizeOfMediaFile', + 'getThumbnailsAvailableForDimensions', + 'html', + 'to', + ], + + relations: (relation) => + ({link: relation('linkTemplate')}), + + data: (path) => + ({path}), + + generate(data, relations, { + checkIfImagePathHasCachedThumbnails, + getDimensionsOfImagePath, + getSizeOfMediaFile, + getThumbnailsAvailableForDimensions, + html, + to, + }) { + const attributes = html.attributes(); + + if (checkIfImagePathHasCachedThumbnails(data.path)) { + const dimensions = getDimensionsOfImagePath(data.path); + const availableThumbs = getThumbnailsAvailableForDimensions(dimensions); + const fileSize = getSizeOfMediaFile(data.path); + + const embedSrc = + to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg')); + + attributes.add([ + {class: 'image-media-link'}, + + {'data-embed-src': embedSrc}, + + fileSize && + {'data-original-size': fileSize}, + + {'data-dimensions': dimensions.join('x')}, + + !empty(availableThumbs) && + {'data-thumbs': + availableThumbs + .map(([name, size]) => `${name}:${size}`) + .join(' ')}, + ]); + } + + relations.link.setSlots({ + attributes, + path: ['media.path', data.path], + }); + + return relations.link; + }, +}; diff --git a/src/content/dependencies/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js new file mode 100644 index 00000000..dab3ac1f --- /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 00000000..64676465 --- /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/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js new file mode 100644 index 00000000..c456b808 --- /dev/null +++ b/src/content/dependencies/linkReferencedArtworks.js @@ -0,0 +1,24 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'linkAlbumReferencedArtworks', + 'linkTrackReferencedArtworks', + ], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Thing.referenceType], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbumReferencedArtworks', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrackReferencedArtworks', artwork.thing) + : null), + }), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js new file mode 100644 index 00000000..0cfca4db --- /dev/null +++ b/src/content/dependencies/linkReferencingArtworks.js @@ -0,0 +1,24 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'linkAlbumReferencingArtworks', + 'linkTrackReferencingArtworks', + ], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Thing.referenceType], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbumReferencingArtworks', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrackReferencingArtworks', artwork.thing) + : null), + }), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js new file mode 100644 index 00000000..032af6c9 --- /dev/null +++ b/src/content/dependencies/linkStaticPage.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, staticPage) => + ({link: relation('linkThing', 'localized.staticPage', staticPage)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js new file mode 100644 index 00000000..d5506e60 --- /dev/null +++ b/src/content/dependencies/linkStationaryIndex.js @@ -0,0 +1,24 @@ +// Not to be confused with "html.Stationery". + +export default { + contentDependencies: ['linkTemplate'], + extraDependencies: ['language'], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, stringKey) { + return {pathKey, stringKey}; + }, + + generate(data, relations, {language}) { + return relations.linkTemplate + .slots({ + path: [data.pathKey], + content: language.formatString(data.stringKey), + }); + } +} diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js new file mode 100644 index 00000000..4f853dc4 --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,87 @@ +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, + }, + + suffixNormalContent: { + 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 mainContent = + (html.isBlank(slots.content) + ? language.$('misc.missingLinkContent') + : striptags( + html.resolve(slots.content, {normalize: 'string'}), + {disallowedTags: new Set(['a'])})); + + const allContent = + (html.isBlank(slots.suffixNormalContent) + ? mainContent + : html.tags([ + mainContent, + html.tag('span', {class: 'normal-content'}, + slots.suffixNormalContent), + ], {[html.joinChildren]: ''})); + + return html.tag('a', attributes, allContent); + }, +} diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js new file mode 100644 index 00000000..3902f380 --- /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 00000000..d5d96726 --- /dev/null +++ b/src/content/dependencies/linkTrack.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.track', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js new file mode 100644 index 00000000..bbcf1c34 --- /dev/null +++ b/src/content/dependencies/linkTrackDynamically.js @@ -0,0 +1,36 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['pagePath'], + + relations: (relation, track) => ({ + infoLink: relation('linkTrack', track), + }), + + data: (track) => ({ + trackDirectory: + track.directory, + + albumDirectory: + track.album.directory, + + trackHasCommentary: + !empty(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/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js new file mode 100644 index 00000000..b4cb08fe --- /dev/null +++ b/src/content/dependencies/linkTrackReferencedArtworks.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js new file mode 100644 index 00000000..c9c9f4d1 --- /dev/null +++ b/src/content/dependencies/linkTrackReferencingArtworks.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkWikiHomepage.js b/src/content/dependencies/linkWikiHomepage.js new file mode 100644 index 00000000..d8d3d0a0 --- /dev/null +++ b/src/content/dependencies/linkWikiHomepage.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 00000000..c83ffc97 --- /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 00000000..d462ad46 --- /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 00000000..c60685ab --- /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 00000000..21419537 --- /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 00000000..798e6c2e --- /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 00000000..a6e34b9a --- /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 00000000..e33ad7b5 --- /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 00000000..31a70ef0 --- /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 00000000..166b2068 --- /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 00000000..93dd4ce8 --- /dev/null +++ b/src/content/dependencies/listArtTagNetwork.js @@ -0,0 +1,366 @@ +import {sortAlphabetically} from '#sort'; +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTagInfo'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artTagData}) { + return {artTagData}; + }, + + query(sprawl, spec) { + const artTags = + sprawl.artTagData.filter(artTag => !artTag.isContentWarning); + + const rootArtTags = + artTags + .filter(artTag => !empty(artTag.directDescendantArtTags)) + .filter(artTag => + empty(artTag.directAncestorArtTags) || + artTag.directAncestorArtTags.length >= 2); + + sortAlphabetically(rootArtTags); + + rootArtTags.sort( + ({directAncestorArtTags: ancestorsA}, + {directAncestorArtTags: ancestorsB}) => + ancestorsA.length - ancestorsB.length); + + const getStats = (artTag) => ({ + directUses: + artTag.directlyFeaturedInArtworks.length, + + // Not currently displayed + directAndIndirectUses: + unique([ + ...artTag.indirectlyFeaturedInArtworks, + ...artTag.directlyFeaturedInArtworks, + ]).length, + + totalUses: + [ + ...artTag.directlyFeaturedInArtworks, + ... + artTag.allDescendantArtTags + .flatMap(artTag => artTag.directlyFeaturedInArtworks), + ].length, + + descendants: + artTag.allDescendantArtTags.length, + + leaves: + (empty(artTag.directDescendantArtTags) + ? null + : artTag.allDescendantArtTags + .filter(artTag => empty(artTag.directDescendantArtTags)) + .length), + }); + + const recursive = (artTag, depth) => { + const descendantNodes = + (empty(artTag.directDescendantArtTags) + ? null + : depth > 0 && artTag.directAncestorArtTags.length >= 2 + ? null + : artTag.directDescendantArtTags + .map(artTag => recursive(artTag, depth + 1))); + + descendantNodes?.sort( + ({descendantNodes: descendantNodesA}, + {descendantNodes: descendantNodesB}) => + (descendantNodesA ? 1 : 0) + - (descendantNodesB ? 1 : 0)); + + const recursiveGetRootAncestor = ancestorArtTag => + (ancestorArtTag.directAncestorArtTags.length === 1 + ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0]) + : ancestorArtTag); + + const ancestorRootArtTags = + (depth === 0 && !empty(artTag.directAncestorArtTags) + ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor)) + : null); + + const stats = getStats(artTag); + + return { + artTag, + stats, + descendantNodes, + ancestorRootArtTags, + }; + }; + + const uppermostRootTags = + artTags + .filter(artTag => !empty(artTag.directDescendantArtTags)) + .filter(artTag => empty(artTag.directAncestorArtTags)); + + const orphanArtTags = + artTags + .filter(artTag => empty(artTag.directDescendantArtTags)) + .filter(artTag => empty(artTag.directAncestorArtTags)); + + return { + spec, + + rootNodes: + rootArtTags + .map(artTag => recursive(artTag, 0)), + + uppermostRootTags, + orphanArtTags, + }; + }, + + relations(relation, query) { + const recursive = queryNode => ({ + artTagLink: + relation('linkArtTagInfo', queryNode.artTag), + + ancestorTagLinks: + queryNode.ancestorRootArtTags + ?.map(artTag => relation('linkArtTagInfo', artTag)) + ?? null, + + descendantNodes: + queryNode.descendantNodes + ?.map(recursive) + ?? null, + }); + + return { + page: + relation('generateListingPage', query.spec), + + rootNodes: + query.rootNodes.map(recursive), + + uppermostRootTagLinks: + query.uppermostRootTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + orphanArtTagLinks: + query.orphanArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + }; + }, + + data(query) { + const rootArtTags = query.rootNodes.map(({artTag}) => artTag); + + const recursive = queryNode => ({ + directory: + queryNode.artTag.directory, + + directUses: + queryNode.stats.directUses, + + totalUses: + queryNode.stats.totalUses, + + descendants: + queryNode.stats.descendants, + + leaves: + queryNode.stats.leaves, + + representsRoot: + rootArtTags.includes(queryNode.artTag), + + ancestorTagDirectories: + queryNode.ancestorRootArtTags + ?.map(artTag => artTag.directory) + ?? null, + + descendantNodes: + queryNode.descendantNodes + ?.map(recursive) + ?? null, + }); + + return { + rootNodes: + query.rootNodes.map(recursive), + + uppermostRootTagDirectories: + query.uppermostRootTags + .map(artTag => artTag.directory), + }; + }, + + generate(data, relations, {html, language}) { + const prefix = `listingPage.listArtTags.network`; + + const wrapTagWithJumpTo = (dataNode, relationsNode, depth) => + (depth === 0 + ? relationsNode.artTagLink + : dataNode.representsRoot + ? language.$(prefix, 'tag.jumpToRoot', { + tag: + relationsNode.artTagLink.slots({ + anchor: true, + hash: dataNode.directory, + }), + }) + : relationsNode.artTagLink); + + const wrapTagWithStats = (dataNode, relationsNode, depth) => [ + html.tag('span', {class: 'network-tag'}, + language.$(prefix, 'tag', { + tag: + wrapTagWithJumpTo(dataNode, relationsNode, depth), + })), + + html.tag('span', {class: 'network-tag'}, + {class: 'with-stat'}, + {style: 'display: none'}, + + language.$(prefix, 'tag.withStat', { + tag: + wrapTagWithJumpTo(dataNode, relationsNode, depth), + + stat: + html.tag('span', {class: 'network-tag-stat'}, + language.$(prefix, 'tag.withStat.stat', { + stat: [ + html.tag('span', {class: 'network-tag-direct-uses-stat'}, + dataNode.directUses.toString()), + + html.tag('span', {class: 'network-tag-total-uses-stat'}, + dataNode.totalUses.toString()), + + html.tag('span', {class: 'network-tag-descendants-stat'}, + dataNode.descendants.toString()), + + html.tag('span', {class: 'network-tag-leaves-stat'}, + (dataNode.leaves === null + ? language.$(prefix, 'tag.withStat.notApplicable') + : dataNode.leaves.toString())), + ], + })), + })) + ]; + + const recursive = (dataNode, relationsNode, depth) => [ + html.tag('dt', + { + id: depth === 0 && dataNode.directory, + class: depth % 2 === 0 ? 'even' : 'odd', + }, + + (depth === 0 + ? (relationsNode.ancestorTagLinks + ? language.$(prefix, 'root.withAncestors', { + tag: + wrapTagWithStats(dataNode, relationsNode, depth), + + ancestors: + language.formatUnitList( + stitchArrays({ + link: relationsNode.ancestorTagLinks, + directory: dataNode.ancestorTagDirectories, + }).map(({link, directory}) => + link.slots({ + anchor: true, + hash: directory, + }))), + }) + : language.$(prefix, 'root.jumpToTop', { + tag: + wrapTagWithStats(dataNode, relationsNode, depth), + + link: + html.tag('a', {href: '#top'}, + language.$(prefix, 'root.jumpToTop.link')), + })) + : wrapTagWithStats(dataNode, relationsNode, depth))), + + dataNode.descendantNodes && + relationsNode.descendantNodes && + html.tag('dd', + {class: depth % 2 === 0 ? 'even' : 'odd'}, + html.tag('dl', + stitchArrays({ + dataNode: dataNode.descendantNodes, + relationsNode: relationsNode.descendantNodes, + }).map(({dataNode, relationsNode}) => + recursive(dataNode, relationsNode, depth + 1)))), + ]; + + return relations.page.slots({ + type: 'custom', + + content: [ + html.tag('p', {id: 'network-stat-line'}, + language.$(prefix, 'statLine', { + stat: [ + html.tag('a', {id: 'network-stat-none'}, + {href: '#'}, + language.$(prefix, 'statLine.none')), + + html.tag('a', {id: 'network-stat-total-uses'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.totalUses')), + + html.tag('a', {id: 'network-stat-direct-uses'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.directUses')), + + html.tag('a', {id: 'network-stat-descendants'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.descendants')), + + html.tag('a', {id: 'network-stat-leaves'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.leaves')), + ], + })), + + html.tag('dl', {id: 'network-top-dl'}, [ + html.tag('dt', {id: 'top'}, + language.$(prefix, 'jumpToRoot.title')), + + html.tag('dd', + html.tag('ul', + stitchArrays({ + link: relations.uppermostRootTagLinks, + directory: data.uppermostRootTagDirectories, + }).map(({link, directory}) => + html.tag('li', + language.$(prefix, 'jumpToRoot.item', { + tag: + link.slots({ + anchor: true, + hash: directory, + }), + }))))), + + stitchArrays({ + dataNode: data.rootNodes, + relationsNode: relations.rootNodes, + }).map(({dataNode, relationsNode}) => + recursive(dataNode, relationsNode, 0)), + + !empty(relations.orphanArtTagLinks) && [ + html.tag('dt', + language.$(prefix, 'orphanArtTags.title')), + + html.tag('dd', + html.tag('ul', + relations.orphanArtTagLinks.map(orphanArtTagLink => + html.tag('li', + language.$(prefix, 'orphanArtTags.item', { + tag: orphanArtTagLink, + }))))), + ], + ]), + ], + }); + }, +}; diff --git a/src/content/dependencies/listArtTagsByName.js b/src/content/dependencies/listArtTagsByName.js new file mode 100644 index 00000000..1df9dfff --- /dev/null +++ b/src/content/dependencies/listArtTagsByName.js @@ -0,0 +1,57 @@ +import {sortAlphabetically} from '#sort'; +import {stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTagGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artTagData}) { + return {artTagData}; + }, + + query({artTagData}, spec) { + return { + spec, + + artTags: + sortAlphabetically( + artTagData + .filter(artTag => !artTag.isContentWarning)), + }; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + artTagLinks: + query.artTags + .map(artTag => relation('linkArtTagGallery', artTag)), + }; + }, + + data(query) { + return { + counts: + query.artTags.map(artTag => + unique([ + ...artTag.indirectlyFeaturedInArtworks, + ...artTag.directlyFeaturedInArtworks, + ]).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/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js new file mode 100644 index 00000000..eca7f1c6 --- /dev/null +++ b/src/content/dependencies/listArtTagsByUses.js @@ -0,0 +1,54 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTagGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl: ({artTagData}) => + ({artTagData}), + + query({artTagData}, spec) { + const artTags = + sortAlphabetically( + artTagData + .filter(artTag => !artTag.isContentWarning)); + + const counts = + artTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length); + + filterByCount(artTags, counts); + sortByCount(artTags, counts, {greatestFirst: true}); + + return {spec, artTags, counts}; + }, + + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + artTagLinks: + query.artTags + .map(artTag => relation('linkArtTagGallery', artTag)), + }), + + data: (query) => + ({counts: query.counts}), + + generate: (data, relations, {language}) => + 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/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js new file mode 100644 index 00000000..eff2dba3 --- /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 00000000..41944959 --- /dev/null +++ b/src/content/dependencies/listArtistsByContributions.js @@ -0,0 +1,174 @@ +import {sortAlphabetically, sortByCount} from '#sort'; + +import { + accumulateSum, + 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.trackArtistContributions, + artist.trackContributorContributions, + ]).flat() + .map(({thing}) => thing) + )).length); + + queryContributionInfo( + 'artistsByArtworkContributions', + 'countsByArtworkContributions', + artist => + accumulateSum( + [ + artist.albumCoverArtistContributions, + artist.albumWallpaperArtistContributions, + artist.albumBannerArtistContributions, + artist.trackCoverArtistContributions, + ], + contribs => contribs.length)); + + if (sprawl.enableFlashesAndGames) { + queryContributionInfo( + 'artistsByFlashContributions', + 'countsByFlashContributions', + artist => + artist.flashContributorContributions.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 00000000..6b2a18a0 --- /dev/null +++ b/src/content/dependencies/listArtistsByDuration.js @@ -0,0 +1,55 @@ +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 durations = + artists.map(artist => artist.totalDuration); + + 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 00000000..17096cfc --- /dev/null +++ b/src/content/dependencies/listArtistsByGroup.js @@ -0,0 +1,157 @@ +import {sortAlphabetically} from '#sort'; + +import { + empty, + filterByCount, + filterMultipleArrays, + stitchArrays, + transposeArrays, + unique, +} from '#sugar'; + +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 interestingGroups = + sprawl.wikiInfo.divideTrackListsByGroups; + + if (empty(interestingGroups)) { + return {spec}; + } + + // We don't actually care about *which* things belong to each group, only + // how many belong to each group. So we'll just compute a list of all the + // (interesting) groups that each of each artists' things belongs to. + const artistThingGroups = + artists.map(artist => + ([ + (unique( + ([ + artist.albumArtistContributions + .map(contrib => contrib.thing), + artist.albumCoverArtistContributions + .map(contrib => contrib.thing.thing), + artist.albumWallpaperArtistContributions + .map(contrib => contrib.thing.thing), + artist.albumBannerArtistContributions + .map(contrib => contrib.thing.thing), + ]).flat() + )).map(album => album.groups), + (unique( + ([ + artist.trackArtistContributions + .map(contrib => contrib.thing), + artist.trackContributorContributions + .map(contrib => contrib.thing), + artist.trackCoverArtistContributions + .map(contrib => contrib.thing.thing), + ]).flat() + )).map(track => track.album.groups), + ]).flat() + .map(groups => groups + .filter(group => interestingGroups.includes(group)))); + + const [artistsByGroup, countsByGroup] = + transposeArrays(interestingGroups.map(group => { + const counts = + artistThingGroups + .map(thingGroups => thingGroups + .filter(thingGroups => thingGroups.includes(group)) + .length); + + const filteredArtists = artists.slice(); + + filterByCount(filteredArtists, counts); + + return [filteredArtists, counts]; + })); + + const groups = interestingGroups; + + filterMultipleArrays( + groups, + artistsByGroup, + countsByGroup, + (_group, artists, _counts) => !empty(artists)); + + return { + spec, + groups, + artistsByGroup, + countsByGroup, + }; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + 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.artistsByGroup) { + data.groupDirectories = + query.groups + .map(group => group.directory); + + data.countsByGroup = + query.countsByGroup; + } + + return data; + }, + + generate: (data, relations, {language}) => + 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}), + }))), + }), +}; diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js new file mode 100644 index 00000000..2a8d1b4c --- /dev/null +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -0,0 +1,323 @@ +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(({artist}) => artist); + + 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'); + // '?? album.date' is kept here because wallpaper and banner may + // technically be present for an album w/o cover art, therefore + // also no cover art date. + } + } + + 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.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 00000000..93218492 --- /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 00000000..4adfb6d9 --- /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 00000000..43919bef --- /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 00000000..c79e1bc4 --- /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), + {mainReleasesOnly: true})); + + filterByCount(groups, durations); + sortByCount(groups, durations, {greatestFirst: true}); + + return {spec, groups, durations}; + }, + + relations(relation, query) { + return { + page: relation('generateListingPage', query.spec), + + groupLinks: + query.groups + .map(group => relation('linkGroup', group)), + }; + }, + + data(query) { + return { + durations: query.durations, + }; + }, + + generate(data, relations, {language}) { + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.groupLinks, + duration: data.durations, + }).map(({link, duration}) => ({ + group: link, + duration: language.formatDuration(duration), + })), + }); + }, +}; diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js new file mode 100644 index 00000000..48319314 --- /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 00000000..696a49bd --- /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 00000000..0b5e4e97 --- /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 00000000..79bba441 --- /dev/null +++ b/src/content/dependencies/listRandomPageLinks.js @@ -0,0 +1,197 @@ +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 capsule = language.encapsulate('listingPage.other.randomPages'); + + const miscellaneousChunkRows = [ + language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({ + stringsKey: 'randomArtist', + + mainLink: + html.tag('a', + {href: '#', 'data-random': 'artist'}, + language.$(capsule, 'mainLink')), + + atLeastTwoContributions: + html.tag('a', + {href: '#', 'data-random': 'artist-more-than-one-contrib'}, + language.$(capsule, '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.encapsulate(capsule, 'chooseLinkLine', capsule => + language.$(capsule, { + fromPart: + (relations.groupLinks + ? language.$(capsule, 'fromPart.dividedByGroups') + : language.$(capsule, 'fromPart.notDividedByGroups')), + + browserSupportPart: + language.$(capsule, 'browserSupportPart'), + }))), + + html.tag('p', {id: 'data-loading-line'}, + language.$(capsule, 'dataLoadingLine')), + + html.tag('p', {id: 'data-loaded-line'}, + language.$(capsule, 'dataLoadedLine')), + + html.tag('p', {id: 'data-error-line'}, + language.$(capsule, '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(() => + language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({ + randomAlbum: + html.tag('a', + {href: '#', 'data-random': 'album-in-group-dl'}, + language.$(capsule, 'randomAlbum')), + + randomTrack: + html.tag('a', + {href: '#', 'data-random': 'track-in-group-dl'}, + language.$(capsule, '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/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js new file mode 100644 index 00000000..b2405034 --- /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 00000000..dcfaeaf0 --- /dev/null +++ b/src/content/dependencies/listTracksByDate.js @@ -0,0 +1,91 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {chunkByProperties, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], + extraDependencies: ['language', 'wikiData'], + + sprawl: ({trackData}) => ({trackData}), + + query({trackData}, spec) { + const query = {spec}; + + query.tracks = + sortAlbumsTracksChronologically( + trackData.filter(track => track.date)); + + query.chunks = + chunkByProperties(query.tracks, ['album', 'date']); + + return query; + }, + + relations: (relation, query) => ({ + 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) => ({ + dates: + query.chunks + .map(({date}) => date), + + rereleases: + query.chunks + .map(({chunk}) => chunk + .map(track => + // Check if the index of this track... + query.tracks.indexOf(track) > + // ...is greater than the *smallest* index + // of any of this track's *other* releases. + // (It won't be greater than its own index, + // so we can use otherReleases here, rather + // than allReleases.) + Math.min(... + track.otherReleases.map(t => query.tracks.indexOf(t))))), + }), + + 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-line'} + : null))), + }); + }, +}; diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js new file mode 100644 index 00000000..64feb4f1 --- /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 00000000..c1ea32a1 --- /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 00000000..773b0473 --- /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 00000000..5838ded0 --- /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 00000000..8ca0d993 --- /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 00000000..6ab954ed --- /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 00000000..c7f42f9d --- /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 00000000..a13a76f0 --- /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 00000000..418af4c2 --- /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 00000000..0c6761eb --- /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 00000000..1bbd45e2 --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,756 @@ +import {bindFind} from '#find'; +import {replacerSpec, parseInput} from '#replacer'; + +import {Marked} from 'marked'; +import striptags from 'striptags'; + +const commonMarkedOptions = { + headerIds: false, + mangle: false, + + tokenizer: { + url(src) { + // Don't link emails + const cap = this.rules.inline.url.exec(src); + if (cap?.[2] === '@') return; + + // Use normal tokenizer url behavior otherwise + // Note that super.url doesn't work here because marked is binding or + // applying this function on the tokenizer instance - super.prop would + // just read the prototype of the containing object literal, not the + // rebound tokenizer. (Thanks MDN.) + return Object.getPrototypeOf(this).url.call(this, src); + }, + }, +}; + +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, + name: arg?.name, + shortName: arg?.shortName ?? arg?.nameShort, + } + : 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, + }, + + absorbPunctuationFollowingExternalLinks: { + type: 'boolean', + default: true, + }, + + textOnly: { + type: 'boolean', + default: false, + }, + + 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; + + let offsetTextNode = 0; + + const contentFromNodes = + data.nodes.map((node, index) => { + const nextNode = data.nodes[index + 1]; + + const absorbFollowingPunctuation = template => { + if (nextNode?.type !== 'text') { + return; + } + + const text = nextNode.data; + const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i); + const suffix = match?.[0]; + if (suffix) { + template.setSlot('suffixNormalContent', suffix); + offsetTextNode = suffix.length; + } + }; + + switch (node.type) { + case 'text': { + const text = node.data.slice(offsetTextNode); + + offsetTextNode = 0; + + return {type: 'text', data: text}; + } + + 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}, + + align === 'center' && + !link && + {class: 'align-center'}, + + pixelate && + {class: 'pixelate'}); + + if (link) { + content = + html.tag('a', + {href: link}, + {target: '_blank'}, + + align === 'center' && + {class: 'align-center'}, + + {title: + language.encapsulate('misc.external.opensInNewTab', capsule => + language.$(capsule, { + link: + language.formatExternalLink(link, { + style: 'platform', + }), + + annotation: + language.$(capsule, '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 'video': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const {width, height, align, pixelate} = node; + + const content = + html.tag('div', {class: 'content-video-container'}, + align === 'center' && + {class: 'align-center'}, + + html.tag('video', + src && {src}, + width && {width}, + height && {height}, + + {controls: true}, + + pixelate && + {class: 'pixelate'})); + + return { + type: 'processed-video', + data: content, + }; + } + + case 'audio': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const {align, inline} = node; + + const audio = + html.tag('audio', + src && {src}, + + align === 'center' && + inline && + {class: 'align-center'}, + + {controls: true}); + + const content = + (inline + ? audio + : html.tag('div', {class: 'content-audio-container'}, + align === 'center' && + {class: 'align-center'}, + + audio)); + + return { + type: 'processed-audio', + data: content, + }; + } + + case 'internal-link': { + const nodeFromRelations = relations.internalLinks[internalLinkIndex++]; + if (nodeFromRelations.type === 'text') { + return {type: 'text', data: nodeFromRelations.data}; + } + + // TODO: This is a bit hacky, like the stuff below, + // but since we dressed it up in a utility function + // maybe it's okay... + const link = + html.resolve( + nodeFromRelations.link, + {slots: ['content', 'hash']}); + + const {label, hash, shortName, name} = nodeFromRelations; + + if (slots.textOnly) { + if (label) { + return {type: 'text', data: label}; + } else if (slots.preferShortLinkNames) { + return {type: 'text', data: shortName ?? name}; + } else { + return {type: 'text', data: name}; + } + } + + // 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'); + } + + let doTheAbsorbyThing = false; + + // TODO: This is just silly. + try { + const tag = html.resolve(link, {normalize: 'tag'}); + doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link'); + } catch {} + + if (doTheAbsorbyThing) { + absorbFollowingPunctuation(link); + } + + return {type: 'processed-internal-link', data: link}; + } + + case 'external-link': { + const {label} = node.data; + const externalLink = relations.externalLinks[externalLinkIndex++]; + + if (slots.textOnly) { + return {type: 'text', data: label}; + } + + externalLink.setSlots({ + content: label, + fromContent: true, + }); + + if (slots.absorbPunctuationFollowingExternalLinks) { + absorbFollowingPunctuation(externalLink); + } + + 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 content = + (htmlFn + ? htmlFn(value, {html, language}) + : value); + + const contentText = + html.resolve(content, {normalize: 'string'}); + + if (slots.textOnly) { + return {type: 'text', data: striptags(contentText)}; + } else { + return {type: 'text', data: contentText}; + } + } + + 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 (or videos) 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' && + !attributes.get('data-inline')) || + attributes.get('data-type') === 'processed-video' || + attributes.get('data-type') === 'processed-audio' + ) { + 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]: '', + [html.onlyIfContent]: true, + })); + }; + + 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(/(?<!^ *(?:-|\d+\.).*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n') + // Expand line breaks which are at the end of a quote. + .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); + + 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) { + // Just insert <br> before every line break. The resulting + // text will appear all in one paragraph - this is expected + // for lyrics, and allows for multiple lines of proportional + // space between stanzas. + return node.data.replace(/\n/g, '<br>\n'); + }, + }); + + const markedOutput = + lyricsMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + }, +} |