diff options
Diffstat (limited to 'src/content')
54 files changed, 5531 insertions, 0 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js new file mode 100644 index 00000000..eb9fc8b0 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -0,0 +1,121 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'html', + 'language', + ], + + data(additionalFiles, {fileSize = true} = {}) { + return { + // Additional files are already a serializable format. + additionalFiles, + showFileSizes: fileSize, + }; + }, + + generate(data, { + html, + language, + }) { + const fileKeys = data.additionalFiles.flatMap(({files}) => files); + const validateFileMapping = (v, validateValue) => { + return value => { + v.isObject(value); + + // It's OK to skip values for files, but if keys are provided for files + // which don't exist, that's an error. + + const unexpectedKeys = + Object.keys(value).filter(key => !fileKeys.includes(key)) + + if (!empty(unexpectedKeys)) { + throw new TypeError(`Unexpected file keys: ${unexpectedKeys.join(', ')}`); + } + + const valueErrors = []; + for (const [fileKey, fileValue] of Object.entries(value)) { + if (fileValue === null) { + continue; + } + + try { + validateValue(fileValue); + } catch (error) { + error.message = `(${fileKey}) ` + error.message; + valueErrors.push(error); + } + } + + if (!empty(valueErrors)) { + throw new AggregateError(valueErrors, `Errors validating values`); + } + }; + }; + + return html.template({ + annotation: 'generateAdditionalFilesList', + + slots: { + fileLinks: { + validate: v => validateFileMapping(v, v.isHTML), + }, + + fileSizes: { + validate: v => validateFileMapping(v, v.isWholeNumber), + }, + }, + + content(slots) { + if (!slots.fileSizes) { + return html.blank(); + } + + const filesWithLinks = new Set( + Object.entries(slots.fileLinks) + .filter(([key, value]) => value) + .map(([key]) => key)); + + if (filesWithLinks.size === 0) { + return html.blank(); + } + + const filteredFileGroups = data.additionalFiles + .map(({title, description, files}) => ({ + title, + description, + files: files.filter(f => filesWithLinks.has(f)), + })) + .filter(({files}) => !empty(files)); + + if (empty(filteredFileGroups)) { + return html.blank(); + } + + return html.tag('dl', + filteredFileGroups.flatMap(({title, description, files}) => [ + html.tag('dt', + (description + ? language.$('releaseInfo.additionalFiles.entry.withDescription', { + title, + description, + }) + : language.$('releaseInfo.additionalFiles.entry', {title}))), + + html.tag('dd', + html.tag('ul', + files.map(file => + html.tag('li', + (slots.fileSizes[file] + ? language.$('releaseInfo.additionalFiles.file.withSize', { + file: slots.fileLinks[file], + size: language.formatFileSize(slots.fileSizes[file]), + }) + : language.$('releaseInfo.additionalFiles.file', { + file: slots.fileLinks[file], + })))))), + ])); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js new file mode 100644 index 00000000..7dfe07b3 --- /dev/null +++ b/src/content/dependencies/generateAdditionalFilesShortcut.js @@ -0,0 +1,33 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'html', + 'language', + ], + + data(additionalFiles) { + return { + titles: additionalFiles.map(fileGroup => fileGroup.title), + }; + }, + + generate(data, { + html, + language, + }) { + if (empty(data.titles)) { + return html.blank(); + } + + return language.$('releaseInfo.additionalFiles.shortcut', { + anchorLink: + html.tag('a', + {href: '#additional-files'}, + language.$('releaseInfo.additionalFiles.shortcut.anchorLink')), + + titles: + language.formatUnitList(data.titles), + }); + }, +} diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js new file mode 100644 index 00000000..5fd4e05b --- /dev/null +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -0,0 +1,57 @@ +export default { + contentDependencies: [ + 'generateAdditionalFilesList', + 'linkAlbumAdditionalFile', + ], + + extraDependencies: [ + 'getSizeOfAdditionalFile', + 'urls', + ], + + data(album, additionalFiles, {fileSize = true} = {}) { + return { + albumDirectory: album.directory, + fileLocations: additionalFiles.flatMap(({files}) => files), + showFileSizes: fileSize, + }; + }, + + relations(relation, album, additionalFiles, {fileSize = true} = {}) { + return { + additionalFilesList: + relation('generateAdditionalFilesList', additionalFiles, { + fileSize, + }), + + additionalFileLinks: + Object.fromEntries( + additionalFiles + .flatMap(({files}) => files) + .map(file => [ + file, + relation('linkAlbumAdditionalFile', album, file), + ])), + }; + }, + + generate(data, relations, { + getSizeOfAdditionalFile, + urls, + }) { + return relations.additionalFilesList + .slots({ + fileLinks: relations.additionalFileLinks, + fileSizes: + Object.fromEntries(data.fileLocations.map(file => [ + file, + (data.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, file)) + : 0), + ])), + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js new file mode 100644 index 00000000..749dd2af --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -0,0 +1,99 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateAlbumInfoPageContent', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumSocialEmbed', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkTrack', + ], + + extraDependencies: ['language'], + + relations(relation, album) { + return { + layout: relation('generatePageLayout'), + + coverArtistChronologyContributions: getChronologyRelations(album, { + contributions: album.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ]), + }), + + albumNavAccent: relation('generateAlbumNavAccent', album, null), + chronologyLinks: relation('generateChronologyLinks'), + + content: relation('generateAlbumInfoPageContent', album), + sidebar: relation('generateAlbumSidebar', album, null), + socialEmbed: relation('generateAlbumSocialEmbed', album), + albumStyleRules: relation('generateAlbumStyleRules', album), + colorStyleRules: relation('generateColorStyleRules', album.color), + }; + }, + + data(album) { + return { + name: album.name, + }; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: language.$('albumPage.title', {album: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: relations.content.cover, + mainContent: relations.content.main.content, + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + { + auto: 'current', + accent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: true, + }), + }, + ], + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + ...relations.sidebar, + + // socialEmbed: relations.socialEmbed, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js new file mode 100644 index 00000000..5d2817ee --- /dev/null +++ b/src/content/dependencies/generateAlbumInfoPageContent.js @@ -0,0 +1,307 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumTrackList', + 'generateContentHeading', + 'generateCoverArtwork', + 'linkAlbumCommentary', + 'linkAlbumGallery', + 'linkContribution', + 'linkExternal', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album) { + const relations = {}; + const sections = relations.sections = {}; + + const contributionLinksRelation = contribs => + contribs.map(contrib => + relation('linkContribution', contrib.who, contrib.what)); + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + if (!empty(album.artistContribs)) { + releaseInfo.artistContributionLinks = + contributionLinksRelation(album.artistContribs); + } + + if (album.hasCoverArt) { + relations.cover = + relation('generateCoverArtwork', album.artTags); + releaseInfo.coverArtistContributionLinks = + contributionLinksRelation(album.coverArtistContribs); + } else { + relations.cover = null; + } + + if (album.hasWallpaperArt) { + releaseInfo.wallpaperArtistContributionLinks = + contributionLinksRelation(album.wallpaperArtistContribs); + } + + if (album.hasBannerArt) { + releaseInfo.bannerArtistContributionLinks = + contributionLinksRelation(album.bannerArtistContribs); + } + + // Section: Listen on + + if (!empty(album.urls)) { + const listen = sections.listen = {}; + + listen.externalLinks = + album.urls.map(url => + relation('linkExternal', url, {type: 'album'})); + } + + // Section: Extra links + + const extra = sections.extra = {}; + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + extra.galleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + extra.commentaryLink = + relation('linkAlbumCommentary', album); + } + + if (!empty(album.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', album.additionalFiles); + } + + // Section: Track list + + relations.trackList = + relation('generateAlbumTrackList', album); + + // Section: Additional files + + if (!empty(album.additionalFiles)) { + const additionalFiles = sections.additionalFiles = {}; + + additionalFiles.heading = + relation('generateContentHeading'); + + additionalFiles.additionalFilesList = + relation('generateAlbumAdditionalFilesList', album, album.additionalFiles); + } + + // Section: Artist commentary + + if (album.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', album.commentary); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.date = album.date; + data.duration = accumulateSum(album.tracks, track => track.duration); + data.durationApproximate = album.tracks.length > 1; + + data.hasCoverArt = album.hasCoverArt; + + if (album.hasCoverArt) { + data.coverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + } + + if (!empty(album.additionalFiles)) { + data.numAdditionalFiles = album.additionalFiles.length; + } + + data.dateAddedToWiki = album.dateAddedToWiki; + + return data; + }, + + generate(data, relations, { + html, + language, + }) { + const content = {}; + + const {sections: sec} = relations; + + const formatContributions = + (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => + contributionLinks && + language.$(stringKey, { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({showContribution, showIcons}))), + }); + + if (data.hasCoverArt) { + content.cover = relations.cover + .slots({ + path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension], + alt: language.$('misc.alt.trackCover') + }); + } else { + content.cover = null; + } + + content.main = { + headingMode: 'sticky', + content: html.tags([ + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), + formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), + formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks), + formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: + language.formatDuration(data.duration, { + approximate: data.durationApproximate, + }), + }), + ]), + + sec.listen && + html.tag('p', + language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + })), + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + sec.extra.additionalFilesShortcut, + + sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewGalleryOrCommentary', { + gallery: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')), + commentary: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')), + }), + + sec.extra.galleryLink && !sec.extra.commentaryLink && + language.$('releaseInfo.viewGallery', { + link: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGallery.link')), + }), + + !sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewCommentary', { + link: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewCommentary.link')), + }), + ]), + + relations.trackList, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + data.dateAddedToWiki && + language.$('releaseInfo.addedToWiki', { + date: language.formatDate(data.dateAddedToWiki), + }), + ]), + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.additionalFilesList, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ]), + }; + + return content; + }, +}; + +/* + banner: !empty(album.bannerArtistContribs) && { + dimensions: album.bannerDimensions, + path: [ + 'media.albumBanner', + album.directory, + album.bannerFileExtension, + ], + alt: language.$('misc.alt.albumBanner'), + position: 'top', + }, + + secondaryNav: generateAlbumSecondaryNav(album, null, { + getLinkThemeString, + html, + language, + link, + }), +*/ diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js new file mode 100644 index 00000000..9d1d87c3 --- /dev/null +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -0,0 +1,120 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generatePreviousNextLinks', + 'linkTrack', + 'linkAlbumCommentary', + 'linkAlbumGallery', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, track) { + const relations = {}; + + relations.previousNextLinks = + relation('generatePreviousNextLinks'); + + relations.previousTrackLink = null; + relations.nextTrackLink = null; + + if (track) { + const index = album.tracks.indexOf(track); + + if (index > 0) { + relations.previousTrackLink = + relation('linkTrack', album.tracks[index - 1]); + } + + if (index < album.tracks.length - 1) { + relations.nextTrackLink = + relation('linkTrack', album.tracks[index + 1]); + } + } + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + relations.albumGalleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + relations.albumCommentaryLink = + relation('linkAlbumCommentary', album); + } + + return relations; + }, + + data(album, track) { + return { + hasMultipleTracks: album.tracks.length > 1, + isTrackPage: !!track, + }; + }, + + generate(data, relations, {html, language}) { + return html.template({ + annotation: `generateAlbumNavAccent`, + + slots: { + showTrackNavigation: {type: 'boolean', default: false}, + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery', 'commentary'), + }, + }, + + content(slots) { + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.albumGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('albumPage.nav.gallery'), + }), + + relations.albumCommentaryLink?.slots({ + attributes: {class: slots.currentExtra === 'commentary' && 'current'}, + content: language.$('albumPage.nav.commentary'), + }), + ]}; + + const {content: previousNextLinks = []} = + slots.showTrackNavigation && + data.isTrackPage && + data.hasMultipleTracks && + relations.previousNextLinks.slots({ + previousLink: relations.previousTrackLink, + nextLink: relations.nextTrackLink, + }); + + const randomLink = + slots.showTrackNavigation && + data.hasMultipleTracks && + html.tag('a', + { + href: '#', + 'data-random': 'track-in-album', + id: 'random-button', + }, + (data.isTrackPage + ? language.$('trackPage.nav.random') + : language.$('albumPage.nav.randomTrack'))); + + const allLinks = [ + ...previousNextLinks, + ...extraLinks, + randomLink, + ].filter(Boolean); + + if (empty(allLinks)) { + return html.blank(); + } + + return `(${language.formatUnitList(allLinks)})` + }, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js new file mode 100644 index 00000000..bf6b091a --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -0,0 +1,76 @@ +export default { + contentDependencies: [ + 'generateAlbumSidebarGroupBox', + 'generateAlbumSidebarTrackSection', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + + relations(relation, album, track) { + const relations = {}; + + relations.albumLink = + relation('linkAlbum', album); + + relations.groupBoxes = + album.groups.map(group => + relation('generateAlbumSidebarGroupBox', album, group)); + + relations.trackSections = + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, track, trackSection)); + + return relations; + }, + + data(album, track) { + return {isAlbumPage: !track}; + }, + + generate(data, relations, {html}) { + const trackListBox = { + content: + html.tags([ + html.tag('h1', relations.albumLink), + relations.trackSections, + ]), + }; + + if (data.isAlbumPage) { + const groupBoxes = + relations.groupBoxes + .map(content => content.slot('isAlbumPage', true)) + .map(content => ({content})); + + return { + leftSidebarMultiple: [ + ...groupBoxes, + trackListBox, + ], + }; + } + + const conjoinedGroupBox = { + content: + relations.groupBoxes + .flatMap((content, i, {length}) => [ + content, + i < length - 1 && + html.tag('hr', { + style: `border-color: var(--primary-color); border-style: none none dotted none` + }), + ]) + .filter(Boolean), + }; + + return { + // leftSidebarStickyMode: 'column', + leftSidebarMultiple: [ + trackListBox, + conjoinedGroupBox, + ], + }; + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js new file mode 100644 index 00000000..1c27af9e --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -0,0 +1,88 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkAlbum', + 'linkExternal', + 'linkGroup', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, album, group) { + const relations = {}; + + relations.groupLink = + relation('linkGroup', group); + + relations.externalLinks = + group.urls.map(url => + relation('linkExternal', url)); + + const albums = group.albums.filter(album => album.date); + const index = albums.indexOf(album); + const previousAlbum = (index > 0) && albums[index - 1]; + const nextAlbum = (index < albums.length - 1) && albums[index + 1]; + + if (group.descriptionShort) { + relations.description = + relation('transformContent', group.descriptionShort); + } + + if (previousAlbum) { + relations.previousAlbumLink = + relation('linkAlbum', previousAlbum); + } + + if (nextAlbum) { + relations.nextAlbumLink = + relation('linkAlbum', nextAlbum); + } + + return relations; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: `generateAlbumSidebarGroupBox`, + + slots: { + isAlbumPage: {type: 'boolean', default: false}, + }, + + content(slots) { + return html.tags([ + html.tag('h1', + language.$('albumSidebar.groupBox.title', { + group: relations.groupLink, + })), + + slots.isAlbumPage && + relations.description + ?.slot('mode', 'multiline'), + + !empty(relations.externalLinks) && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(relations.externalLinks), + })), + + slots.isAlbumPage && + relations.nextAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.next', { + album: relations.nextAlbumLink, + })), + + slots.isAlbumPage && + relations.previousAlbumLink && + html.tag('p', {class: 'group-chronology-link'}, + language.$('albumSidebar.groupBox.previous', { + album: relations.previousAlbumLink, + })), + ]); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js new file mode 100644 index 00000000..2aca6da1 --- /dev/null +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -0,0 +1,98 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['getColors', 'html', 'language'], + + relations(relation, album, track, trackSection) { + const relations = {}; + + relations.trackLinks = + trackSection.tracks.map(track => + relation('linkTrack', track)); + + return relations; + }, + + data(album, track, trackSection) { + const data = {}; + + data.hasTrackNumbers = album.hasTrackNumbers; + data.isTrackPage = !!track; + + data.name = trackSection.name; + data.color = trackSection.color; + data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + + data.firstTrackNumber = trackSection.startIndex + 1; + data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length; + + if (track) { + const index = trackSection.tracks.indexOf(track); + if (index !== -1) { + data.includesCurrentTrack = true; + data.currentTrackIndex = index; + } + } + + return data; + }, + + generate(data, relations, {getColors, html, language}) { + const sectionName = + html.tag('span', {class: 'group-name'}, + (data.isDefaultTrackSection + ? language.$('albumSidebar.trackList.fallbackSectionName') + : data.name)); + + let style; + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + const trackListItems = + relations.trackLinks.map((trackLink, index) => + html.tag('li', + { + class: + data.includesCurrentTrack && + index === data.currentTrackIndex && + 'current', + }, + language.$('albumSidebar.trackList.item', { + track: trackLink, + }))); + + return html.tag('details', + { + class: data.includesCurrentTrack && 'current', + + open: ( + // Leave sidebar track sections collapsed on album info page, + // since there's already a view of the full track listing + // in the main content area. + data.isTrackPage && + + // Only expand the track section which includes the track + // currently being viewed by default. + data.includesCurrentTrack), + }, + [ + html.tag('summary', {style}, + html.tag('span', + (data.hasTrackNumbers + ? language.$('albumSidebar.trackList.group.withRange', { + group: sectionName, + range: `${data.firstTrackNumber}–${data.lastTrackNumber}` + }) + : language.$('albumSidebar.trackList.group', { + group: sectionName, + })))), + + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)), + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js new file mode 100644 index 00000000..656bd997 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -0,0 +1,85 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateAlbumSocialEmbedDescription', + ], + + extraDependencies: [ + 'absoluteTo', + 'language', + 'urls', + ], + + relations(relation, album) { + const relations = {}; + + relations.description = + relation('generateAlbumSocialEmbedDescription', album); + + return relations; + }, + + data(album) { + const data = {}; + + data.hasHeading = !empty(album.groups); + + if (data.hasHeading) { + const firstGroup = album.groups[0]; + data.headingGroupName = firstGroup.directory; + data.headingGroupDirectory = firstGroup.directory; + } + + data.hasImage = album.hasCoverArt; + + if (data.hasImage) { + data.coverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + } + + data.albumName = album.name; + data.albumColor = album.color; + + return data; + }, + + generate(data, relations, { + absoluteTo, + language, + urls, + }) { + const socialEmbed = {}; + + if (data.hasHeading) { + socialEmbed.heading = + language.$('albumPage.socialEmbed.heading', { + group: data.headingGroupName, + }); + + socialEmbed.headingLink = + absoluteTo('localized.album', data.headingGroupDirectory); + } else { + socialEmbed.heading = ''; + socialEmbed.headingLink = null; + } + + socialEmbed.title = + language.$('albumPage.socialEmbed.title', { + album: data.albumName, + }); + + socialEmbed.description = relations.description; + + if (data.hasImage) { + const imagePath = urls + .from('shared.root') + .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension); + socialEmbed.image = '/' + imagePath; + } + + socialEmbed.color = data.albumColor; + + return socialEmbed; + }, +}; diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js new file mode 100644 index 00000000..5fa67b26 --- /dev/null +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -0,0 +1,50 @@ +import {accumulateSum} from '../../util/sugar.js'; + +export default { + extraDependencies: ['language'], + + data(album) { + const data = {}; + + const duration = accumulateSum(album.tracks, track => track.duration); + + data.hasDuration = duration > 0; + data.hasTracks = album.tracks.length > 0; + data.hasDate = !!album.date; + data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration); + + if (!data.hasAny) + return data; + + if (data.hasDuration) + data.duration = duration; + + if (data.hasTracks) + data.tracks = album.tracks.length; + + if (data.hasDate) + data.date = album.date; + + return data; + }, + + generate(data, { + language, + }) { + return language.formatString( + 'albumPage.socialEmbed.body' + [ + data.hasDuration && '.withDuration', + data.hasTracks && '.withTracks', + data.hasDate && '.withReleaseDate', + ].filter(Boolean).join(''), + + Object.fromEntries([ + data.hasDuration && + ['duration', language.formatDuration(data.duration)], + data.hasTracks && + ['tracks', language.countTracks(data.tracks, {unit: true})], + data.hasDate && + ['date', language.formatDate(data.date)], + ].filter(Boolean))); + }, +}; diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js new file mode 100644 index 00000000..c9547836 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleRules.js @@ -0,0 +1,61 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'to', + ], + + data(album) { + const data = {}; + + data.hasWallpaper = !empty(album.wallpaperArtistContribs); + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasWallpaper) { + data.hasWallpaperStyle = !!album.wallpaperStyle; + data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; + data.wallpaperStyle = album.wallpaperStyle; + } + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + return data; + }, + + generate(data, {to}) { + const wallpaperPart = + (data.hasWallpaper + ? [ + `body::before {`, + ` background-image: url("${to(...data.wallpaperPath)}");`, + ...(data.hasWallpaperStyle + ? data.wallpaperStyle + .split('\n') + .map(line => ` ${line}`) + : []), + `}`, + ] + : []); + + const bannerPart = + (data.hasBannerStyle + ? [ + `#banner img {`, + ...data.bannerStyle + .split('\n') + .map(line => ` ${line}`), + `}`, + ] + : []); + + return [ + ...wallpaperPart, + ...bannerPart, + ] + .filter(Boolean) + .join('\n'); + }, +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 00000000..ce174953 --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,126 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +function displayTrackSections(album) { + if (empty(album.trackSections)) { + return false; + } + + if (album.trackSections.length > 1) { + return true; + } + + if (!album.trackSections[0].isDefaultTrackSection) { + return true; + } + + return false; +} + +function displayTracks(album) { + if (empty(album.tracks)) { + return false; + } + + return true; +} + +function getDisplayMode(album) { + if (displayTrackSections(album)) { + return 'trackSections'; + } else if (displayTracks(album)) { + return 'tracks'; + } else { + return 'none'; + } +} + +export default { + contentDependencies: [ + 'generateAlbumTrackListItem', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, album) { + const relations = {}; + + const displayMode = getDisplayMode(album); + + if (displayMode === 'trackSections') { + relations.itemsByTrackSection = + album.trackSections.map(section => + section.tracks.map(track => + relation('generateAlbumTrackListItem', track, album))); + } + + if (displayMode === 'tracks') { + relations.itemsByTrack = + album.tracks.map(track => + relation('generateAlbumTrackListItem', track, album)); + } + + return relations; + }, + + data(album) { + const data = {}; + + data.hasTrackNumbers = album.hasTrackNumbers; + + if (displayTrackSections && !empty(album.trackSections)) { + data.trackSectionInfo = + album.trackSections.map(section => { + const info = {}; + + info.name = section.name; + info.duration = accumulateSum(section.tracks, track => track.duration); + info.durationApproximate = section.tracks.length > 1; + + if (album.hasTrackNumbers) { + info.startIndex = section.startIndex; + } + + return info; + }); + } + + return data; + }, + + generate(data, relations, { + html, + language, + }) { + const listTag = (data.hasTrackNumbers ? 'ol' : 'ul'); + + if (relations.itemsByTrackSection) { + return html.tag('dl', + {class: 'album-group-list'}, + data.trackSectionInfo.map((info, index) => [ + html.tag('dt', + {class: 'content-heading', tabindex: '0'}, + language.$('trackList.section.withDuration', { + section: info.name, + duration: + language.formatDuration(info.duration, { + approximate: info.durationApproximate, + }), + })), + + html.tag('dd', + html.tag(listTag, + data.hasTrackNumbers ? {start: info.startIndex + 1} : {}, + relations.itemsByTrackSection[index])), + ])); + } + + if (relations.itemsByTrack) { + return html.tag(listTag, relations.itemsByTrack); + } + + return html.blank(); + } +}; diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js new file mode 100644 index 00000000..fe46153d --- /dev/null +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -0,0 +1,75 @@ +import {compareArrays} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkContribution', + 'linkTrack', + ], + + extraDependencies: [ + 'getColors', + 'html', + 'language', + ], + + relations(relation, track) { + const relations = {}; + + relations.contributionLinks = + track.artistContribs.map(({who, what}) => + relation('linkContribution', who, what, { + showContribution: false, + showIcons: false, + })); + + relations.trackLink = + relation('linkTrack', track); + + return relations; + }, + + data(track, album) { + const data = {}; + + data.color = track.color; + data.duration = track.duration ?? 0; + + data.showArtists = + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false}); + + return data; + }, + + generate(data, relations, { + getColors, + html, + language, + }) { + const stringOpts = { + duration: language.formatDuration(data.duration), + track: relations.trackLink, + }; + + let style; + if (data.color) { + const {primary} = getColors(data.color); + style = `--primary-color: ${primary}`; + } + + return html.tag('li', + {style}, + (!data.showArtists + ? language.$('trackList.item.withDuration', stringOpts) + : language.$('trackList.item.withDuration.withArtists', { + ...stringOpts, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: language.formatConjunctionList(relations.contributionLinks), + })), + }))); + }, +}; diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js new file mode 100644 index 00000000..a91eebf2 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -0,0 +1,818 @@ +import {empty, filterProperties, unique} from '../../util/sugar.js'; + +import { + chunkByProperties, + getTotalDuration, + sortAlbumsTracksChronologically, +} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateArtistNavLinks', + 'generateContentHeading', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkArtistGallery', + 'linkExternal', + 'linkGroup', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations(relation, artist) { + const relations = {}; + const sections = relations.sections = {}; + + relations.layout = + relation('generatePageLayout'); + + relations.artistNavLinks = + relation('generateArtistNavLinks', artist); + + const processContribs = (...contribArrays) => { + const properties = {}; + + const ownContribs = + contribArrays + .map(contribs => contribs.find(({who}) => who === artist)) + .filter(Boolean); + + const contributionDescriptions = + ownContribs + .map(({what}) => what) + .filter(Boolean); + + if (!empty(contributionDescriptions)) { + properties.contributionDescriptions = contributionDescriptions; + } + + const otherArtistContribs = + contribArrays + .map(contribs => contribs.filter(({who}) => who !== artist)) + .flat(); + + if (!empty(otherArtistContribs)) { + properties.otherArtistLinks = + otherArtistContribs + .map(({who}) => relation('linkArtist', who)); + } + + return properties; + }; + + const sortContributionEntries = (entries, sortFunction) => { + const things = unique(entries.map(({thing}) => thing)); + sortFunction(things); + + const outputArrays = []; + const thingToOutputArray = new Map(); + + for (const thing of things) { + const array = []; + thingToOutputArray.set(thing, array); + outputArrays.push(array); + } + + for (const entry of entries) { + thingToOutputArray.get(entry.thing).push(entry); + } + + entries.splice(0, entries.length, ...outputArrays.flat()); + }; + + const getGroupInfo = (entries) => { + const allGroups = new Set(); + const groupToDuration = new Map(); + const groupToCount = new Map(); + + for (const entry of entries) { + for (const group of entry.album.groups) { + allGroups.add(group); + groupToCount.set(group, (groupToCount.get(group) ?? 0) + 1); + groupToDuration.set(group, (groupToDuration.get(group) ?? 0) + entry.duration ?? 0); + } + } + + const groupInfo = + Array.from(allGroups) + .map(group => ({ + groupLink: relation('linkGroup', group), + duration: groupToDuration.get(group) ?? 0, + count: groupToCount.get(group), + })); + + groupInfo.sort((a, b) => b.count - a.count); + groupInfo.sort((a, b) => b.duration - a.duration); + + return groupInfo; + }; + + if (artist.contextNotes) { + const contextNotes = sections.contextNotes = {}; + contextNotes.content = relation('transformContent', artist.contextNotes); + } + + if (!empty(artist.urls)) { + const visit = sections.visit = {}; + visit.externalLinks = + artist.urls.map(url => + relation('linkExternal', url)); + } + + const trackContributionEntries = [ + ...artist.tracksAsArtist.map(track => ({ + date: track.date, + thing: track, + album: track.album, + duration: track.duration, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.artistContribs), + })), + + ...artist.tracksAsContributor.map(track => ({ + date: track.date, + thing: track, + album: track.album, + duration: track.duration, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.contributorContribs), + })), + ]; + + sortContributionEntries(trackContributionEntries, sortAlbumsTracksChronologically); + + const trackContributionChunks = + chunkByProperties(trackContributionEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + duration: getTotalDuration(chunk), + entries: chunk + .map(entry => + filterProperties(entry, [ + 'contributionDescriptions', + 'duration', + 'otherArtistLinks', + 'rerelease', + 'trackLink', + ])), + })); + + const trackGroupInfo = getGroupInfo(trackContributionEntries, 'duration'); + + if (!empty(trackContributionChunks)) { + const tracks = sections.tracks = {}; + tracks.heading = relation('generateContentHeading'); + tracks.chunks = trackContributionChunks; + + if (!empty(trackGroupInfo)) { + tracks.groupInfo = trackGroupInfo; + } + } + + // TODO: Add and integrate wallpaper and banner date fields (#90) + const artContributionEntries = [ + ...artist.albumsAsCoverArtist.map(album => ({ + kind: 'albumCover', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.coverArtistContribs), + })), + + ...artist.albumsAsWallpaperArtist.map(album => ({ + kind: 'albumWallpaper', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.wallpaperArtistContribs), + })), + + ...artist.albumsAsBannerArtist.map(album => ({ + kind: 'albumBanner', + date: album.coverArtDate, + thing: album, + album: album, + ...processContribs(album.bannerArtistContribs), + })), + + ...artist.tracksAsCoverArtist.map(track => ({ + kind: 'trackCover', + date: track.coverArtDate, + thing: track, + album: track.album, + rerelease: track.originalReleaseTrack !== null, + trackLink: relation('linkTrack', track), + ...processContribs(track.coverArtistContribs), + })), + ]; + + sortContributionEntries(artContributionEntries, sortAlbumsTracksChronologically); + + const artContributionChunks = + chunkByProperties(artContributionEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + entries: + chunk.map(entry => + filterProperties(entry, [ + 'contributionDescriptions', + 'kind', + 'otherArtistLinks', + 'rerelease', + 'trackLink', + ])), + })); + + const artGroupInfo = getGroupInfo(artContributionEntries, 'count'); + + if (!empty(artContributionChunks)) { + const artworks = sections.artworks = {}; + artworks.heading = relation('generateContentHeading'); + artworks.chunks = artContributionChunks; + + if ( + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist) + ) { + artworks.artistGalleryLink = + relation('linkArtistGallery', artist); + } + + if (!empty(artGroupInfo)) { + artworks.groupInfo = artGroupInfo; + } + } + + // Commentary doesn't use the detailed contribution system where multiple + // artists are collaboratively credited for the same piece, so there isn't + // really anything special to do for processing or presenting it. + + const commentaryEntries = [ + ...artist.albumsAsCommentator.map(album => ({ + kind: 'albumCommentary', + date: album.date, + thing: album, + album: album, + })), + + ...artist.tracksAsCommentator.map(track => ({ + kind: 'trackCommentary', + date: track.date, + thing: track, + album: track.album, + trackLink: relation('linkTrack', track), + })), + ]; + + sortContributionEntries(commentaryEntries, sortAlbumsTracksChronologically); + + // We still pass through (and chunk by) date here, even though it doesn't + // actually get displayed on the album page. See issue #193. + const commentaryChunks = + chunkByProperties(commentaryEntries, ['album', 'date']) + .map(({album, date, chunk}) => ({ + albumLink: relation('linkAlbum', album), + date: +date, + entries: + chunk.map(entry => + filterProperties(entry, [ + 'kind', + 'trackLink', + ])), + })); + + if (!empty(commentaryChunks)) { + const commentary = sections.commentary = {}; + commentary.heading = relation('generateContentHeading'); + commentary.chunks = commentaryChunks; + } + + return relations; + }, + + data(artist) { + const data = {}; + + data.name = artist.name; + + const allTracks = unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]); + data.totalTrackCount = allTracks.length; + data.totalDuration = getTotalDuration(allTracks, {originalReleasesOnly: true}); + + return data; + }, + + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + + const addAccentsToEntry = ({ + rerelease, + entry, + otherArtistLinks, + contributionDescriptions, + }) => { + if (rerelease) { + return language.$('artistPage.creditList.entry.rerelease', {entry}); + } + + const options = {entry}; + const parts = ['artistPage.creditList.entry']; + + if (otherArtistLinks) { + parts.push('withArtists'); + options.artists = language.formatConjunctionList(otherArtistLinks); + } + + if (contributionDescriptions) { + parts.push('withContribution'); + options.contribution = language.formatUnitList(contributionDescriptions); + } + + if (parts.length === 1) { + return entry; + } + + return language.formatString(parts.join('.'), options); + }; + + const addAccentsToAlbumLink = ({ + albumLink, + date, + duration, + entries, + }) => { + const options = {album: albumLink}; + const parts = ['artistPage.creditList.album']; + + if (date) { + parts.push('withDate'); + options.date = language.formatDate(new Date(date)); + } + + if (duration) { + parts.push('withDuration'); + options.duration = language.formatDuration(duration, { + approximate: entries.length > 1, + }); + } + + return language.formatString(parts.join('.'), options); + }; + + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + mainClasses: ['long-content'], + mainContent: [ + sec.contextNotes && [ + html.tag('p', language.$('releaseInfo.note')), + html.tag('blockquote', + sec.contextNotes.content), + ], + + sec.visit && + html.tag('p', + language.$('releaseInfo.visitOn', { + links: language.formatDisjunctionList(sec.visit.externalLinks), + })), + + sec.artworks?.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) && + html.tag('p', + language.$('misc.jumpTo.withLinks', { + links: language.formatUnitList( + [ + sec.tracks && + html.tag('a', + {href: '#tracks'}, + language.$('artistPage.trackList.title')), + + sec.artworks && + html.tag('a', + {href: '#art'}, + language.$('artistPage.artList.title')), + + sec.flashes && + html.tag('a', + {href: '#flashes'}, + language.$('artistPage.flashList.title')), + + sec.commentary && + html.tag('a', + {href: '#commentary'}, + language.$('artistPage.commentaryList.title')), + ].filter(Boolean)), + })), + + sec.tracks && [ + sec.tracks.heading + .slots({ + tag: 'h2', + id: 'tracks', + title: language.$('artistPage.trackList.title'), + }), + + data.totalDuration > 0 && + html.tag('p', + language.$('artistPage.contributedDurationLine', { + artist: data.name, + duration: + language.formatDuration(data.totalDuration, { + approximate: data.totalTrackCount > 1, + unit: true, + }), + })), + + sec.tracks.groupInfo && + html.tag('p', + language.$('artistPage.musicGroupsLine', { + groups: + language.formatUnitList( + sec.tracks.groupInfo.map(({groupLink, count, duration}) => + (duration + ? language.$('artistPage.groupsLine.item.withDuration', { + group: groupLink, + duration: language.formatDuration(duration, {approximate: count > 1}), + }) + : language.$('artistPage.groupsLine.item.withCount', { + group: groupLink, + count: language.countContributions(count), + })))), + })), + + html.tag('dl', + sec.tracks.chunks.map(({albumLink, date, duration, entries}) => [ + html.tag('dt', + addAccentsToAlbumLink({albumLink, date, duration, entries})), + + html.tag('dd', + html.tag('ul', + entries + .map(({trackLink, duration, ...properties}) => ({ + entry: + (duration + ? language.$('artistPage.creditList.entry.track.withDuration', { + track: trackLink, + duration: language.formatDuration(duration), + }) + : language.$('artistPage.creditList.entry.track', { + track: trackLink, + })), + ...properties, + })) + .map(addAccentsToEntry) + .map(entry => html.tag('li', entry)))), + ])), + ], + + sec.artworks && [ + sec.artworks.heading + .slots({ + tag: 'h2', + id: 'art', + title: language.$('artistPage.artList.title'), + }), + + sec.artworks.artistGalleryLink && + html.tag('p', + language.$('artistPage.viewArtGallery.orBrowseList', { + link: sec.artworks.artistGalleryLink.slots({ + content: language.$('artistPage.viewArtGallery.link'), + }), + })), + + sec.artworks.groupInfo && + html.tag('p', + language.$('artistPage.artGroupsLine', { + groups: + language.formatUnitList( + sec.artworks.groupInfo.map(({groupLink, count}) => + language.$('artistPage.groupsLine.item.withCount', { + group: groupLink, + count: + language.countContributions(count), + }))), + })), + + html.tag('dl', + sec.artworks.chunks.map(({albumLink, date, entries}) => [ + html.tag('dt', + addAccentsToAlbumLink({albumLink, date, entries})), + + html.tag('dd', + html.tag('ul', + entries + .map(({kind, trackLink, ...properties}) => ({ + entry: + (kind === 'trackCover' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.' + { + albumWallpaper: 'wallpaperArt', + albumBanner: 'bannerArt', + albumCover: 'coverArt', + }[kind]))), + ...properties, + })) + .map(addAccentsToEntry) + .map(entry => html.tag('li', entry)))), + ])), + ], + + sec.commentary && [ + sec.commentary.heading + .slots({ + tag: 'h2', + id: 'commentary', + title: language.$('artistPage.commentaryList.title'), + }), + + html.tag('dl', + sec.commentary.chunks.map(({albumLink, entries}) => [ + html.tag('dt', + language.$('artistPage.creditList.album', { + album: albumLink, + })), + + html.tag('dd', + html.tag('ul', + entries + .map(({kind, trackLink}) => + (kind === 'trackCommentary' + ? language.$('artistPage.creditList.entry.track', { + track: trackLink, + }) + : html.tag('i', + language.$('artistPage.creditList.entry.album.commentary')))) + .map(entry => html.tag('li', entry)))), + ])), + ], + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + }) + .content, + }); + }, +}; + +/* + +export function write(artist, {wikiData}) { + const {groupData, wikiInfo} = wikiData; + + let flashes, flashListChunks; + if (wikiInfo.enableFlashesAndGames) { + flashes = sortFlashesChronologically(artist.flashesAsContributor.slice()); + flashListChunks = chunkByProperties( + flashes.map((flash) => ({ + act: flash.act, + flash, + date: flash.date, + // Manual artists/contrib properties here, 8ecause we don't + // want to show the full list of other contri8utors inline. + // (It can often 8e very, very large!) + artists: [], + contrib: flash.contributorContribs.find(({who}) => who === artist), + })), + ['act'] + ).map(({act, chunk}) => ({ + act, + chunk, + dateFirst: chunk[0].date, + dateLast: chunk[chunk.length - 1].date, + })); + } + + const unbound_serializeArtistsAndContrib = + (key, {serializeContribs, serializeLink}) => + (thing) => { + const {artists, contrib} = getArtistsAndContrib(thing, key); + const ret = {}; + ret.link = serializeLink(thing); + if (contrib.what) ret.contribution = contrib.what; + if (!empty(artists)) ret.otherArtists = serializeContribs(artists); + return ret; + }; + + const unbound_serializeTrackListChunks = (chunks, {serializeLink}) => + chunks.map(({date, album, chunk, duration}) => ({ + album: serializeLink(album), + date, + duration, + tracks: chunk.map(({track}) => ({ + link: serializeLink(track), + duration: track.duration, + })), + })); + + const jumpTo = { + tracks: !empty(allTracks), + art: !empty(artThingsAll), + flashes: wikiInfo.enableFlashesAndGames && !empty(flashes), + commentary: !empty(commentaryThings), + }; + + const showJumpTo = Object.values(jumpTo).includes(true); + + const data = { + type: 'data', + path: ['artist', artist.directory], + data: ({serializeContribs, serializeLink}) => { + const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, { + serializeContribs, + serializeLink, + }); + + const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, { + serializeLink, + }); + + return { + albums: { + asCoverArtist: artist.albumsAsCoverArtist + .map(serializeArtistsAndContrib('coverArtistContribs')), + asWallpaperArtist: artist.albumsAsWallpaperArtist + .map(serializeArtistsAndContrib('wallpaperArtistContribs')), + asBannerArtist: artist.albumsAsBannerArtis + .map(serializeArtistsAndContrib('bannerArtistContribs')), + }, + flashes: wikiInfo.enableFlashesAndGames + ? { + asContributor: artist.flashesAsContributor + .map(flash => getArtistsAndContrib(flash, 'contributorContribs')) + .map(({contrib, thing: flash}) => ({ + link: serializeLink(flash), + contribution: contrib.what, + })), + } + : null, + tracks: { + asArtist: artist.tracksAsArtist + .map(serializeArtistsAndContrib('artistContribs')), + asContributor: artist.tracksAsContributo + .map(serializeArtistsAndContrib('contributorContribs')), + chunked: serializeTrackListChunks(trackListChunks), + }, + }; + }, + }; + + const infoPage = { + type: 'page', + path: ['artist', artist.directory], + page: ({ + fancifyURL, + generateInfoGalleryLinks, + getArtistAvatar, + getArtistString, + html, + link, + language, + transformMultiline, + }) => { + const generateTrackList = bindOpts(unbound_generateTrackList, { + getArtistString, + html, + language, + link, + }); + + return { + title: language.$('artistPage.title', {artist: name}), + + cover: artist.hasAvatar && { + src: getArtistAvatar(artist), + alt: language.$('misc.alt.artistAvatar'), + }, + + main: { + headingMode: 'sticky', + + content: [ + ...html.fragment( + wikiInfo.enableFlashesAndGames && + !empty(flashes) && [ + html.tag('h2', + {id: 'flashes', class: ['content-heading']}, + language.$('artistPage.flashList.title')), + + html.tag('dl', + flashListChunks.flatMap(({ + act, + chunk, + dateFirst, + dateLast, + }) => [ + html.tag('dt', + language.$('artistPage.creditList.flashAct.withDateRange', { + act: link.flash(chunk[0].flash, { + text: act.name, + }), + dateRange: language.formatDateRange( + dateFirst, + dateLast + ), + })), + + html.tag('dd', + html.tag('ul', + chunk + .map(({flash, ...props}) => ({ + ...props, + entry: language.$('artistPage.creditList.entry.flash', { + flash: link.flash(flash), + }), + })) + .map(opts => generateEntryAccents({ + getArtistString, + language, + ...opts, + })) + .map(row => html.tag('li', row)))), + ])), + ]), + ], + }, + }; + }, + }; + + const artThingsGallery = sortAlbumsTracksChronologically( + [ + ...(artist.albumsAsCoverArtist ?? []), + ...(artist.tracksAsCoverArtist ?? []), + ], + {latestFirst: true, getDate: (o) => o.coverArtDate}); + + const galleryPage = hasGallery && { + type: 'page', + path: ['artistGallery', artist.directory], + page: ({ + generateInfoGalleryLinks, + getAlbumCover, + getGridHTML, + getTrackCover, + html, + link, + language, + }) => ({ + title: language.$('artistGalleryPage.title', {artist: name}), + + main: { + classes: ['top-index'], + headingMode: 'static', + + content: [ + html.tag('p', + {class: 'quick-info'}, + language.$('artistGalleryPage.infoLine', { + coverArts: language.countCoverArts(artThingsGallery.length, { + unit: true, + }), + })), + + html.tag('div', + {class: 'grid-listing'}, + getGridHTML({ + entries: artThingsGallery.map((item) => ({item})), + srcFn: (thing) => + thing.album + ? getTrackCover(thing) + : getAlbumCover(thing), + linkFn: (thing, opts) => + thing.album + ? link.track(thing, opts) + : link.album(thing, opts), + })), + ], + }, + }), + }; + + return [data, infoPage, galleryPage].filter(Boolean); +} + +*/ diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js new file mode 100644 index 00000000..f283b30d --- /dev/null +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -0,0 +1,105 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkArtistGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + enableListings: wikiInfo.enableListings, + }; + }, + + relations(relation, sprawl, artist) { + const relations = {}; + + relations.artistMainLink = + relation('linkArtist', artist); + + relations.artistInfoLink = + relation('linkArtist', artist); + + if ( + !empty(artist.albumsAsCoverArtist) || + !empty(artist.tracksAsCoverArtist) + ) { + relations.artistGalleryLink = + relation('linkArtistGallery', artist); + } + + return relations; + }, + + data(sprawl) { + return { + enableListings: sprawl.enableListings, + }; + }, + + generate(data, relations, {html, language}) { + return html.template({ + annotation: `generateArtistNav`, + slots: { + showExtraLinks: {type: 'boolean', default: false}, + + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + content(slots) { + const infoLink = + relations.artistInfoLink?.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const {content: extraLinks = []} = + slots.showExtraLinks && + {content: [ + relations.artistGalleryLink?.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }), + ]}; + + const mostAccentLinks = [ + ...extraLinks, + ].filter(Boolean); + + // Don't show the info accent link all on its own. + const allAccentLinks = + (empty(mostAccentLinks) + ? [] + : [infoLink, ...mostAccentLinks]); + + const accent = + (empty(allAccentLinks) + ? html.blank() + : `(${language.formatUnitList(allAccentLinks)})`); + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + accent, + html: + language.$('artistPage.nav.artist', { + artist: relations.artistMainLink, + }), + }, + ]; + }, + }); + }, +}; diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js new file mode 100644 index 00000000..a61b5e6f --- /dev/null +++ b/src/content/dependencies/generateChronologyLinks.js @@ -0,0 +1,88 @@ +import {accumulateSum, empty} from '../../util/sugar.js'; + +export default { + extraDependencies: ['html', 'language'], + + generate({html, language}) { + return html.template({ + annotation: `generateChronologyLinks`, + + slots: { + chronologyInfoSets: { + validate: v => + v.arrayOf( + v.validateProperties({ + headingString: v.isString, + contributions: v.arrayOf(v.validateProperties({ + index: v.isCountingNumber, + artistLink: v.isHTML, + previousLink: v.isHTML, + nextLink: v.isHTML, + })), + })), + } + }, + + content(slots) { + if (empty(slots.chronologyInfoSets)) { + return html.blank(); + } + + const totalContributionCount = + accumulateSum( + slots.chronologyInfoSets, + ({contributions}) => contributions.length); + + if (totalContributionCount === 0) { + return html.blank(); + } + + if (totalContributionCount > 8) { + return html.tag('div', {class: 'chronology'}, + language.$('misc.chronology.seeArtistPages')); + } + + return html.tags( + slots.chronologyInfoSets.map(({ + headingString, + contributions, + }) => + contributions.map(({ + index, + artistLink, + previousLink, + nextLink, + }) => { + const heading = + html.tag('span', {class: 'heading'}, + language.$(headingString, { + index: language.formatIndex(index), + artist: artistLink, + })); + + const navigation = + (previousLink || nextLink) && + html.tag('span', {class: 'buttons'}, + language.formatUnitList([ + previousLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.previous'), + }), + + nextLink?.slots({ + tooltip: true, + color: false, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean))); + + return html.tag('div', {class: 'chronology'}, + (navigation + ? language.$('misc.chronology.withNavigation', {heading, navigation}) + : heading)); + }))); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js new file mode 100644 index 00000000..44600935 --- /dev/null +++ b/src/content/dependencies/generateColorStyleRules.js @@ -0,0 +1,41 @@ +export default { + extraDependencies: [ + 'getColors', + ], + + data(color) { + return {color}; + }, + + generate(data, { + getColors, + }) { + if (!data.color) return ''; + + const { + primary, + dark, + dim, + dimGhost, + bg, + bgBlack, + shadow, + } = getColors(data.color); + + const variables = [ + `--primary-color: ${primary}`, + `--dark-color: ${dark}`, + `--dim-color: ${dim}`, + `--dim-ghost-color: ${dimGhost}`, + `--bg-color: ${bg}`, + `--bg-black-color: ${bgBlack}`, + `--shadow-color: ${shadow}`, + ]; + + return [ + `:root {`, + ...variables.map((line) => ` ${line};`), + `}`, + ].join('\n'); + }, +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js new file mode 100644 index 00000000..1666ef4b --- /dev/null +++ b/src/content/dependencies/generateContentHeading.js @@ -0,0 +1,27 @@ +export default { + extraDependencies: [ + 'html', + ], + + generate({html}) { + return html.template({ + annotation: 'generateContentHeading', + + slots: { + title: {type: 'html'}, + id: {type: 'string'}, + tag: {type: 'string', default: 'p'}, + }, + + content(slots) { + return html.tag(slots.tag, + { + class: 'content-heading', + id: slots.id, + tabindex: '0', + }, + slots.title); + }, + }); + } +} diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js new file mode 100644 index 00000000..a7a7f859 --- /dev/null +++ b/src/content/dependencies/generateCoverArtwork.js @@ -0,0 +1,83 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['image', 'linkArtTag'], + extraDependencies: ['html', 'language'], + + relations(relation, artTags) { + const relations = {}; + + relations.image = + relation('image', artTags); + + if (artTags) { + relations.tagLinks = + artTags + .filter(tag => !tag.isContentWarning) + .map(tag => relation('linkArtTag', tag)); + } else { + relations.tagLinks = null; + } + + return relations; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: 'generateCoverArtwork', + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + + displayMode: { + validate: v => v.is('primary', 'thumbnail'), + default: 'primary', + }, + }, + + content(slots) { + switch (slots.displayMode) { + case 'primary': + return html.tag('div', {id: 'cover-art-container'}, [ + relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'medium', + id: 'cover-art', + reveal: true, + link: true, + square: true, + }), + + !empty(relations.tagLinks) && + html.tag('p', + language.$('releaseInfo.artTags.inline', { + tags: language.formatUnitList(relations.tagLinks), + })), + ]); + + case 'thumbnail': + return relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'small', + reveal: false, + link: false, + square: true, + }); + + case 'default': + return html.blank(); + } + }, + }); + }, +}; diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js new file mode 100644 index 00000000..01b5b209 --- /dev/null +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -0,0 +1,56 @@ +export default { + extraDependencies: [ + 'defaultLanguage', + 'html', + 'language', + 'languages', + 'pagePath', + 'to', + ], + + generate({ + defaultLanguage, + html, + language, + languages, + pagePath, + to, + }) { + const links = Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language) + .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) + .map((language) => + html.tag('span', + html.tag('a', + { + href: + language === defaultLanguage + ? to( + 'localizedDefaultLanguage.' + pagePath[0], + ...pagePath.slice(1)) + : to( + 'localizedWithBaseDirectory.' + pagePath[0], + language.code, + ...pagePath.slice(1)), + }, + language.name))); + + return html.tag('div', {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', { + languages: links.join('\n'), + })); + }, +}; + +/* +function unbound_getFooterLocalizationLinks({ + html, + defaultLanguage, + language, + languages, + pagePath, + to, +}) { +} +*/ diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js new file mode 100644 index 00000000..55f5b940 --- /dev/null +++ b/src/content/dependencies/generatePageLayout.js @@ -0,0 +1,506 @@ +import {empty, openAggregate} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'generateColorStyleRules', + 'generateFooterLocalizationLinks', + 'generateStickyHeadingContainer', + 'transformContent', + ], + + extraDependencies: [ + 'cachebust', + 'html', + 'language', + 'to', + 'transformMultiline', + 'wikiData', + ], + + sprawl({wikiInfo}) { + return { + footerContent: wikiInfo.footerContent, + wikiColor: wikiInfo.color, + wikiName: wikiInfo.nameShort, + }; + }, + + data({wikiName}) { + return { + wikiName, + }; + }, + + relations(relation, sprawl) { + const relations = {}; + + relations.footerLocalizationLinks = + relation('generateFooterLocalizationLinks'); + + relations.stickyHeadingContainer = + relation('generateStickyHeadingContainer'); + + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + + relations.defaultColorStyleRules = + relation('generateColorStyleRules', sprawl.wikiColor); + + return relations; + }, + + generate(data, relations, { + cachebust, + html, + language, + to, + }) { + const sidebarSlots = side => ({ + // Content is a flat HTML array. It'll generate one sidebar section + // if specified. + [side + 'Content']: {type: 'html'}, + + // Multiple is an array of {content: (HTML)} objects. Each of these + // will generate one sidebar section. + [side + 'Multiple']: { + validate: v => + v.arrayOf( + v.validateProperties({ + content: v.isHTML, + })), + }, + + // Sticky mode controls which sidebar section(s), if any, follow the + // scroll position, "sticking" to the top of the browser viewport. + // + // 'last' - last or only sidebar box is sticky + // 'column' - entire column, incl. multiple boxes from top, is sticky + // 'none' - sidebar not sticky at all, stays at top of page + // + // Note: This doesn't affect the content of any sidebar section, only + // the whole section's containing box (or the sidebar column as a whole). + [side + 'StickyMode']: { + validate: v => v.is('last', 'column', 'static'), + }, + + // Collapsing sidebars disappear when the viewport is sufficiently + // thin. (This is the default.) Override as false to make the sidebar + // stay visible in thinner viewports, where the page layout will be + // reflowed so the sidebar is as wide as the screen and appears below + // nav, above the main content. + [side + 'Collapse']: {type: 'boolean', default: true}, + + // Wide sidebars generally take up more horizontal space in the normal + // page layout, and should be used if the content of the sidebar has + // a greater than typical focus compared to main content. + [side + 'Wide']: {type: 'boolean', defualt: false}, + }); + + return html.template({ + annotation: 'generatePageLayout', + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + coverNeedsReveal: {type: 'boolean'}, + + socialEmbed: {type: 'html'}, + + colorStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + additionalStyleRules: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + mainClasses: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + + // Main + + mainContent: {type: 'html'}, + + headingMode: { + validate: v => v.is('sticky', 'static'), + default: 'static', + }, + + // Sidebars + + ...sidebarSlots('leftSidebar'), + ...sidebarSlots('rightSidebar'), + + // Nav & Footer + + navContent: {type: 'html'}, + navBottomRowContent: {type: 'html'}, + + navLinkStyle: { + validate: v => v.is('hierarchical', 'index'), + default: 'index', + }, + + navLinks: { + validate: v => + v.arrayOf(object => { + v.isObject(object); + + const aggregate = openAggregate({message: `Errors validating navigation link`}); + + aggregate.call(v.validateProperties({ + auto: () => true, + html: () => true, + + path: () => true, + title: () => true, + accent: () => true, + }), object); + + if (object.auto || object.html) { + if (object.auto && object.html) { + aggregate.push(new TypeError(`Don't specify both auto and html`)); + } else if (object.auto) { + aggregate.call(v.is('home', 'current'), object.auto); + } else { + aggregate.call(v.isHTML, object.html); + } + + if (object.path || object.title) { + aggregate.push(new TypeError(`Don't specify path or title along with auto or html`)); + } + } else { + aggregate.call(v.validateProperties({ + path: v.arrayOf(v.isString), + title: v.isString, + }), { + path: object.path, + title: object.title, + }); + } + + aggregate.close(); + + return true; + }) + }, + + footerContent: {type: 'html'}, + }, + + content(slots) { + let titleHTML = null; + + if (!html.isBlank(slots.title)) { + switch (slots.headingMode) { + case 'sticky': + titleHTML = + relations.stickyHeadingContainer.slots({ + title: slots.title, + cover: slots.cover, + needsReveal: slots.coverNeedsReveal, + }); + break; + case 'static': + titleHTML = html.tag('h1', slots.title); + break; + } + } + + let footerContent = slots.footerContent; + + if (html.isBlank(footerContent)) { + footerContent = relations.defaultFooterContent + .slot('mode', 'multiline'); + } + + const mainHTML = + html.tag('main', { + id: 'content', + class: slots.mainClasses, + }, [ + titleHTML, + + slots.cover, + + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'main-content-container', + }, + slots.mainContent), + ]); + + const footerHTML = + html.tag('footer', + {[html.onlyIfContent]: true, id: 'footer'}, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'footer-content', + }, + footerContent), + + relations.footerLocalizationLinks, + ]); + + const navHTML = html.tag('nav', + { + [html.onlyIfContent]: true, + id: 'header', + class: [ + !empty(slots.navLinks) && 'nav-has-main-links', + !html.isBlank(slots.navContent) && 'nav-has-content', + !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row', + ], + }, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: [ + 'nav-main-links', + 'nav-links-' + slots.navLinkStyle, + ], + }, + slots.navLinks?.map((cur, i) => { + let content; + + if (cur.html) { + content = cur.html; + } else { + let title; + let href; + + switch (cur.auto) { + case 'home': + title = data.wikiName; + href = to('localized.home'); + break; + case 'current': + title = slots.title; + href = ''; + break; + case null: + case undefined: + title = cur.title; + href = to(...cur.path); + break; + } + + content = html.tag('a', + {href}, + title); + } + + let className; + + if (cur.auto === 'current') { + className = 'current'; + } else if ( + slots.navLinkStyle === 'hierarchical' && + i === slots.navLinks.length - 1 + ) { + className = 'current'; + } + + return html.tag('span', + {class: className}, + [ + html.tag('span', + {class: 'nav-link-content'}, + content), + html.tag('span', + {[html.onlyIfContent]: true, class: 'nav-link-accent'}, + cur.accent), + ]); + })), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-bottom-row'}, + slots.navBottomRowContent), + + html.tag('div', + {[html.onlyIfContent]: true, class: 'nav-content'}, + slots.navContent), + ]) + + const generateSidebarHTML = (side, id) => { + const content = slots[side + 'Content']; + const multiple = slots[side + 'Multiple']; + const stickyMode = slots[side + 'StickyMode']; + const wide = slots[side + 'Wide']; + const collapse = slots[side + 'Collapse']; + + let sidebarClasses = []; + let sidebarContent = html.blank(); + + if (!html.isBlank(content)) { + sidebarClasses = ['sidebar']; + sidebarContent = content; + } else if (multiple) { + sidebarClasses = ['sidebar-multiple']; + sidebarContent = + multiple + .filter(Boolean) + .map(({content}) => + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'sidebar', + }, + content)); + } + + return html.tag('div', + { + [html.onlyIfContent]: true, + id, + class: [ + 'sidebar-column', + wide && 'wide', + !collapse && 'no-hide', + stickyMode !== 'static' && `sticky-${stickyMode}`, + ...sidebarClasses, + ], + }, + sidebarContent); + } + + const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left'); + const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right'); + const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse; + + const layoutHTML = [ + navHTML, + // banner.position === 'top' && bannerHTML, + // secondaryNavHTML, + html.tag('div', + { + class: [ + 'layout-columns', + !collapseSidebars && 'vertical-when-thin', + (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', + (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', + !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', + sidebarLeftHTML && 'has-sidebar-left', + sidebarRightHTML && 'has-sidebar-right', + ], + }, + [ + sidebarLeftHTML, + mainHTML, + sidebarRightHTML, + ]), + // banner.position === 'bottom' && bannerHTML, + footerHTML, + ].filter(Boolean).join('\n'); + + const documentHTML = html.tags([ + `<!DOCTYPE html>`, + html.tag('html', + { + lang: language.intlCode, + 'data-language-code': language.code, + + /* + 'data-url-key': 'localized.' + pagePath[0], + ...Object.fromEntries( + pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])), + */ + + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.root'), + }, + [ + // developersComment, + + html.tag('head', [ + /* + html.tag('title', + showWikiNameInTitle + ? language.formatString('misc.pageTitle.withWikiName', { + title, + wikiName: data.wikiName, + }) + : language.formatString('misc.pageTitle', {title})), + */ + + html.tag('meta', {charset: 'utf-8'}), + html.tag('meta', { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }), + + /* + ...( + Object.entries(meta) + .filter(([key, value]) => value) + .map(([key, value]) => html.tag('meta', {[key]: value}))), + + canonical && + html.tag('link', { + rel: 'canonical', + href: canonical, + }), + + ...( + localizedCanonical + .map(({lang, href}) => html.tag('link', { + rel: 'alternate', + hreflang: lang, + href, + }))), + + */ + + // slots.socialEmbed, + + html.tag('link', { + rel: 'stylesheet', + href: to('shared.staticFile', `site4.css?${cachebust}`), + }), + + html.tag('style', [ + (empty(slots.colorStyleRules) + ? relations.defaultColorStyleRules + : slots.colorStyleRules), + slots.additionalStyleRules, + ]), + + html.tag('script', { + src: to('shared.staticFile', `lazy-loading.js?${cachebust}`), + }), + ]), + + html.tag('body', + // {style: body.style || ''}, + [ + html.tag('div', {id: 'page-container'}, [ + // mainHTML && skippersHTML, + layoutHTML, + ]), + + // infoCardHTML, + // imageOverlayHTML, + + html.tag('script', { + type: 'module', + src: to('shared.staticFile', `client.js?${cachebust}`), + }), + ]), + ]) + ]); + + return documentHTML; + }, + }); + }, +}; diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js new file mode 100644 index 00000000..42b2c42b --- /dev/null +++ b/src/content/dependencies/generatePreviousNextLinks.js @@ -0,0 +1,36 @@ +export default { + // Returns an array with the slotted previous and next links, prepared + // for inclusion in a page's navigation bar. Include with other links + // in the nav bar and then join them all as a unit list, for example. + + extraDependencies: ['html', 'language'], + + generate({html, language}) { + return html.template({ + annotation: `generatePreviousNextLinks`, + + slots: { + previousLink: {type: 'html'}, + nextLink: {type: 'html'}, + }, + + content(slots) { + return [ + !html.isBlank(slots.previousLink) && + slots.previousLink.slots({ + tooltip: true, + attributes: {id: 'previous-button'}, + content: language.$('misc.nav.previous'), + }), + + !html.isBlank(slots.nextLink) && + slots.nextLink?.slots({ + tooltip: true, + attributes: {id: 'next-button'}, + content: language.$('misc.nav.next'), + }), + ].filter(Boolean); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js new file mode 100644 index 00000000..cbd477e0 --- /dev/null +++ b/src/content/dependencies/generateStaticPage.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generatePageLayout', 'transformContent'], + + relations(relation, staticPage) { + return { + layout: relation('generatePageLayout'), + content: relation('transformContent', staticPage.content), + }; + }, + + data(staticPage) { + return { + name: staticPage.name, + stylesheet: staticPage.stylesheet, + }; + }, + + generate(data, relations) { + return relations.layout + .slots({ + title: data.name, + headingMode: 'sticky', + + additionalStyleRules: + (data.stylesheet + ? [data.stylesheet] + : []), + + mainClasses: ['long-content'], + mainContent: relations.content, + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {auto: 'current'}, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js new file mode 100644 index 00000000..fb6d8307 --- /dev/null +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -0,0 +1,45 @@ +export default { + extraDependencies: ['html'], + + generate({html}) { + return html.template({ + annotation: `generateStickyHeadingContainer`, + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + needsReveal: {type: 'boolean', default: false}, + }, + + content(slots) { + const hasCover = !html.isBlank(slots.cover); + + return html.tag('div', + { + class: [ + 'content-sticky-heading-container', + hasCover && 'has-cover', + ], + }, + [ + html.tag('div', {class: 'content-sticky-heading-row'}, [ + html.tag('h1', slots.title), + + hasCover && + html.tag('div', {class: 'content-sticky-heading-cover-container'}, + html.tag('div', + {class: [ + 'content-sticky-heading-cover', + slots.needsReveal && + 'content-sticky-heading-cover-needs-reveal', + ]}, + slots.cover.slot('displayMode', 'thumbnail'))) + ]), + + html.tag('div', {class: 'content-sticky-subheading-row'}, + html.tag('h2', {class: 'content-sticky-subheading'})), + ]); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js new file mode 100644 index 00000000..ee68f534 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -0,0 +1,132 @@ +import getChronologyRelations from '../util/getChronologyRelations.js'; +import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateTrackInfoPageContent', + 'generateAlbumNavAccent', + 'generateAlbumSidebar', + 'generateAlbumStyleRules', + 'generateChronologyLinks', + 'generateColorStyleRules', + 'generatePageLayout', + 'linkAlbum', + 'linkArtist', + 'linkTrack', + ], + + extraDependencies: ['language'], + + relations(relation, track) { + return { + layout: relation('generatePageLayout'), + + artistChronologyContributions: getChronologyRelations(track, { + contributions: [...track.artistContribs, ...track.contributorContribs], + + linkArtist: artist => relation('linkArtist', artist), + linkThing: track => relation('linkTrack', track), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.tracksAsArtist, + ...artist.tracksAsContributor, + ]), + }), + + coverArtistChronologyContributions: getChronologyRelations(track, { + contributions: track.coverArtistContribs, + + linkArtist: artist => relation('linkArtist', artist), + + linkThing: trackOrAlbum => + (trackOrAlbum.album + ? relation('linkTrack', trackOrAlbum) + : relation('linkAlbum', trackOrAlbum)), + + getThings: artist => + sortAlbumsTracksChronologically([ + ...artist.albumsAsCoverArtist, + ...artist.tracksAsCoverArtist, + ], { + getDate: albumOrTrack => albumOrTrack.coverArtDate, + }), + }), + + albumLink: relation('linkAlbum', track.album), + trackLink: relation('linkTrack', track), + albumNavAccent: relation('generateAlbumNavAccent', track.album, track), + chronologyLinks: relation('generateChronologyLinks'), + + content: relation('generateTrackInfoPageContent', track), + sidebar: relation('generateAlbumSidebar', track.album, track), + albumStyleRules: relation('generateAlbumStyleRules', track.album), + colorStyleRules: relation('generateColorStyleRules', track.color), + }; + }, + + data(track) { + return { + name: track.name, + + hasTrackNumbers: track.album.hasTrackNumbers, + trackNumber: track.album.tracks.indexOf(track) + 1, + }; + }, + + generate(data, relations, {language}) { + return relations.layout + .slots({ + title: language.$('trackPage.title', {track: data.name}), + headingMode: 'sticky', + + colorStyleRules: [relations.colorStyleRules], + additionalStyleRules: [relations.albumStyleRules], + + cover: relations.content.cover, + coverNeedsReveal: relations.content.coverNeedsReveal, + mainContent: relations.content.main.content, + + navLinkStyle: 'hierarchical', + navLinks: [ + {auto: 'home'}, + {html: relations.albumLink}, + { + html: + (data.hasTrackNumbers + ? language.$('trackPage.nav.track.withNumber', { + number: data.trackNumber, + track: relations.trackLink + .slot('attributes', {class: 'current'}), + }) + : language.$('trackPage.nav.track', { + track: relations.trackLink + .slot('attributes', {class: 'current'}), + })), + }, + ], + + navBottomRowContent: + relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + }), + + navContent: + relations.chronologyLinks.slots({ + chronologyInfoSets: [ + { + headingString: 'misc.chronology.heading.track', + contributions: relations.artistChronologyContributions, + }, + { + headingString: 'misc.chronology.heading.coverArt', + contributions: relations.coverArtistChronologyContributions, + }, + ], + }), + + ...relations.sidebar, + }); + }, +} diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js new file mode 100644 index 00000000..c33c2f62 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageContent.js @@ -0,0 +1,671 @@ +import {empty} from '../../util/sugar.js'; +import {sortFlashesChronologically} from '../../util/wiki-data.js'; + +export default { + contentDependencies: [ + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateContentHeading', + 'generateCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', + 'linkAlbum', + 'linkContribution', + 'linkExternal', + 'linkFlash', + 'linkTrack', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({wikiInfo}) { + return { + divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + relations(relation, sprawl, track) { + const {album} = track; + + const relations = {}; + const sections = relations.sections = {}; + + const contributionLinksRelation = contribs => + contribs + .slice(0, 4) + .map(contrib => + relation('linkContribution', contrib.who, contrib.what)); + + const additionalFilesSection = additionalFiles => ({ + heading: relation('generateContentHeading'), + list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), + }); + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + releaseInfo.artistContributionLinks = + contributionLinksRelation(track.artistContribs); + + if (track.hasUniqueCoverArt) { + relations.cover = + relation('generateCoverArtwork', track.artTags); + releaseInfo.coverArtistContributionLinks = + contributionLinksRelation(track.coverArtistContribs); + } else if (album.hasCoverArt) { + relations.cover = + relation('generateCoverArtwork', album.artTags); + } else { + relations.cover = null; + } + + // Section: Listen on + + const listen = sections.listen = {}; + + if (!empty(track.urls)) { + listen.externalLinks = + track.urls.map(url => + relation('linkExternal', url)); + } + + // Section: Extra links + + const extra = sections.extra = {}; + + if (!empty(track.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', track.additionalFiles); + } + + // Section: Other releases + + if (!empty(track.otherReleases)) { + const otherReleases = sections.otherReleases = {}; + + otherReleases.heading = + relation('generateContentHeading'); + + otherReleases.items = + track.otherReleases.map(track => ({ + trackLink: relation('linkTrack', track), + albumLink: relation('linkAlbum', track.album), + })); + } + + // Section: Contributors + + if (!empty(track.contributorContribs)) { + const contributors = sections.contributors = {}; + + contributors.heading = + relation('generateContentHeading'); + + contributors.contributionLinks = + contributionLinksRelation(track.contributorContribs); + } + + // Section: Referenced tracks + + if (!empty(track.referencedTracks)) { + const references = sections.references = {}; + + references.heading = + relation('generateContentHeading'); + + references.list = + relation('generateTrackList', track.referencedTracks); + } + + // Section: Tracks that reference + + if (!empty(track.referencedByTracks)) { + const referencedBy = sections.referencedBy = {}; + + referencedBy.heading = + relation('generateContentHeading'); + + referencedBy.list = + relation('generateTrackListDividedByGroups', + track.referencedByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Sampled tracks + + if (!empty(track.sampledTracks)) { + const samples = sections.samples = {}; + + samples.heading = + relation('generateContentHeading'); + + samples.list = + relation('generateTrackList', track.sampledTracks); + } + + // Section: Tracks that sample + + if (!empty(track.sampledByTracks)) { + const sampledBy = sections.sampledBy = {}; + + sampledBy.heading = + relation('generateContentHeading'); + + sampledBy.list = + relation('generateTrackListDividedByGroups', + track.sampledByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Flashes that feature + + if (sprawl.enableFlashesAndGames) { + const sortedFeatures = + sortFlashesChronologically( + [track, ...track.otherReleases].flatMap(track => + track.featuredInFlashes.map(flash => ({ + // These aren't going to be exposed directly, they're processed + // into the appropriate relations after this sort. + flash, track, + + // These properties are only used for the sort. + act: flash.act, + date: flash.date, + })))); + + if (!empty(sortedFeatures)) { + const flashesThatFeature = sections.flashesThatFeature = {}; + + flashesThatFeature.heading = + relation('generateContentHeading'); + + flashesThatFeature.entries = + sortedFeatures.map(({flash, track: directlyFeaturedTrack}) => + (directlyFeaturedTrack === track + ? { + flashLink: relation('linkFlash', flash), + } + : { + flashLink: relation('linkFlash', flash), + trackLink: relation('linkTrack', directlyFeaturedTrack), + })); + } + } + + // Section: Lyrics + + if (track.lyrics) { + const lyrics = sections.lyrics = {}; + + lyrics.heading = + relation('generateContentHeading'); + + lyrics.content = + relation('transformContent', track.lyrics); + } + + // Sections: Sheet music files, MIDI/proejct files, additional files + + if (!empty(track.sheetMusicFiles)) { + sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles); + } + + if (!empty(track.midiProjectFiles)) { + sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles); + } + + if (!empty(track.additionalFiles)) { + sections.additionalFiles = additionalFilesSection(track.additionalFiles); + } + + // Section: Artist commentary + + if (track.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', track.commentary); + } + + return relations; + }, + + data(sprawl, track) { + const data = {}; + + const {album} = track; + + data.name = track.name; + data.date = track.date; + data.duration = track.duration; + + data.hasUniqueCoverArt = track.hasUniqueCoverArt; + data.hasAlbumCoverArt = album.hasCoverArt; + + if (track.hasUniqueCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.trackCoverArtDirectory = track.directory; + data.coverArtFileExtension = track.coverArtFileExtension; + data.coverNeedsReveal = track.artTags.some(t => t.isContentWarning); + + if (track.coverArtDate && +track.coverArtDate !== +track.date) { + data.coverArtDate = track.coverArtDate; + } + } else if (track.album.hasCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + data.coverNeedsReveal = album.artTags.some(t => t.isContentWarning); + } + + if (!empty(track.additionalFiles)) { + data.numAdditionalFiles = track.additionalFiles.length; + } + + return data; + }, + + generate(data, relations, {html, language}) { + const content = {}; + + const {sections: sec} = relations; + + const formatContributions = + (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => + contributionLinks && + language.$(stringKey, { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({showContribution, showIcons}))), + }); + + if (data.hasUniqueCoverArt) { + content.cover = relations.cover + .slots({ + path: [ + 'media.trackCover', + data.albumCoverArtDirectory, + data.trackCoverArtDirectory, + data.coverArtFileExtension, + ], + }); + content.coverNeedsReveal = data.coverNeedsReveal; + } else if (data.hasAlbumCoverArt) { + content.cover = relations.cover + .slots({ + path: [ + 'media.albumCover', + data.albumCoverArtDirectory, + data.coverArtFileExtension, + ], + }); + content.coverNeedsReveal = data.coverNeedsReveal; + } else { + content.cover = null; + content.coverNeedsReveal = null; + } + + content.main = { + headingMode: 'sticky', + + content: html.tags([ + html.tag('p', { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, [ + formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), + formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: language.formatDuration(data.duration), + }), + ]), + + html.tag('p', + (sec.listen.externalLinks + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + sec.sheetMusicFiles && + language.$('releaseInfo.sheetMusicFiles.shortcut', { + link: html.tag('a', + {href: '#sheet-music-files'}, + language.$('releaseInfo.sheetMusicFiles.shortcut.link')), + }), + + sec.midiProjectFiles && + language.$('releaseInfo.midiProjectFiles.shortcut', { + link: html.tag('a', + {href: '#midi-project-files'}, + language.$('releaseInfo.midiProjectFiles.shortcut.link')), + }), + + sec.additionalFiles && + sec.extra.additionalFilesShortcut, + + sec.artistCommentary && + language.$('releaseInfo.readCommentary', { + link: html.tag('a', + {href: '#artist-commentary'}, + language.$('releaseInfo.readCommentary.link')), + }), + ]), + + sec.otherReleases && [ + sec.otherReleases.heading + .slots({ + id: 'also-released-as', + title: language.$('releaseInfo.alsoReleasedAs'), + }), + + html.tag('ul', + sec.otherReleases.items.map(({trackLink, albumLink}) => + html.tag('li', + language.$('releaseInfo.alsoReleasedAs.item', { + track: trackLink, + album: albumLink, + })))), + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + html.tag('ul', sec.contributors.contributionLinks.map(contributionLink => + html.tag('li', + contributionLink + .slots({ + showIcons: true, + showContribution: true, + })))), + ], + + sec.references && [ + sec.references.heading + .slots({ + id: 'references', + title: + language.$('releaseInfo.tracksReferenced', { + track: html.tag('i', data.name), + }), + }), + + sec.references.list, + ], + + sec.referencedBy && [ + sec.referencedBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatReference', { + track: html.tag('i', data.name), + }), + }), + + sec.referencedBy.list, + ], + + sec.samples && [ + sec.samples.heading + .slots({ + id: 'samples', + title: + language.$('releaseInfo.tracksSampled', { + track: html.tag('i', data.name), + }), + }), + + sec.samples.list, + ], + + sec.sampledBy && [ + sec.sampledBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatSample', { + track: html.tag('i', data.name), + }), + }), + + sec.sampledBy.list, + ], + sec.flashesThatFeature && [ + sec.flashesThatFeature.heading + .slots({ + id: 'featured-in', + title: + language.$('releaseInfo.flashesThatFeature', { + track: html.tag('i', data.name), + }), + }), + + html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) => + (trackLink + ? html.tag('li', {class: 'rerelease'}, + language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { + flash: flashLink, + track: trackLink, + })) + : html.tag('li', + language.$('releaseInfo.flashesThatFeature.item', { + flash: flashLink, + }))))), + ], + + sec.lyrics && [ + sec.lyrics.heading + .slots({ + id: 'lyrics', + title: language.$('releaseInfo.lyrics'), + }), + + html.tag('blockquote', + sec.lyrics.content + .slot('mode', 'lyrics')), + ], + + sec.sheetMusicFiles && [ + sec.sheetMusicFiles.heading + .slots({ + id: 'sheet-music-files', + title: language.$('releaseInfo.sheetMusicFiles.heading'), + }), + + sec.sheetMusicFiles.list, + ], + + sec.midiProjectFiles && [ + sec.midiProjectFiles.heading + .slots({ + id: 'midi-project-files', + title: language.$('releaseInfo.midiProjectFiles.heading'), + }), + + sec.midiProjectFiles.list, + ], + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.list, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ]), + }; + + return content; + }, +}; + +/* + const generateCommentary = ({language, link, transformMultiline}) => + transformMultiline([ + track.commentary, + ...otherReleases.map((track) => + track.commentary + ?.split('\n') + .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>')) + .flatMap(line => [ + line, + language.$('releaseInfo.artistCommentary.seeOriginalRelease', { + original: link.track(track), + }), + ]) + .join('\n') + ), + ].filter(Boolean).join('\n')); + + const data = { + type: 'data', + path: ['track', track.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForTrack, + serializeLink, + }) => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate, + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artistsContribs: serializeContribs(track.artistContribs), + contributorContribs: serializeContribs(track.contributorContribs), + coverArtistContribs: serializeContribs(track.coverArtistContribs || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map((track) => ({ + track: serializeLink(track), + album: serializeLink(track.album), + })), + }), + }; + + const getSocialEmbedDescription = ({ + getArtistString: _getArtistString, + language, + }) => { + const hasArtists = !empty(track.artistContribs); + const hasCoverArtists = !empty(track.coverArtistContribs); + const getArtistString = (contribs) => + _getArtistString(contribs, { + // We don't want to put actual HTML tags in social embeds (sadly + // they don't get parsed and displayed, generally speaking), so + // override the link argument so that artist "links" just show + // their names. + link: {artist: (artist) => artist.name}, + }); + if (!hasArtists && !hasCoverArtists) return ''; + return language.formatString( + 'trackPage.socialEmbed.body' + + [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] + .filter(Boolean) + .join(''), + Object.fromEntries( + [ + hasArtists && ['artists', getArtistString(track.artistContribs)], + hasCoverArtists && [ + 'coverArtists', + getArtistString(track.coverArtistContribs), + ], + ].filter(Boolean) + ) + ); + }; + + const page = { + page: () => { + return { + title: language.$('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + + themeColor: track.color, + theme: + getThemeString(track.color, { + additionalVariables: [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ] + }), + + socialEmbed: { + heading: language.$('trackPage.socialEmbed.heading', { + album: track.album.name, + }), + headingLink: absoluteTo('localized.album', album.directory), + title: language.$('trackPage.socialEmbed.title', { + track: track.name, + }), + description: getSocialEmbedDescription({getArtistString, language}), + image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), + color: track.color, + }, + + secondaryNav: generateAlbumSecondaryNav(album, track, { + getLinkThemeString, + html, + language, + link, + }), + }; + }, + }; +*/ diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js new file mode 100644 index 00000000..e2e9f48d --- /dev/null +++ b/src/content/dependencies/generateTrackList.js @@ -0,0 +1,55 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkTrack', 'linkContribution'], + + extraDependencies: ['html', 'language'], + + relations(relation, tracks) { + if (empty(tracks)) { + return {}; + } + + return { + items: tracks.map(track => ({ + trackLink: + relation('linkTrack', track), + + contributionLinks: + track.artistContribs.map(contrib => + relation('linkContribution', contrib.who, contrib.what)), + })), + }; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: `generateTrackList`, + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + content(slots) { + return html.tag('ul', + relations.items.map(({trackLink, contributionLinks}) => + html.tag('li', + language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js new file mode 100644 index 00000000..1f1ebef8 --- /dev/null +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -0,0 +1,53 @@ +import {empty} from '../../util/sugar.js'; + +import groupTracksByGroup from '../util/groupTracksByGroup.js'; + +export default { + contentDependencies: ['generateTrackList', 'linkGroup'], + extraDependencies: ['html', 'language'], + + relations(relation, tracks, groups) { + if (empty(tracks)) { + return {}; + } + + if (empty(groups)) { + return { + flatList: + relation('generateTrackList', tracks), + }; + } + + const lists = groupTracksByGroup(tracks, groups); + + return { + groupedLists: + Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({ + ...(groupOrOther === 'other' + ? {other: true} + : {groupLink: relation('linkGroup', groupOrOther)}), + + list: + relation('generateTrackList', tracks), + })), + }; + }, + + generate(relations, {html, language}) { + if (relations.flatList) { + return relations.flatList; + } + + return html.tag('dl', + relations.groupedLists.map(({other, groupLink, list}) => [ + html.tag('dt', + (other + ? language.$('trackList.group.fromOther') + : language.$('trackList.group', { + group: groupLink + }))), + + html.tag('dd', list), + ])); + }, +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js new file mode 100644 index 00000000..f9cb00bf --- /dev/null +++ b/src/content/dependencies/image.js @@ -0,0 +1,207 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'getSizeOfImageFile', + 'html', + 'language', + 'thumb', + 'to', + ], + + data(artTags) { + const data = {}; + + if (artTags) { + data.contentWarnings = + artTags + .filter(tag => tag.isContentWarning) + .map(tag => tag.name); + } else { + data.contentWarnings = null; + } + + return data; + }, + + generate(data, { + getSizeOfImageFile, + html, + language, + thumb, + to, + }) { + return html.template({ + annotation: 'image', + + slots: { + src: { + type: 'string', + }, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + thumb: {type: 'string'}, + + reveal: {type: 'boolean', default: true}, + link: {type: 'boolean', default: false}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + + id: {type: 'string'}, + alt: {type: 'string'}, + width: {type: 'number'}, + height: {type: 'number'}, + + missingSourceContent: {type: 'html'}, + }, + + content(slots) { + let originalSrc; + + if (slots.src) { + originalSrc = slots.src; + } else if (!empty(slots.path)) { + originalSrc = to(...slots.path); + } else { + originalSrc = ''; + } + + const thumbSrc = + originalSrc && + (slots.thumb + ? thumb[slots.thumb](originalSrc) + : originalSrc); + + const willLink = typeof slots.link === 'string' || slots.link; + + const willReveal = + slots.reveal && + originalSrc && + !empty(data.contentWarnings); + + const willSquare = slots.square; + + const idOnImg = willLink ? null : slots.id; + const idOnLink = willLink ? slots.id : null; + + if (!originalSrc) { + return prepare( + html.tag('div', {class: 'image-text-area'}, + slots.missingSourceContent)); + } + + let fileSize = null; + if (willLink) { + const mediaRoot = to('media.root'); + if (originalSrc.startsWith(mediaRoot)) { + fileSize = + getSizeOfImageFile( + originalSrc + .slice(mediaRoot.length) + .replace(/^\//, '')); + } + } + + let reveal = null; + if (willReveal) { + reveal = [ + language.$('misc.contentWarnings', { + warnings: language.formatUnitList(data.contentWarnings), + }), + html.tag('br'), + html.tag('span', {class: 'reveal-interaction'}, + language.$('misc.contentWarnings.reveal')), + ]; + } + + const imgAttributes = { + id: idOnImg, + alt: slots.alt, + width: slots.width, + height: slots.height, + 'data-original-size': fileSize, + }; + + const nonlazyHTML = + originalSrc && + prepare( + html.tag('img', { + ...imgAttributes, + src: thumbSrc, + })); + + if (slots.lazy) { + return html.tags([ + html.tag('noscript', nonlazyHTML), + prepare( + html.tag('img', + { + ...imgAttributes, + class: 'lazy', + 'data-original': thumbSrc, + }), + true), + ]); + } + + return nonlazyHTML; + + function prepare(content, hide = false) { + let wrapped = content; + + wrapped = + html.tag('div', {class: 'image-container'}, + html.tag('div', {class: 'image-inner-area'}, + wrapped)); + + if (willReveal) { + wrapped = + html.tag('div', {class: 'reveal'}, [ + wrapped, + html.tag('span', {class: 'reveal-text-container'}, + html.tag('span', {class: 'reveal-text'}, + reveal)), + ]); + } + + if (willSquare) { + wrapped = + html.tag('div', + { + class: [ + 'square', + hide && !willLink && 'js-hide' + ], + }, + + html.tag('div', {class: 'square-content'}, + wrapped)); + } + + if (willLink) { + wrapped = html.tag('a', + { + id: idOnLink, + class: [ + 'box', + 'image-link', + hide && 'js-hide', + ], + + href: + (typeof slots.link === 'string' + ? slots.link + : originalSrc), + }, + wrapped); + } + + return wrapped; + } + }, + }); + } +}; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js new file mode 100644 index 00000000..1210d78e --- /dev/null +++ b/src/content/dependencies/index.js @@ -0,0 +1,235 @@ +import chokidar from 'chokidar'; +import EventEmitter from 'events'; +import * as path from 'path'; +import {ESLint} from 'eslint'; +import {fileURLToPath} from 'url'; + +import contentFunction from '../../content-function.js'; +import {color, logWarn} from '../../util/cli.js'; +import {annotateFunction} from '../../util/sugar.js'; + +function cachebust(filePath) { + if (filePath in cachebust.cache) { + cachebust.cache[filePath] += 1; + return `${filePath}?cachebust${cachebust.cache[filePath]}`; + } else { + cachebust.cache[filePath] = 0; + return filePath; + } +} + +cachebust.cache = Object.create(null); + +export function watchContentDependencies({ + mock = null, + logging = true, +} = {}) { + const events = new EventEmitter(); + const contentDependencies = {}; + + let emittedReady = false; + let initialScanComplete = false; + let allDependenciesFulfilled = false; + + Object.assign(events, { + contentDependencies, + close, + }); + + const eslint = new ESLint(); + + // Watch adjacent files + const metaPath = fileURLToPath(import.meta.url); + const metaDirname = path.dirname(metaPath); + const watcher = chokidar.watch(metaDirname); + + watcher.on('all', (event, filePath) => { + if (!['add', 'change'].includes(event)) return; + if (filePath === metaPath) return; + handlePathUpdated(filePath); + }); + + watcher.on('unlink', (filePath) => { + if (filePath === metaPath) { + console.error(`Yeowzers content dependencies just got nuked.`); + return; + } + handlePathRemoved(filePath); + }); + + watcher.on('ready', () => { + initialScanComplete = true; + checkReadyConditions(); + }); + + if (mock) { + const errors = []; + for (const [functionName, spec] of Object.entries(mock)) { + try { + const fn = processFunctionSpec(functionName, spec); + contentDependencies[functionName] = fn; + } catch (error) { + error.message = `(${functionName}) ${error.message}`; + errors.push(error); + } + } + if (errors.length) { + throw new AggregateError(errors, `Errors processing mocked content functions`); + } + checkReadyConditions(); + } + + return events; + + async function close() { + return watcher.close(); + } + + function checkReadyConditions() { + if (emittedReady) { + return; + } + + if (!initialScanComplete) { + return; + } + + checkAllDependenciesFulfilled(); + + if (!allDependenciesFulfilled) { + return; + } + + events.emit('ready'); + emittedReady = true; + } + + function checkAllDependenciesFulfilled() { + allDependenciesFulfilled = !Object.values(contentDependencies).includes(null); + } + + function getFunctionName(filePath) { + const shortPath = path.basename(filePath); + const functionName = shortPath.slice(0, -path.extname(shortPath).length); + return functionName; + } + + function isMocked(functionName) { + return !!mock && Object.keys(mock).includes(functionName); + } + + async function handlePathRemoved(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + delete contentDependencies[functionName]; + } + + async function handlePathUpdated(filePath) { + const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + + let error = null; + + main: { + const eslintResults = await eslint.lintFiles([filePath]); + const eslintFormatter = await eslint.loadFormatter('stylish'); + const eslintResultText = eslintFormatter.format(eslintResults); + if (eslintResultText.trim().length) { + console.log(eslintResultText); + } + + let spec; + try { + spec = (await import(cachebust(filePath))).default; + } catch (caughtError) { + error = caughtError; + error.message = `Error importing: ${error.message}`; + break main; + } + + let fn; + try { + fn = processFunctionSpec(functionName, spec); + } catch (caughtError) { + error = caughtError; + break main; + } + + if (logging && initialScanComplete) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(color.green(`[${timestamp}] Updated ${functionName}`)); + } + + contentDependencies[functionName] = fn; + + events.emit('update', functionName); + checkReadyConditions(); + } + + if (!error) { + return true; + } + + if (!(functionName in contentDependencies)) { + contentDependencies[functionName] = null; + } + + events.emit('error', functionName, error); + + if (logging) { + if (contentDependencies[functionName]) { + logWarn`Failed to import ${functionName} - using existing version`; + } else { + logWarn`Failed to import ${functionName} - no prior version loaded`; + } + + if (typeof error === 'string') { + console.error(color.yellow(error)); + } else { + console.error(error); + } + } + + return false; + } + + function processFunctionSpec(functionName, spec) { + if (typeof spec.data === 'function') { + annotateFunction(spec.data, {name: functionName, description: 'data'}); + } + + if (typeof spec.generate === 'function') { + annotateFunction(spec.generate, {name: functionName}); + } + + let fn; + try { + fn = contentFunction(spec); + } catch (error) { + error.message = `Error loading spec: ${error.message}`; + throw error; + } + + return fn; + } +} + +export function quickLoadContentDependencies(opts) { + return new Promise((resolve, reject) => { + const watcher = watchContentDependencies(opts); + + watcher.on('error', (name, error) => { + watcher.close().then(() => { + error.message = `Error loading dependency ${name}: ${error}`; + reject(error); + }); + }); + + watcher.on('ready', () => { + watcher.close().then(() => { + resolve(watcher.contentDependencies); + }); + }); + }); +} diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js new file mode 100644 index 00000000..36b0d13a --- /dev/null +++ b/src/content/dependencies/linkAlbum.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.album', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js new file mode 100644 index 00000000..27c0ba9c --- /dev/null +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -0,0 +1,26 @@ +export default { + contentDependencies: [ + 'linkTemplate', + ], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(album, file) { + return { + albumDirectory: album.directory, + file, + }; + }, + + generate(data, relations) { + return relations.linkTemplate + .slots({ + path: ['media.albumAdditionalFile', data.albumDirectory, data.file], + content: data.file, + }); + }, +}; diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js new file mode 100644 index 00000000..ab519fd6 --- /dev/null +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumCommentary', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js new file mode 100644 index 00000000..e3f30a29 --- /dev/null +++ b/src/content/dependencies/linkAlbumGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, album) => + ({link: relation('linkThing', 'localized.albumGallery', album)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js new file mode 100644 index 00000000..7ddb7786 --- /dev/null +++ b/src/content/dependencies/linkArtTag.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.tag', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js new file mode 100644 index 00000000..718ee6fa --- /dev/null +++ b/src/content/dependencies/linkArtist.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artist', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js new file mode 100644 index 00000000..66dc172d --- /dev/null +++ b/src/content/dependencies/linkArtistGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistGallery', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js new file mode 100644 index 00000000..1d0e2d6a --- /dev/null +++ b/src/content/dependencies/linkContribution.js @@ -0,0 +1,74 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: [ + 'linkArtist', + 'linkExternalAsIcon', + ], + + extraDependencies: [ + 'html', + 'language', + ], + + relations(relation, artist) { + const relations = {}; + + relations.artistLink = relation('linkArtist', artist); + + relations.artistIcons = + (artist.urls ?? []).map(url => + relation('linkExternalAsIcon', url)); + + return relations; + }, + + data(artist, contribution) { + return {contribution}; + }, + + generate(data, relations, { + html, + language, + }) { + return html.template({ + annotation: 'linkContribution', + + slots: { + showContribution: {type: 'boolean', default: false}, + showIcons: {type: 'boolean', default: false}, + }, + + content(slots) { + const hasContributionPart = !!(slots.showContribution && data.contribution); + const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons)); + + const externalLinks = hasExternalPart && + html.tag('span', + {[html.noEdgeWhitespace]: true, class: 'icons'}, + language.formatUnitList(relations.artistIcons)); + + return ( + (hasContributionPart + ? (hasExternalPart + ? language.$('misc.artistLink.withContribution.withExternalLinks', { + artist: relations.artistLink, + contrib: data.contribution, + links: externalLinks, + }) + : language.$('misc.artistLink.withContribution', { + artist: relations.artistLink, + contrib: data.contribution, + })) + : (hasExternalPart + ? language.$('misc.artistLink.withExternalLinks', { + artist: relations.artistLink, + links: externalLinks, + }) + : language.$('misc.artistLink', { + artist: relations.artistLink, + })))); + }, + }); + }, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js new file mode 100644 index 00000000..08191a21 --- /dev/null +++ b/src/content/dependencies/linkExternal.js @@ -0,0 +1,93 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language'], + + data(url, { + type = 'generic', + } = {}) { + const types = ['generic', 'album']; + if (!types.includes(type)) { + throw new TypeError(`Expected type to be one of ${types}`); + } + + return { + url, + type, + }; + }, + + generate(data, {html, language}) { + let isLocal; + let domain; + try { + domain = new URL(data.url).hostname; + } catch (error) { + // No support for relative local URLs yet, sorry! (I.e, local URLs must + // be absolute relative to the domain name in order to work.) + isLocal = true; + } + + const a = html.tag('a', + { + href: data.url, + class: 'nowrap', + }, + + // truly unhinged indentation here + isLocal + ? language.$('misc.external.local') + + : domain.includes('bandcamp.com') + ? language.$('misc.external.bandcamp') + + : BANDCAMP_DOMAINS.includes(domain) + ? language.$('misc.external.bandcamp.domain', {domain}) + + : MASTODON_DOMAINS.includes(domain) + ? language.$('misc.external.mastodon.domain', {domain}) + + : domain.includes('youtu') + ? data.type === 'album' + ? data.url.includes('list=') + ? language.$('misc.external.youtube.playlist') + : language.$('misc.external.youtube.fullAlbum') + : language.$('misc.external.youtube') + + : domain.includes('soundcloud') + ? language.$('misc.external.soundcloud') + + : domain.includes('tumblr.com') + ? language.$('misc.external.tumblr') + + : domain.includes('twitter.com') + ? language.$('misc.external.twitter') + + : domain.includes('deviantart.com') + ? language.$('misc.external.deviantart') + + : domain.includes('wikipedia.org') + ? language.$('misc.external.wikipedia') + + : domain.includes('poetryfoundation.org') + ? language.$('misc.external.poetryFoundation') + + : domain.includes('instagram.com') + ? language.$('misc.external.instagram') + + : domain.includes('patreon.com') + ? language.$('misc.external.patreon') + + : domain.includes('spotify.com') + ? language.$('misc.external.spotify') + + : domain.includes('newgrounds.com') + ? language.$('misc.external.newgrounds') + + : domain); + + return a; + } +}; diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js new file mode 100644 index 00000000..6496d026 --- /dev/null +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -0,0 +1,46 @@ +// TODO: Define these as extra dependencies and pass them somewhere +const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; +const MASTODON_DOMAINS = ['types.pl']; + +export default { + extraDependencies: ['html', 'language', 'to'], + + data(url) { + return {url}; + }, + + generate(data, {html, language, to}) { + const domain = new URL(data.url).hostname; + const [id, msg] = ( + domain.includes('bandcamp.com') + ? ['bandcamp', language.$('misc.external.bandcamp')] + : BANDCAMP_DOMAINS.includes(domain) + ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] + : MASTODON_DOMAINS.includes(domain) + ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] + : domain.includes('youtu') + ? ['youtube', language.$('misc.external.youtube')] + : domain.includes('soundcloud') + ? ['soundcloud', language.$('misc.external.soundcloud')] + : domain.includes('tumblr.com') + ? ['tumblr', language.$('misc.external.tumblr')] + : domain.includes('twitter.com') + ? ['twitter', language.$('misc.external.twitter')] + : domain.includes('deviantart.com') + ? ['deviantart', language.$('misc.external.deviantart')] + : domain.includes('instagram.com') + ? ['instagram', language.$('misc.external.bandcamp')] + : domain.includes('newgrounds.com') + ? ['newgrounds', language.$('misc.external.newgrounds')] + : ['globe', language.$('misc.external.domain', {domain})]); + + return html.tag('a', + {href: data.url, class: 'icon'}, + html.tag('svg', [ + html.tag('title', msg), + html.tag('use', { + href: to('shared.staticFile', `icons.svg#icon-${id}`), + }), + ])); + }, +}; diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js new file mode 100644 index 00000000..65158ff8 --- /dev/null +++ b/src/content/dependencies/linkExternalFlash.js @@ -0,0 +1,41 @@ +// Note: This function is seriously hard-coded for HSMusic, with custom +// presentation of links to Homestuck flashes hosted various places. + +export default { + contentDependencies: ['linkExternal'], + extraDependencies: ['html', 'language'], + + relations(relation, url) { + return { + link: relation('linkExternal', url), + }; + }, + + data(url, flash) { + return { + url, + page: flash.page, + }; + }, + + generate(data, relations, {html, language}) { + const {link} = relations; + const {url, page} = data; + + return html.tag('span', + {class: 'nowrap'}, + + url.includes('homestuck.com') + ? isNaN(Number(page)) + ? language.$('misc.external.flash.homestuck.secret', {link}) + : language.$('misc.external.flash.homestuck.page', {link, page}) + + : url.includes('bgreco.net') + ? language.$('misc.external.flash.bgreco', {link}) + + : url.includes('youtu') + ? language.$('misc.external.flash.youtube', {link}) + + : link); + }, +}; diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js new file mode 100644 index 00000000..93dd5a28 --- /dev/null +++ b/src/content/dependencies/linkFlash.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, flash) => + ({link: relation('linkThing', 'localized.flash', flash)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js new file mode 100644 index 00000000..ebab1b5b --- /dev/null +++ b/src/content/dependencies/linkGroup.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupInfo', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js new file mode 100644 index 00000000..86c4a0f3 --- /dev/null +++ b/src/content/dependencies/linkGroupGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, group) => + ({link: relation('linkThing', 'localized.groupGallery', group)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js new file mode 100644 index 00000000..f27d93ac --- /dev/null +++ b/src/content/dependencies/linkListing.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, listing) => + ({link: relation('linkThing', 'localized.listing', listing)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js new file mode 100644 index 00000000..1fb32dd9 --- /dev/null +++ b/src/content/dependencies/linkNewsEntry.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, newsEntry) => + ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js new file mode 100644 index 00000000..032af6c9 --- /dev/null +++ b/src/content/dependencies/linkStaticPage.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, staticPage) => + ({link: relation('linkThing', 'localized.staticPage', staticPage)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js new file mode 100644 index 00000000..9109ab50 --- /dev/null +++ b/src/content/dependencies/linkTemplate.js @@ -0,0 +1,73 @@ +import {empty} from '../../util/sugar.js'; + +export default { + extraDependencies: [ + 'appendIndexHTML', + 'getColors', + 'html', + 'to', + ], + + generate({ + appendIndexHTML, + getColors, + html, + to, + }) { + return html.template({ + annotation: 'linkTemplate', + + slots: { + href: {type: 'string'}, + path: {validate: v => v.validateArrayItems(v.isString)}, + hash: {type: 'string'}, + + tooltip: {validate: v => v.isString}, + attributes: {validate: v => v.isAttributes}, + color: {validate: v => v.isColor}, + content: {type: 'html'}, + }, + + content(slots) { + let href = slots.href; + let style; + let title; + + if (!href && !empty(slots.path)) { + href = to(...slots.path); + } + + if (appendIndexHTML) { + if ( + /^(?!https?:\/\/).+\/$/.test(href) && + href.endsWith('/') + ) { + href += 'index.html'; + } + } + + if (slots.hash) { + href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash; + } + + if (slots.color) { + const {primary, dim} = getColors(slots.color); + style = `--primary-color: ${primary}; --dim-color: ${dim}`; + } + + if (slots.tooltip) { + title = slots.tooltip; + } + + return html.tag('a', + { + ...slots.attributes ?? {}, + href, + style, + title, + }, + slots.content); + }, + }); + }, +} diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js new file mode 100644 index 00000000..1e648ee6 --- /dev/null +++ b/src/content/dependencies/linkThing.js @@ -0,0 +1,91 @@ +export default { + contentDependencies: [ + 'linkTemplate', + ], + + extraDependencies: [ + 'html', + ], + + relations(relation) { + return { + linkTemplate: relation('linkTemplate'), + }; + }, + + data(pathKey, thing) { + return { + pathKey, + + color: thing.color, + directory: thing.directory, + + name: thing.name, + nameShort: thing.nameShort, + }; + }, + + generate(data, relations, {html}) { + const path = [data.pathKey, data.directory]; + + return html.template({ + annotation: 'linkThing', + + slots: { + content: relations.linkTemplate.getSlotDescription('content'), + preferShortName: {type: 'boolean', default: false}, + + tooltip: { + validate: v => v.oneOf(v.isBoolean, v.isString), + default: false, + }, + + color: { + validate: v => v.oneOf(v.isBoolean, v.isColor), + default: true, + }, + + attributes: relations.linkTemplate.getSlotDescription('attributes'), + hash: relations.linkTemplate.getSlotDescription('hash'), + }, + + content(slots) { + let content = slots.content; + + const name = + (slots.preferShortName + ? data.nameShort ?? data.name + : data.name); + + if (html.isBlank(content)) { + content = name; + } + + let color = null; + if (slots.color === true) { + color = data.color ?? null; + } else if (typeof slots.color === 'string') { + color = slots.color; + } + + let tooltip = null; + if (slots.tooltip === true) { + tooltip = name; + } else if (typeof slots.tooltip === 'string') { + tooltip = slots.tooltip; + } + + return relations.linkTemplate + .slots({ + path, + content, + color, + tooltip, + + attributes: slots.attributes, + hash: slots.hash, + }); + }, + }); + }, +} diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js new file mode 100644 index 00000000..d5d96726 --- /dev/null +++ b/src/content/dependencies/linkTrack.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, track) => + ({link: relation('linkThing', 'localized.track', track)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js new file mode 100644 index 00000000..bf4233fd --- /dev/null +++ b/src/content/dependencies/transformContent.js @@ -0,0 +1,325 @@ +import {marked} from 'marked'; + +import {bindFind} from '../../util/find.js'; +import {parseInput} from '../../util/replacer.js'; +import {replacerSpec} from '../../util/transform-content.js'; + +const linkThingRelationMap = { + album: 'linkAlbum', + albumCommentary: 'linkAlbumCommentary', + albumGallery: 'linkAlbumGallery', + artist: 'linkArtist', + artistGallery: 'linkArtistGallery', + flash: 'linkFlash', + groupInfo: 'linkGroup', + groupGallery: 'linkGroupGallery', + listing: 'linkListing', + newsEntry: 'linkNewsEntry', + staticPage: 'linkStaticPage', + tag: 'linkArtTag', + track: 'linkTrack', +}; + +const linkValueRelationMap = { + // media: 'linkPathFromMedia', + // root: 'linkPathFromRoot', + // site: 'linkPathFromSite', +}; + +const linkIndexRelationMap = { + // commentaryIndex: 'linkCommentaryIndex', + // flashIndex: 'linkFlashIndex', + // home: 'linkHome', + // listingIndex: 'linkListingIndex', + // newsIndex: 'linkNewsIndex', +}; + +function getPlaceholder(node, content) { + return {type: 'text', data: content.slice(node.i, node.iEnd)}; +} + +export default { + contentDependencies: [ + ...Object.values(linkThingRelationMap), + ...Object.values(linkValueRelationMap), + ...Object.values(linkIndexRelationMap), + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl(wikiData, content) { + const find = bindFind(wikiData); + + const parsedNodes = parseInput(content); + + return { + nodes: parsedNodes + .map(node => { + if (node.type !== 'tag') { + return node; + } + + const placeholder = getPlaceholder(node, content); + + const replacerKeyImplied = !node.data.replacerKey; + const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data; + + // TODO: We don't support recursive nodes like before, at the moment. Sorry! + // const replacerValue = transformNodes(node.data.replacerValue, opts); + const replacerValue = node.data.replacerValue[0].data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return placeholder; + } + + if (spec.link) { + let data = {key: spec.link}; + + determineData: { + // No value at all: this is an index link. + if (!replacerValue) { + break determineData; + } + + // Nothing to find: the link operates on a path or string, not a data object. + if (!spec.find) { + data.value = replacerValue; + break determineData; + } + + const thing = + find[spec.find]( + (replacerKeyImplied + ? replacerValue + : replacerKey + `:` + replacerValue), + wikiData); + + // Nothing was found: this is unexpected, so return placeholder. + if (!thing) { + return placeholder; + } + + // Something was found: the link operates on that thing. + data.thing = thing; + } + + const {transformName} = spec; + + // TODO: Again, no recursive nodes. Sorry! + // const enteredLabel = node.data.label && transformNode(node.data.label, opts); + const enteredLabel = node.data.label?.data; + const enteredHash = node.data.hash?.data; + + data.label = + enteredLabel ?? + (transformName && data.thing.name + ? transformName(data.thing.name, node, content) + : null); + + data.hash = enteredHash ?? null; + + return {i: node.i, iEnd: node.iEnd, type: 'link', data}; + } + + // This will be another {type: 'tag'} node which gets processed in + // generate. + return node; + }), + }; + }, + + data(sprawl, content) { + return { + content, + + nodes: + sprawl.nodes + .map(node => { + // Replace link nodes with a stub. It'll be replaced (by position) + // with an item from relations. + if (node.type === 'link') { + return {type: 'link'}; + } + + // Other nodes will get processed in generate. + return node; + }), + }; + }, + + relations(relation, sprawl, content) { + const {nodes} = sprawl; + + const relationOrPlaceholder = + (node, name, arg) => + (name + ? { + link: relation(name, arg), + label: node.data.label, + hash: node.data.hash, + } + : getPlaceholder(node, content)); + + return { + links: + nodes + .filter(({type}) => type === 'link') + .map(node => { + const {key, thing, value} = node.data; + + if (thing) { + return relationOrPlaceholder(node, linkThingRelationMap[key], thing); + } else if (value) { + return relationOrPlaceholder(node, linkValueRelationMap[key], value); + } else { + return relationOrPlaceholder(node, linkIndexRelationMap[key]); + } + }), + }; + }, + + generate(data, relations, {html, language}) { + let linkIndex = 0; + + // This array contains only straight text and link nodes, which are directly + // representable in html (so no further processing is needed on the level of + // individual nodes). + const contentFromNodes = + data.nodes.map(node => { + if (node.type === 'text') { + return {type: 'text', data: node.data}; + } + + if (node.type === 'link') { + const linkNode = relations.links[linkIndex++]; + if (linkNode.type === 'text') { + return {type: 'text', data: linkNode.data}; + } + + const {link, label, hash} = linkNode; + + return { + type: 'link', + data: link.slots({content: label, hash}), + }; + } + + if (node.type === 'tag') { + const {replacerKey, replacerValue} = node.data; + + const spec = replacerSpec[replacerKey]; + + if (!spec) { + return getPlaceholder(node, data.content); + } + + const {value: valueFn, html: htmlFn} = spec; + + const value = + (valueFn + ? valueFn(replacerValue) + : replacerValue); + + const contents = + (htmlFn + ? htmlFn(value, {html, language}) + : value); + + return {type: 'text', data: contents}; + } + + return getPlaceholder(node, data.content); + }); + + return html.template({ + annotation: `transformContent`, + + slots: { + mode: { + validate: v => v.is('inline', 'multiline', 'lyrics'), + default: 'multiline', + }, + }, + + content(slots) { + // In inline mode, no further processing is needed! + + if (slots.mode === 'inline') { + return html.tags(contentFromNodes.map(node => node.data)); + } + + // Multiline mode has a secondary processing stage where it's passed... + // through marked! Rolling your own Markdown only gets you so far :D + + const markedOptions = { + headerIds: false, + mangle: false, + }; + + // This is separated into its own function just since we're gonna reuse + // it in a minute if everything goes to heck in lyrics mode. + const transformMultiline = () => { + const markedInput = + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data; + } else { + return node.data.toString(); + } + }) + .join('') + + // Compress multiple line breaks into single line breaks. + .replace(/\n{2,}/g, '\n') + // Expand line breaks which don't follow a list. + .replace(/(?<!^ *-.*)\n+/gm, '\n\n') + // Expand line breaks which are at the end of a list. + .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n'); + + return marked.parse(markedInput, markedOptions); + } + + if (slots.mode === 'multiline') { + // Unfortunately, we kind of have to be super evil here and stringify + // the links, or else parse marked's output into html tags, which is + // very out of scope at the moment. + return transformMultiline(); + } + + // Lyrics mode goes through marked too, but line breaks are processed + // differently. Instead of having each line get its own paragraph, + // "adjacent" lines are joined together (with blank lines separating + // each verse/paragraph). + + if (slots.mode === 'lyrics') { + // If it looks like old data, using <br> instead of bunched together + // lines... then oh god... just use transformMultiline. Perishes. + if ( + contentFromNodes.some(node => + node.type === 'text' && + node.data.includes('<br')) + ) { + return transformMultiline(); + } + + // Lyrics mode is also evil for the same stringifying reasons as + // multiline. + return marked.parse( + contentFromNodes + .map(node => { + if (node.type === 'text') { + return node.data.replace(/\b\n\b/g, '<br>\n'); + } else { + return node.data.toString(); + } + }) + .join(''), + markedOptions); + } + }, + }); + }, +} diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js new file mode 100644 index 00000000..11281e75 --- /dev/null +++ b/src/content/util/getChronologyRelations.js @@ -0,0 +1,42 @@ +export default function getChronologyRelations(thing, { + contributions, + linkArtist, + linkThing, + getThings, +}) { + // One call to getChronologyRelations is considered "lumping" together all + // contributions as carrying equivalent meaning (for example, "artist" + // contributions and "contributor" contributions are bunched together in + // one call to getChronologyRelations, while "cover artist" contributions + // are a separate call). getChronologyRelations prevents duplicates that + // carry the same meaning by only using the first instance of each artist + // in the contributions array passed to it. It's expected that the string + // identifying which kind of contribution ("track" or "cover art") is + // shared and applied to all contributions, as providing them together + // in one call to getChronologyRelations implies they carry the same + // meaning. + + const artistsSoFar = new Set(); + + contributions = contributions.filter(({who}) => { + if (artistsSoFar.has(who)) { + return false; + } else { + artistsSoFar.add(who); + return true; + } + }); + + return contributions.map(({who}) => { + const things = Array.from(new Set(getThings(who))); + const index = things.indexOf(thing); + const previous = things[index - 1]; + const next = things[index + 1]; + return { + index: index + 1, + artistLink: linkArtist(who), + previousLink: previous ? linkThing(previous) : null, + nextLink: next ? linkThing(next) : null, + }; + }); +} diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js new file mode 100644 index 00000000..bae2c8c5 --- /dev/null +++ b/src/content/util/groupTracksByGroup.js @@ -0,0 +1,23 @@ +import {empty} from '../../util/sugar.js'; + +export default function groupTracksByGroup(tracks, groups) { + const lists = new Map(groups.map(group => [group, []])); + lists.set('other', []); + + for (const track of tracks) { + const group = groups.find(group => group.albums.includes(track.album)); + if (group) { + lists.get(group).push(track); + } else { + other.get('other').push(track); + } + } + + for (const [key, tracks] of lists.entries()) { + if (empty(tracks)) { + lists.delete(key); + } + } + + return lists; +} |