diff options
author | (quasar) nebula <towerofnix@gmail.com> | 2021-03-02 19:10:30 -0400 |
---|---|---|
committer | (quasar) nebula <towerofnix@gmail.com> | 2021-03-02 19:10:30 -0400 |
commit | d0d324f34a9ca399f8e9e8380353abe45f6688ff (patch) | |
tree | d9812be0d96f34760994ac7420d32c3dabe9e9b6 | |
parent | 1370490d576ded35004b741bec1e07dc8187cfe5 (diff) |
rework 90% all upd8 code to support localization
-rw-r--r-- | package-lock.json | 93 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | static/client.js | 8 | ||||
-rw-r--r-- | static/site.css | 5 | ||||
-rw-r--r-- | strings-default.json | 253 | ||||
-rw-r--r-- | upd8-util.js | 63 | ||||
-rwxr-xr-x | upd8.js | 4753 |
7 files changed, 3407 insertions, 1769 deletions
diff --git a/package-lock.json b/package-lock.json index 74ae313e..76f76678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,75 @@ { - "name": "hs-music-wiki", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "fix-whitespace": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.3.tgz", - "integrity": "sha1-eTVKgWzBKguT+HTkxeA8S5mR9pU=" + "name": "hsmusic-wiki", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "hsmusic-wiki", + "version": "0.1.0", + "license": "GPL-3.0", + "dependencies": { + "fix-whitespace": "^1.0.4", + "he": "^1.2.0", + "mkdirp": "^0.5.5" + }, + "bin": { + "hsmusic": "upd8.js" + } + }, + "node_modules/fix-whitespace": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz", + "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ==" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + } }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "requires": { - "minimist": "^1.2.5" - } + "dependencies": { + "fix-whitespace": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz", + "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ==" + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + } } - } } diff --git a/package.json b/package.json index a0495520..aaaf8f68 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "fix-whitespace": "^1.0.4", + "he": "^1.2.0", "mkdirp": "^0.5.5" }, "license": "GPL-3.0" diff --git a/static/client.js b/static/client.js index 83c82a29..9c0e621a 100644 --- a/static/client.js +++ b/static/client.js @@ -10,10 +10,10 @@ let officialAlbumData, fandomAlbumData, artistNames; let ready = false; -function rebase(href) { - const relative = document.documentElement.dataset.rebase; +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = document.documentElement.dataset[rebaseKey]; if (relative) { - return relative + "/" + href; + return relative + href; } else { return href; } @@ -162,7 +162,7 @@ const elements2 = document.getElementsByClassName('js-show-once-data'); for (const element of elements1) element.style.display = 'block'; -fetch(rebase('data.json')).then(data => data.json()).then(data => { +fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => { albumData = data.albumData; artistData = data.artistData; flashData = data.flashData; diff --git a/static/site.css b/static/site.css index bf3303a3..3f66c9ea 100644 --- a/static/site.css +++ b/static/site.css @@ -94,8 +94,7 @@ a:hover { font-weight: normal; } -#header > h2 a.current, -#header > h2.highlight-last-link > a:last-of-type { +#header > h2 a.current { font-weight: 800; } @@ -462,6 +461,8 @@ h1 { .grid-actions > .grid-item { flex-basis: unset !important; margin: 5px; + --primary-color: inherit !important; + --dim-color: inherit !important; } .grid-item { diff --git a/strings-default.json b/strings-default.json new file mode 100644 index 00000000..20806a5f --- /dev/null +++ b/strings-default.json @@ -0,0 +1,253 @@ +{ + "meta.languageCode": "en", + "count.tracks": "{TRACKS}", + "count.tracks.withUnit.singular": "{TRACKS} track", + "count.tracks.withUnit.plural": "{TRACKS} tracks", + "count.albums": "{ALBUMS}", + "count.albums.withUnit.singular": "{ALBUMS} album", + "count.albums.withUnit.plural": "{ALBUMS} albums", + "count.commentaryEntries": "{ENTRIES}", + "count.commentaryEntries.withUnit.singular": "{ENTRIES} entry", + "count.commentaryEntries.withUnit.plural": "{ENTRIES} entries", + "count.contributions": "{CONTRIBUTIONS}", + "count.contributions.withUnit.singular": "{CONTRIBUTIONS} contribution", + "count.contributions.withUnit.plural": "{CONTRIBUTIONS} contributions", + "count.coverArts": "{COVER_ARTS}", + "count.coverArts.withUnit.singular": "{COVER_ARTS} cover art", + "count.coverArts.withUnit.plural": "{COVER_ARTS} cover arts", + "count.timesReferenced": "{TIMES_REFERENCED}", + "count.timesReferenced.withUnit.singular": "{TIMES_REFERENCED} time referenced", + "count.timesReferenced.withUnit.plural": "{TIMES_REFERENCED} times referenced", + "count.timesUsed": "{TIMES_USED}", + "count.timesUsed.withUnit.singular": "used {TIMES_USED} time", + "count.timesUsed.withUnit.plural": "used {TIMES_USED} times", + "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}", + "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours", + "count.duration.minutes": "{MINUTES}:{SECONDS}", + "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes", + "count.duration.approximate": "~{DURATION}", + "count.duration.missing": "_:__", + "count.words": "{WORDS}", + "count.words.thousand": "{WORDS}k", + "count.words.withUnit.singular": "{WORDS} word", + "count.words.withUnit.plural": "{WORDS} words", + "releaseInfo.by": "By {ARTISTS}.", + "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", + "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", + "releaseInfo.released": "Released {DATE}.", + "releaseInfo.artReleased": "Art released {DATE}.", + "releaseInfo.duration": "Duration: {DURATION}.", + "releaseInfo.viewCommentary": "View {LINK}!", + "releaseInfo.viewCommentary.link": "commentary page", + "releaseInfo.listenOn": "Listen on {LINKS}.", + "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.", + "releaseInfo.visitOn": "Visit on {LINKS}.", + "releaseInfo.playOn": "Play on {LINKS}.", + "releaseInfo.alsoReleasedAs": "Also released as:", + "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", + "releaseInfo.contributors": "Contributors:", + "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:", + "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:", + "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:", + "releaseInfo.flashesThatFeature.item": "{FLASH}", + "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})", + "releaseInfo.lyrics": "Lyrics:", + "releaseInfo.artistCommentary": "Artist commentary:", + "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", + "releaseInfo.artTags": "Tags:", + "releaseInfo.note": "Note:", + "trackList.group": "{GROUP} ({DURATION}):", + "trackList.item.withDuration": "({DURATION}) {TRACK}", + "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", + "trackList.item.withArtists": "{TRACK} {BY}", + "trackList.item.withArtists.by": "by {ARTISTS}", + "trackList.item.rerelease": "{TRACK} (re-release)", + "misc.alt.albumCover": "album cover", + "misc.alt.trackCover": "track cover", + "misc.alt.artistAvatar": "artist avatar", + "misc.alt.flashArt": "flash art", + "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)", + "misc.chronology.heading": "{INDEX} {WORD} by {ARTIST}", + "misc.chronology.coverArt": "cover art", + "misc.chronology.flash": "flash/game", + "misc.chronology.track": "track", + "misc.external.bandcamp": "Bandcamp", + "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})", + "misc.external.deviantart": "DeviantArt", + "misc.external.instagram": "Instagram", + "misc.external.mastodon": "Mastodon", + "misc.external.mastodon.domain": "Mastodon ({DOMAIN})", + "misc.external.patreon": "Patreon", + "misc.external.poetryFoundation": "Poetry Foundation", + "misc.external.soundcloud": "SoundCloud", + "misc.external.tumblr": "Tumblr", + "misc.external.twitter": "Twitter", + "misc.external.wikipedia": "Wikipedia", + "misc.external.youtube": "YouTube", + "misc.external.youtube.playlist": "YouTube (playlist)", + "misc.external.youtube.fullAlbum": "YouTube (full album)", + "misc.external.flash.bgreco": "{LINK} (HQ Audio)", + "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})", + "misc.external.flash.homestuck.secret": "{LINK} (secret page)", + "misc.external.flash.youtube": "{LINK} (on any device)", + "misc.nav.previous": "Previous", + "misc.nav.next": "Next", + "misc.nav.info": "Info", + "misc.nav.gallery": "Gallery", + "misc.skippers.skipToContent": "Skip to content", + "misc.skippers.skipToSidebar": "Skip to sidebar", + "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)", + "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)", + "misc.skippers.skipToFooter": "Skip to footer", + "misc.jumpTo": "Jump to:", + "misc.jumpTo.withLinks": "Jump to: {LINKS}.", + "misc.contentWarnings": "cw: {WARNINGS}", + "misc.contentWarnings.reveal": "click to show", + "misc.albumGridDetails": "({TRACKS}, {TIME})", + "homepage.title": "{TITLE}", + "homepage.news.title": "News", + "homepage.news.entry.viewRest": "(View rest of entry!)", + "albumSidebar.trackList.group": "{GROUP}", + "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", + "albumSidebar.trackList.item": "{TRACK}", + "albumSidebar.groupBox.title": "{GROUP}", + "albumSidebar.groupBox.next": "Next: {ALBUM}", + "albumSidebar.groupBox.previous": "Previous: {ALBUM}", + "albumPage.title": "{ALBUM}", + "albumPage.nav.album": "{ALBUM}", + "albumPage.nav.randomTrack": "Random Track", + "albumCommentaryPage.title": "{ALBUM} - Commentary", + "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.", + "albumCommentaryPage.nav.album": "Album: {ALBUM}", + "albumCommentaryPage.entry.title.albumCommentary": "Album commentary", + "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}", + "artistPage.title": "{ARTIST}", + "artistPage.creditList.album": "{ALBUM}", + "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})", + "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", + "artistPage.creditList.flashAct": "{ACT}", + "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", + "artistPage.creditList.entry.track": "{TRACK}", + "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", + "artistPage.creditList.entry.album.coverArt": "(cover art)", + "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)", + "artistPage.creditList.entry.album.commentary": "(album commentary)", + "artistPage.creditList.entry.flash": "{FLASH}", + "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)", + "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})", + "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})", + "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})", + "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", + "artistPage.trackList.title": "Tracks", + "artistPage.unreleasedTrackList.title": "Unreleased Tracks", + "artistPage.artList.title": "Art", + "artistPage.flashList.title": "Flashes & Games", + "artistPage.commentaryList.title": "Commentary", + "artistPage.viewArtGallery": "View {LINK}!", + "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:", + "artistPage.viewArtGallery.link": "art gallery", + "artistPage.nav.artist": "Artist: {ARTIST}", + "artistGalleryPage.title": "{ARTIST} - Gallery", + "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.", + "commentaryIndex.title": "Commentary", + "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.", + "commentaryIndex.albumList.title": "Choose an album:", + "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})", + "flashIndex.title": "Flashes & Games", + "flashPage.title": "{FLASH}", + "flashPage.nav.flash": "{FLASH}", + "groupSidebar.title": "Groups", + "groupSidebar.groupList.category": "{CATEGORY}", + "groupSidebar.groupList.item": "{GROUP}", + "groupPage.nav.group": "Group: {GROUP}", + "groupInfoPage.title": "{GROUP}", + "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:", + "groupInfoPage.viewAlbumGallery.link": "album gallery", + "groupInfoPage.albumList.title": "Albums", + "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}", + "groupGalleryPage.title": "{GROUP} - Gallery", + "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.", + "listingIndex.title": "Listings", + "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.", + "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!", + "listingPage.listAlbums.byName.title": "Albums - by Name", + "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", + "listingPage.listAlbums.byTracks.title": "Albums - by Tracks", + "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})", + "listingPage.listAlbums.byDuration.title": "Albums - by Duration", + "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})", + "listingPage.listAlbums.byDate.title": "Albums - by Date", + "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", + "listingPage.listArtists.byName.title": "Artists - by Name", + "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byContribs.title": "Artists - by Contributions", + "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries", + "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", + "listingPage.listArtists.byDuration.title": "Artists - by Duration", + "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", + "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", + "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})", + "listingPage.listGroups.byName.title": "Groups - by Name", + "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", + "listingPage.listGroups.byName.item.gallery": "Gallery", + "listingPage.listGroups.byCategory.title": "Groups - by Category", + "listingPage.listGroups.byCategory.category": "{CATEGORY}", + "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})", + "listingPage.listGroups.byCategory.group.gallery": "Gallery", + "listingPage.listGroups.byAlbums.title": "Groups - by Albums", + "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", + "listingPage.listGroups.byTracks.title": "Groups - by Tracks", + "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})", + "listingPage.listGroups.byDuration.title": "Groups - by Duration", + "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})", + "listingPage.listGroups.byLatest.title": "Groups - by Latest Album", + "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})", + "listingPage.listTracks.byName.title": "Tracks - by Name", + "listingPage.listTracks.byName.item": "{TRACK}", + "listingPage.listTracks.byAlbum.title": "Tracks - by Album", + "listingPage.listTracks.byAlbum.album": "{ALBUM}", + "listingPage.listTracks.byAlbum.track": "{TRACK}", + "listingPage.listTracks.byDate.title": "Tracks - by Date", + "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.byDate.track": "{TRACK}", + "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)", + "listingPage.listTracks.byDuration.title": "Tracks - by Duration", + "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})", + "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)", + "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}", + "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})", + "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced", + "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})", + "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)", + "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})", + "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)", + "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})", + "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})", + "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics", + "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})", + "listingPage.listTracks.withLyrics.track": "{TRACK}", + "listingPage.listTags.byName.title": "Tags - by Name", + "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})", + "listingPage.listTags.byUses.title": "Tags - by Uses", + "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})", + "listingPage.misc.trackContributors": "Track Contributors", + "listingPage.misc.artContributors": "Art Contributors", + "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", + "newsIndex.title": "News", + "newsIndex.entry.viewRest": "(View rest of entry!)", + "newsEntryPage.title": "{ENTRY}", + "newsEntryPage.published": "(Published {DATE}.)", + "newsEntryPage.nav.news": "News", + "newsEntryPage.nav.entry": "{DATE}: {ENTRY}", + "redirectPage.title": "Moved to {TITLE}", + "redirectPage.infoLine": "This page has been moved to {TARGET}.", + "tagPage.title": "{TAG}", + "tagPage.infoLine": "Appears in {COVER_ARTS}.", + "tagPage.nav.tag": "Tag: {TAG}", + "trackPage.title": "{TRACK}", + "trackPage.nav.track": "{TRACK}", + "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", + "trackPage.nav.random": "Random" +} diff --git a/upd8-util.js b/upd8-util.js index b18c0529..e188ed4c 100644 --- a/upd8-util.js +++ b/upd8-util.js @@ -306,3 +306,66 @@ module.exports.mapInPlace = (array, fn) => array.splice(0, array.length, ...arra module.exports.filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n'); module.exports.unique = arr => Array.from(new Set(arr)); + +const logColor = color => (literals, ...values) => { + const w = s => process.stdout.write(s); + w(`\x1b[${color}m`); + for (let i = 0; i < literals.length; i++) { + w(literals[i]); + if (values[i] !== undefined) { + w(`\x1b[1m`); + w(String(values[i])); + w(`\x1b[0;${color}m`); + } + } + w(`\x1b[0m\n`); +}; + +module.exports.logWarn = logColor(33); +module.exports.logError = logColor(31); + +module.exports.sortByName = (a, b) => { + let an = a.name.toLowerCase(); + let bn = b.name.toLowerCase(); + if (an.startsWith('the ')) an = an.slice(4); + if (bn.startsWith('the ')) bn = bn.slice(4); + return an < bn ? -1 : an > bn ? 1 : 0; +}; + +module.exports.chunkByConditions = function(array, conditions) { + if (array.length === 0) { + return []; + } else if (conditions.length === 0) { + return [array]; + } + + const out = []; + let cur = [array[0]]; + for (let i = 1; i < array.length; i++) { + const item = array[i]; + const prev = array[i - 1]; + let chunk = false; + for (const condition of conditions) { + if (condition(item, prev)) { + chunk = true; + break; + } + } + if (chunk) { + out.push(cur); + cur = [item]; + } else { + cur.push(item); + } + } + out.push(cur); + return out; +}; + +module.exports.chunkByProperties = function(array, properties) { + return module.exports.chunkByConditions(array, properties.map(p => (a, b) => a[p] !== b[p] || a[p] != b[p])) + .map(chunk => ({ + ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])), + chunk + })); +}; diff --git a/upd8.js b/upd8.js index a03796b3..36f634a6 100755 --- a/upd8.js +++ b/upd8.js @@ -79,6 +79,9 @@ const fixWS = require('fix-whitespace'); // exactly what we do here. const mkdirp = util.promisify(require('mkdirp')); +// It stands for "HTML Entities", apparently. Cursed. +const he = require('he'); + // This is the dum8est name for a function possi8le. Like, SURE, fine, may8e // the UNIX people had some valid reason to go with the weird truncated // lowercased convention they did. 8ut Node didn't have to ALSO use that @@ -104,15 +107,20 @@ const unlink = util.promisify(fs.unlink); const { cacheOneArg, + chunkByConditions, + chunkByProperties, curry, decorateTime, filterEmptyLines, joinNoOxford, mapInPlace, + logWarn, + logError, parseOptions, progressPromiseAll, queue, s, + sortByName, splitArray, th, unique @@ -120,7 +128,7 @@ const { const C = require('./common/common'); -const CACHEBUST = 2; +const CACHEBUST = 3; const WIKI_INFO_FILE = 'wiki-info.txt'; const HOMEPAGE_INFO_FILE = 'homepage.txt'; @@ -130,6 +138,7 @@ const NEWS_DATA_FILE = 'news.txt'; const TAG_DATA_FILE = 'tags.txt'; const GROUP_DATA_FILE = 'groups.txt'; const STATIC_PAGE_DATA_FILE = 'static-pages.txt'; +const DEFAULT_STRINGS_FILE = 'strings-default.json'; const CSS_FILE = 'site.css'; @@ -141,6 +150,7 @@ const CSS_FILE = 'site.css'; // passing around a data object containing all this, though. let dataPath; let mediaPath; +let langPath; let outputPath; let wikiInfo; @@ -148,22 +158,433 @@ let homepageInfo; let albumData; let trackData; let flashData; +let flashActData; let newsData; let tagData; let groupData; +let groupCategoryData; let staticPageData; let artistNames; let artistData; +let artistAliasData; let officialAlbumData; let fandomAlbumData; -let justEverythingMan; // tracks, albums, flashes -- don't forget to upd8 getHrefOfAnythingMan! +let justEverythingMan; // tracks, albums, flashes -- don't forget to upd8 toAnythingMan! let justEverythingSortedByArtDateMan; let contributionData; let queueSize; +let languages; + +const urlSpec = { + localized: { + home: '', + site: '<>', + + album: 'album/<>/', + albumCommentary: 'commentary/album/<>/', + + artist: 'artist/<>/', + artistGallery: 'artist/<>/gallery/', + + commentaryIndex: 'commentary/', + + flashIndex: 'flash/', + flash: 'flash/<>/', + + groupInfo: 'group/<>/', + groupGallery: 'group/<>/gallery/', + + listingIndex: 'list/', + listing: 'list/<>/', + + newsIndex: 'news/', + newsEntry: 'news/<>/', + + staticPage: '<>/', + tag: 'tag/<>/', + track: 'track/<>/' + }, + + shared: { + root: '<>', + + commonFile: 'common/<>', + staticFile: 'static/<>', + + media: 'media/<>', + albumCover: 'media/album-art/<>/cover.jpg', + albumWallpaper: 'media/album-art/<>/bg.jpg', + trackCover: 'media/album-art/<>/<>.jpg', + artistAvatar: 'media/artist-avatar/<>.jpg', + flashArt: 'media/flash-art/<>.jpg' + } +}; + +const linkHelper = (hrefFn, {color = true} = {}) => + (thing, { + strings, to, + text = '', + class: className = '', + hash = '' + }) => `<a href="${hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : '')}" ${attributes({ + style: color ? getLinkThemeString(thing) : '', + class: className + })}>${text || thing.name}</a>`; + +const linkDirectory = (key, conf) => linkHelper(({directory}, {to}) => to[key](directory), conf); +const linkPathname = (key, conf) => linkHelper((pathname, {to}) => to[key](pathname), conf); +const linkIndex = (key, conf) => linkHelper((_, {to}) => to[key](''), conf); + +const link = { + album: linkDirectory('album'), + albumCommentary: linkDirectory('albumCommentary'), + artist: linkDirectory('artist', {color: false}), + artistGallery: linkDirectory('artistGallery', {color: false}), + commentaryIndex: linkIndex('commentaryIndex', {color: false}), + flashIndex: linkIndex('flashIndex', {color: false}), + flash: linkDirectory('flash'), + groupInfo: linkDirectory('groupInfo'), + groupGallery: linkDirectory('groupGallery'), + home: linkIndex('home', {color: false}), + listingIndex: linkIndex('listingIndex'), + listing: linkDirectory('listing'), + newsIndex: linkIndex('newsIndex', {color: false}), + newsEntry: linkDirectory('newsEntry', {color: false}), + staticPage: linkDirectory('staticPage', {color: false}), + tag: linkDirectory('tag'), + track: linkDirectory('track'), + + media: linkPathname('media', {color: false}), + root: linkPathname('root', {color: false}), + site: linkPathname('site', {color: false}) +}; + +function generateURLs(fromPath) { + const helper = toPath => { + let argIndex = 0; + const relative = (path.relative(fromPath, toPath.replaceAll('<>', () => `<${argIndex++}>`)) + + (toPath.endsWith('/') ? '/' : '')); + return (...args) => relative.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]); + }; + + return Object.fromEntries(Object.entries({...urlSpec.localized, ...urlSpec.shared}).map( + ([key, path]) => [key, helper(path)] + )); +} + +const urls = Object.fromEntries(Object.entries(urlSpec.localized).map( + ([key, path]) => [key, generateURLs(path)] +)); + +const searchHelper = (keys, dataFn, findFn) => ref => { + if (!ref) return null; + ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), ''); + const found = findFn(ref, dataFn()); + if (!found) { + logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`; + } + return found; +}; + +const matchDirectory = (ref, data) => data.find(({ directory }) => directory === ref); + +const matchDirectoryOrName = (ref, data) => { + let thing; + + thing = matchDirectory(ref, data); + if (thing) return thing; + + thing = data.find(({ name }) => name === ref); + if (thing) return thing; + + thing = data.find(({ name }) => name.toLowerCase() === ref.toLowerCase()); + if (thing) { + logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`; + return thing; + } + + return null; +}; + +const search = { + album: searchHelper(['album', 'album-commentary'], () => albumData, matchDirectoryOrName), + artist: searchHelper(['artist', 'artist-gallery'], () => artistData, matchDirectoryOrName), + flash: searchHelper(['flash'], () => flashData, matchDirectory), + group: searchHelper(['group', 'group-gallery'], () => groupData, matchDirectoryOrName), + listing: searchHelper(['listing'], () => listingSpec, matchDirectory), + newsEntry: searchHelper(['news-entry'], () => newsData, matchDirectory), + staticPage: searchHelper(['static'], () => staticPageData, matchDirectory), + tag: searchHelper(['tag'], () => tagData, (ref, data) => + matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)), + track: searchHelper(['track'], () => trackData, matchDirectoryOrName) +}; + +// Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le +// name and not one I intend on using, thank you very much. (Don't even get me +// started on """"a11y"""".) +// +// All the default strings are in strings-default.json, if you're curious what +// those actually look like. Pretty much it's "I like {ANIMAL}" for example. +// For each language, the o8ject gets turned into a single function of form +// f(key, {args}). It searches for a key in the o8ject and uses the string it +// finds (or the one in strings-default.json) as a templ8 evaluated with the +// arguments passed. (This function gets treated as an o8ject too; it gets +// the language code attached.) +// +// The function's also responsi8le for getting rid of dangerous characters +// (quotes and angle tags), though only within the templ8te (not the args), +// and it converts the keys of the arguments o8ject from camelCase to +// CONSTANT_CASE too. +function genStrings(stringsJSON, defaultJSON = null) { + // genStrings will only 8e called once for each language, and it happens + // right at the start of the program (or at least 8efore 8uilding pages). + // So, now's a good time to valid8te the strings and let any warnings be + // known. + + // May8e contrary to the argument name, the arguments should 8e o8jects, + // not actual JSON-formatted strings! + if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) { + return {error: `Expected an object (parsed JSON) for stringsJSON.`}; + } + if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS. + return {error: `Expected an object (parsed JSON) or null for defaultJSON.`}; + } + + // All languages require a language code. + const code = stringsJSON['meta.languageCode']; + if (!code) { + return {error: `Missing language code.`}; + } + if (typeof code !== 'string') { + return {error: `Expected language code to be a string.`}; + } + + // Every value on the provided o8ject should be a string. + // (This is lazy, but we only 8other checking this on stringsJSON, on the + // assumption that defaultJSON was passed through this function too, and so + // has already been valid8ted.) + { + let err = false; + for (const [ key, value ] of Object.entries(stringsJSON)) { + if (typeof value !== 'string') { + logError`(${code}) The value for ${key} should be a string.`; + err = true; + } + } + if (err) { + return {error: `Expected all values to be a string.`}; + } + } + + // Checking is generally done against the default JSON, so we'll skip out + // if that isn't provided (which should only 8e the case when it itself is + // 8eing processed as the first loaded language). + if (defaultJSON) { + // Warn for keys that are missing or unexpected. + const expectedKeys = Object.keys(defaultJSON); + const presentKeys = Object.keys(stringsJSON); + for (const key of presentKeys) { + if (!expectedKeys.includes(key)) { + logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`; + } + } + for (const key of expectedKeys) { + if (!presentKeys.includes(key)) { + logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`; + } + } + } + + // Valid8tion is complete, 8ut We can still do a little caching to make + // repeated actions faster. + + // We're gonna 8e mut8ting the strings dictionary o8ject from here on out. + // We make a copy so we don't mess with the one which was given to us. + stringsJSON = Object.assign({}, stringsJSON); + + // Preemptively pass everything through HTML encoding. This will prevent + // strings from embedding HTML tags or accidentally including characters + // that throw HTML parsers off. + for (const key of Object.keys(stringsJSON)) { + stringsJSON[key] = escapeAttributeValue(stringsJSON[key]); + } + + // It's time to cre8te the actual langauge function! + + // In the function, we don't actually distinguish 8etween the primary and + // default (fall8ack) strings - any relevant warnings have already 8een + // presented a8ove, at the time the language JSON is processed. Now we'll + // only 8e using them for indexing strings to use as templ8tes, and we can + // com8ine them for that. + const stringIndex = Object.assign({}, defaultJSON, stringsJSON); + + // We do still need the list of valid keys though. That's 8ased upon the + // default strings. (Or stringsJSON, 8ut only if the defaults aren't + // provided - which indic8tes that the single o8ject provided *is* the + // default.) + const validKeys = Object.keys(defaultJSON || stringsJSON); + + const invalidKeysFound = []; + + const strings = (key, args = {}) => { + // Ok, with the warning out of the way, it's time to get to work. + // First make sure we're even accessing a valid key. (If not, return + // an error string as su8stitute.) + if (!validKeys.includes(key)) { + // We only want to warn a8out a given key once. More than that is + // just redundant! + if (!invalidKeysFound.includes(key)) { + invalidKeysFound.push(key); + logError`(${code}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`; + } + return `MISSING: ${key}`; + } + + const template = stringIndex[key]; + + // Convert the keys on the args dict from camelCase to CONSTANT_CASE. + // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut + // like, who cares, dude?) Also, this is an array, 8ecause it's handy + // for the iterating we're a8out to do. + const processedArgs = Object.entries(args) + .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]); + + // Replacement time! Woot. Reduce comes in handy here! + const output = processedArgs.reduce( + (x, [ k, v ]) => x.replaceAll(`{${k}}`, v), + template); + + // Post-processing: if any expected arguments *weren't* replaced, that + // is almost definitely an error. + if (output.match(/\{[A-Z_]+\}/)) { + logError`(${code}) Args in ${key} were missing - output: ${output}`; + } + + return output; + }; + + // And lastly, we add some utility stuff to the strings function. + + // Store the language code, for convenience of access. + strings.code = code; + + // Store the strings dictionary itself, also for convenience. + strings.json = stringsJSON; + + const bindUtilities = (obj, bind) => Object.fromEntries(Object.entries(obj).map( + ([ key, fn ]) => [key, (value, opts = {}) => fn(value, {...bind, ...opts})] + )); + + // There are a 8unch of handy count functions which expect a strings value; + // for a more terse syntax, we'll stick 'em on the strings function itself, + // with automatic 8inding for the strings argument. + strings.count = bindUtilities(count, {strings}); + + // The link functions also expect the strings o8ject(*). May as well hand + // 'em over here too! Keep in mind they still expect {to} though, and that + // isn't something we have access to from this scope (so calls such as + // strings.link.album(...) still need to provide it themselves). + // + // (*) At time of writing, it isn't actually used for anything, 8ut future- + // proofing, ok???????? + strings.link = bindUtilities(link, {strings}); + + // List functions, too! + strings.list = bindUtilities(list, {strings}); + + return strings; +}; + +const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings( + (unit + ? `count.${stringKey}.withUnit` + (value === 1 + ? '.singular' + : '.plural') + : `count.${stringKey}`), + {[argName]: value}); + +const count = { + date: (date, {strings}) => { + // TODO: Localize. + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ] + date = new Date(date); + return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}` + }, + + dateRange: ([startDate, endDate], {strings}) => { + // TODO: Localize. + return (startDate === endDate + ? count.date(startDate, {strings}) + : `${count.date(startDate, {strings})} to ${count.date(endDate, {strings})}`); + }, + + duration: (secTotal, {strings, approximate = false, unit = false}) => { + if (secTotal === 0) { + return strings('count.duration.missing'); + } + + const hour = Math.floor(secTotal / 3600); + const min = Math.floor((secTotal - hour * 3600) / 60); + const sec = Math.floor(secTotal - hour * 3600 - min * 60); + + const pad = val => val.toString().padStart(2, '0'); + + const stringSubkey = unit ? '.withUnit' : ''; + + const duration = (hour > 0 + ? strings('count.duration.hours' + stringSubkey, { + hours: hour, + minutes: pad(min), + seconds: pad(sec) + }) + : strings('count.duration.minutes' + stringSubkey, { + minutes: min, + seconds: pad(sec) + })); + + return (approximate + ? strings('count.duration.approximate', {duration}) + : duration); + }, + + index: (value, {strings}) => { + // TODO: Localize. + return th(value); + }, + + words: (value, {strings, unit = false}) => { + const words = (value > 1000 + ? strings('count.words.thousand', {words: Math.floor(value / 100) / 10}) + : strings('count.words', {words: value})); + + return (unit + ? countHelper('words')(words, {strings, unit: true}) + : words); + }, + + albums: countHelper('albums'), + commentaryEntries: countHelper('commentaryEntries', 'entries'), + contributions: countHelper('contributions'), + coverArts: countHelper('coverArts'), + timesReferenced: countHelper('timesReferenced'), + timesUsed: countHelper('timesUsed'), + tracks: countHelper('tracks') +}; + +const list = { + // TODO: Localize. + comma: (arr, {strings}) => arr.join(', '), + or: (arr, {strings}) => joinNoOxford(arr, 'or'), + and: (arr, {strings}) => joinNoOxford(arr, 'and') +}; + // Note there isn't a 'find track data files' function. I plan on including the // data for all tracks within an al8um collected in the single metadata file // for that al8um. Otherwise there'll just 8e way too many files, and I'd also @@ -171,9 +592,9 @@ let queueSize; // only the track listing, not track data itself), and dealing with errors of // missing track files (or track files which are not linked to al8ums). All a // 8unch of stuff that's a pain to deal with for no apparent 8enefit. -async function findAlbumDataFiles(albumDirectory) { - return (await readdir(path.join(albumDirectory))) - .map(albumFile => path.join(albumDirectory, albumFile)); +async function findFiles(dataPath) { + return (await readdir(dataPath)) + .map(file => path.join(dataPath, file)); } function* getSections(lines) { @@ -288,104 +709,154 @@ function getMultilineField(lines, name) { return listLines.map(line => line.slice(4)).join('\n'); }; -function transformInline(text) { - return text.replace(/\[\[(album:|album-commentary:|artist:|flash:|track:|tag:|group:)?(.+?)\]\]/g, (match, category, ref, offset) => { - if (category === 'album:') { - const album = getLinkedAlbum(ref); - if (album) { - return fixWS` - <a href="${C.ALBUM_DIRECTORY}/${album.directory}/" style="${getLinkThemeString(album)}">${album.name}</a> - `; - } else { - console.warn(`\x1b[33mThe linked album ${match} does not exist!\x1b[0m`); - return ref; - } - } else if (category === 'album-commentary:') { - const album = getLinkedAlbum(ref); - if (filterAlbumsByCommentary().includes(album)) { - return fixWS` - <a href="${C.COMMENTARY_DIRECTORY}/${C.ALBUM_DIRECTORY}/${album.directory}/" style="${getLinkThemeString(album)}">${album.name} (commentary)</a> - `; - } else { - console.warn(`\x1b[33mThe linked album ${match} does not exist or has no commentary!\x1b[0m`); - return ref; - } - } else if (category === 'artist:') { - const artist = getLinkedArtist(ref); - if (artist) { - return `<a href="${C.ARTIST_DIRECTORY}/${C.getArtistDirectory(artist.name)}/">${artist.name}</a>`; - } else { - console.warn(`\x1b[33mThe linked artist ${artist} does not exist!\x1b[0m`); - return ref; - } - } else if (category === 'flash:') { - const flash = getLinkedFlash(ref); - if (flash) { - let name = flash.name; - const nextCharacter = text[offset + match.length]; - const lastCharacter = name[name.length - 1]; - if ( - ![' ', '\n', '<'].includes(nextCharacter) && - lastCharacter === '.' - ) { - name = name.slice(0, -1); - } - return getFlashLinkHTML(flash, name); +const replacerSpec = { + 'album': { + search: 'album', + link: 'album' + }, + 'album-commentary': { + search: 'album', + link: 'albumCommentary' + }, + 'artist': { + search: 'artist', + link: 'artist' + }, + 'artist-gallery': { + search: 'artist', + link: 'artistGallery' + }, + 'commentary-index': { + search: null, + link: 'commentaryIndex' + }, + 'flash': { + search: 'flash', + link: 'flash', + transformName(name, search, offset, text) { + const nextCharacter = text[offset + search.length]; + const lastCharacter = name[name.length - 1]; + if ( + ![' ', '\n', '<'].includes(nextCharacter) && + lastCharacter === '.' + ) { + return name.slice(0, -1); } else { - console.warn(`\x1b[33mThe linked flash ${match} does not exist!\x1b[0m`); - return ref; - } - } else if (category === 'track:') { - const track = getLinkedTrack(ref); - if (track) { - return fixWS` - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - `; - } else { - console.warn(`\x1b[33mThe linked track ${match} does not exist!\x1b[0m`); - return ref; - } - } else if (category === 'tag:') { - const tag = getLinkedTag(ref); - if (tag) { - return fixWS` - <a href="${C.TAG_DIRECTORY}/${tag.directory}/" style="${getLinkThemeString(tag)}">${tag.name}</a> - `; - } else { - console.warn(`\x1b[33mThe linked tag ${match} does not exist!\x1b[0m`); - return ref; - } - } else if (category === 'group:') { - const group = getLinkedGroup(ref); - if (group) { - return fixWS` - <a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a> - `; - } else { - console.warn(`\x1b[33mThe linked group ${group} does not exist!\x1b[0m`); - return ref; - } - } else { - const track = getLinkedTrack(ref); - if (track) { - let name = ref.match(/(.*):/); - if (name) { - name = name[1]; - } else { - name = track.name; - } - return fixWS` - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${name}</a> - `; - } else { - console.warn(`\x1b[33mThe linked track ${match} does not exist!\x1b[0m`); - return ref; + return name; } } + }, + 'group': { + search: 'group', + link: 'groupInfo' + }, + 'group-gallery': { + search: 'group', + link: 'groupGallery' + }, + 'listing-index': { + search: null, + link: 'listingIndex' + }, + 'listing': { + search: 'listing', + link: 'listing' + }, + 'media': { + search: null, + link: 'media' + }, + 'news-index': { + search: null, + link: 'newsIndex' + }, + 'news-entry': { + search: 'newsEntry', + link: 'newsEntry' + }, + 'root': { + search: null, + link: 'root' + }, + 'site': { + search: null, + link: 'site' + }, + 'static': { + search: 'staticPage', + link: 'staticPage' + }, + 'tag': { + search: 'tag', + link: 'tag' + }, + 'track': { + search: 'track', + link: 'track' + } +}; + +{ + let error = false; + for (const [key, {link: linkKey, search: searchKey}] of Object.entries(replacerSpec)) { + if (!link[linkKey]) { + logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`; + error = true; + } + if (searchKey && !search[searchKey]) { + logError`The replacer spec ${key} has invalid search key ${searchKey}! Specify it in search specs or fix typo.`; + error = true; + } + } + if (error) process.exit(); + + const categoryPart = Object.keys(replacerSpec).join('|'); + transformInline.regexp = new RegExp(String.raw`\[\[((${categoryPart}):)?(.+?)((?<! )#.+?)?(\|(.+?))?\]\]`, 'g'); +} + +function transformInline(text, {strings, to}) { + return text.replace(transformInline.regexp, (match, _1, category, ref, hash, _2, enteredName, offset) => { + if (!category) { + category = 'track'; + } + + const { + search: searchKey, + link: linkKey, + transformName = null + } = replacerSpec[category]; + + const thing = (searchKey + ? search[searchKey](ref) + : { + directory: ref.replace(category + ':', ''), + name: null + }); + + if (!thing) { + logWarn`The link ${match} does not match anything!`; + return match; + } + + const label = (enteredName + || transformName && transformName(thing.name, match, offset, text) + || thing.name); + + if (!label) { + logWarn`The link ${match} requires a label be entered!`; + return match; + } + + try { + return strings.link[linkKey](thing, {text: label, hash, to}); + } catch (error) { + logError`The link ${match} failed to be processed: ${error}`; + return match; + } }); } -function parseAttributes(string) { +function parseAttributes(string, {to}) { const attributes = Object.create(null); const skipWhitespace = i => { const ws = /\s/; @@ -422,7 +893,11 @@ function parseAttributes(string) { const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; const value = string.slice(vStart, vEnd); i = vEnd + endOffset; - attributes[attribute] = value; + if (attribute === 'src' && value.startsWith('media/')) { + attributes[attribute] = to.media(value.slice('media/'.length)); + } else { + attributes[attribute] = value; + } } else { attributes[attribute] = attribute; } @@ -436,14 +911,10 @@ function parseAttributes(string) { ])); } -function transformMultiline(text, treatAsDocument=false) { +function transformMultiline(text, {strings, to}) { // Heck yes, HTML magics. - text = transformInline(text.trim()); - - if (treatAsDocument) { - return text; - } + text = transformInline(text.trim(), {strings, to}); const outLines = []; @@ -487,7 +958,7 @@ function transformMultiline(text, treatAsDocument=false) { line = line.replace(/<img (.*?)>/g, (match, attributes) => img({ lazy: true, link: true, - ...parseAttributes(attributes) + ...parseAttributes(attributes, {to}) })); let indentThisLine = 0; @@ -585,7 +1056,7 @@ function transformMultiline(text, treatAsDocument=false) { return outLines.join('\n'); } -function transformLyrics(text) { +function transformLyrics(text, {strings, to}) { // Different from transformMultiline 'cuz it joins multiple lines together // with line 8reaks (<br>); transformMultiline treats each line as its own // complete paragraph (or list, etc). @@ -593,10 +1064,10 @@ function transformLyrics(text) { // If it looks like old data, then like, oh god. // Use the normal transformMultiline tool. if (text.includes('<br')) { - return transformMultiline(text); + return transformMultiline(text, {strings, to}); } - text = transformInline(text.trim()); + text = transformInline(text.trim(), {strings, to}); let buildLine = ''; const addLine = () => outLines.push(`<p>${buildLine}</p>`); @@ -734,10 +1205,10 @@ async function processAlbumDataFile(file) { album.tracks = []; // will be overwritten if a group section is found! - album.usesGroups = false; + album.trackGroups = null; - let group = ''; - let groupColor = album.color; + let group = null; + let trackIndex = 0; for (const section of sections.slice(1)) { // Just skip empty sections. Sometimes I paste a 8unch of dividers, @@ -749,16 +1220,26 @@ async function processAlbumDataFile(file) { const groupName = getBasicField(section, 'Group'); if (groupName) { - group = groupName; - groupColor = ( - getBasicField(section, 'Color') || - getBasicField(section, 'FG') || - album.color - ); - album.usesGroups = true; + group = { + name: groupName, + color: ( + getBasicField(section, 'Color') || + getBasicField(section, 'FG') || + album.color + ), + startIndex: trackIndex, + tracks: [] + }; + if (album.trackGroups) { + album.trackGroups.push(group); + } else { + album.trackGroups = [group]; + } continue; } + trackIndex++; + const track = {}; track.name = getBasicField(section, 'Track'); @@ -841,10 +1322,9 @@ async function processAlbumDataFile(file) { // we're outputting the track pages. track.album = album; - track.group = group; - if (group) { - track.color = groupColor; + track.color = group.color; + group.tracks.push(track); } else { track.color = album.color; } @@ -911,7 +1391,7 @@ async function processFlashDataFile(file) { const anchor = getBasicField(section, 'Anchor'); const jump = getBasicField(section, 'Jump'); const jumpColor = getBasicField(section, 'Jump Color') || color; - return {act8r8k: true, act, color, anchor, jump, jumpColor}; + return {act8r8k: true, name: act, color, anchor, jump, jumpColor}; } const name = getBasicField(section, 'Flash'); @@ -1136,7 +1616,6 @@ async function processStaticPageDataFile(file) { let stylesheet = getMultilineField(section, 'Style') || ''; let listed = getBooleanField(section, 'Listed') ?? true; - let treatAsHTML = getBooleanField(section, 'Treat as HTML') ?? false; return { name, @@ -1144,8 +1623,7 @@ async function processStaticPageDataFile(file) { directory, content, stylesheet, - listed, - treatAsHTML + listed }; }); } @@ -1176,6 +1654,11 @@ async function processWikiInfoFile(file) { // gener8ted. const canonicalBase = getBasicField(contentLines, 'Canonical Base'); + // This is optional! Without it, the site will default to 8uilding in + // English. (This is only really relevant if you've provided string files + // for non-English languages.) + const defaultLanguage = getBasicField(contentLines, 'Default Language'); + // Also optional! In charge of <meta rel="description">. const description = getBasicField(contentLines, 'Description'); @@ -1197,6 +1680,7 @@ async function processWikiInfoFile(file) { shortName, color, canonicalBase, + defaultLanguage, description, footer, features: { @@ -1269,9 +1753,6 @@ async function processHomepageInfoFile(file) { } const actions = getListField(section, 'Actions') || []; - if (actions.some(x => !x.startsWith('<a'))) { - return {error: 'Expected every action to be a <a>-type link!'}; - } return {...row, group, groupCount, albums, actions}; } @@ -1281,37 +1762,6 @@ async function processHomepageInfoFile(file) { return {sidebar, rows}; } -function getDateString({ date }) { - /* - const pad = val => val.toString().padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - */ - const months = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ] - date = new Date(date); - return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}` -} - -function getDurationString(secTotal) { - if (secTotal === 0) { - return '_:__' - } - - let hour = Math.floor(secTotal / 3600) - let min = Math.floor((secTotal - hour * 3600) / 60) - let sec = Math.floor(secTotal - hour * 3600 - min * 60) - - const pad = val => val.toString().padStart(2, '0') - - if (hour > 0) { - return `${hour}:${pad(min)}:${pad(sec)}` - } else { - return `${min}:${pad(sec)}` - } -} - function getDurationInSeconds(string) { const parts = string.split(':').map(n => parseInt(n)) if (parts.length === 3) { @@ -1415,7 +1865,7 @@ function stringifyArtistData() { } function escapeAttributeValue(value) { - return value.toString().replace(/"/g, '"'); + return he.encode(value, {useNamedReferences: true}); } function attributes(attribs) { @@ -1491,20 +1941,48 @@ function img({ } } -async function writePage(directoryParts, { - title = '', - meta = {}, - theme = '', - stylesheet = '', - - // missing properties are auto-filled, see below! - body = {}, - main = {}, - sidebarLeft = {}, - sidebarRight = {}, - nav = {}, - footer = {} -}) { +async function writePage(strings, baseDirectory, urlKey, directory, pageFn) { + // Generally this function shouldn't 8e called directly - instead use the + // shadowed version provided 8y wrapLanguages, which automatically provides + // the appropriate baseDirectory and strings arguments. (The utility + // functions attached to this function are generally useful, though!) + + const paths = writePage.paths(baseDirectory, urlKey, directory); + + // This is kinda complic8ted. May8e most of it can 8e moved into the code + // which gener8tes the urls o8ject in the first place? Or all that can 8e + // moved here? Or hey, may8e all THAT code is 8asically no longer needed. + // Worth thinking a8out. + const sharedKeys = Object.keys(urlSpec.shared); + const to = Object.fromEntries(Object.entries(urls[urlKey]).map( + ([key, fn]) => [ + key, + (sharedKeys.includes(key) && baseDirectory + ? (...args) => paths.prefixToShared + fn(...args) + : (...args) => paths.prefixToLocalized + fn(...args)) + ] + )); + + const content = writePage.html(pageFn, {paths, strings, to}); + await writePage.write(content, {paths}); +} + +writePage.html = (pageFn, {paths, strings, to}) => { + let { + title = '', + meta = {}, + theme = '', + stylesheet = '', + + // missing properties are auto-filled, see below! + body = {}, + main = {}, + sidebarLeft = {}, + sidebarRight = {}, + nav = {}, + footer = {} + } = pageFn({to}); + body.style ??= ''; theme = theme || getThemeString(wikiInfo); @@ -1526,21 +2004,11 @@ async function writePage(directoryParts, { nav.links ??= []; footer.classes ??= []; - footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer) : ''); + footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer, {strings, to}) : ''); - const directory = path.join(outputPath, ...directoryParts); - const file = path.join(directory, 'index.html'); - const href = path.join(...directoryParts, 'index.html'); - - let targetPath = directoryParts.join('/'); - if (directoryParts.length) { - targetPath += '/'; - } - - let canonical = ''; - if (wikiInfo.canonicalBase) { - canonical = wikiInfo.canonicalBase + targetPath; - } + const canonical = (wikiInfo.canonicalBase + ? wikiInfo.canonicalBase + paths.pathname + : ''); const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false); @@ -1595,9 +2063,15 @@ async function writePage(directoryParts, { if (nav.simple) { nav.links = [ - ['./', wikiInfo.shortName], - [href, title] - ] + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: '', + title + } + ]; } const links = (nav.links || []).filter(Boolean); @@ -1607,24 +2081,18 @@ async function writePage(directoryParts, { const link = links[i]; const prev = links[i - 1]; const next = links[i + 1]; - const [ href, title ] = link; - let part = ''; - if (href) { - if (prev && prev[0]) { - part = '/ '; - } - part += `<a href="${href}">${title}</a>`; - } else { - if (next && prev) { - part = '/ '; - } - part += `<span>${title}</span>`; + const { html, href, title, divider = true } = link; + let part = prev && divider ? '/ ' : ''; + if (typeof href === 'string') { + part += `<a href="${href}" ${classes(i === links.length - 1 && 'current')}>${title}</a>`; + } else if (html) { + part += `<span>${html}</span>`; } navLinkParts.push(part); } const navContentHTML = [ - nav.links.length && fixWS` + links.length && fixWS` <h2 class="highlight-last-link"> ${navLinkParts.join('\n')} </h2> @@ -1650,50 +2118,83 @@ async function writePage(directoryParts, { footerHTML ].filter(Boolean).join('\n'); - await mkdirp(directory); - await writeFile(file, filterEmptyLines(rebaseURLs(directory, fixWS` + return filterEmptyLines(fixWS` <!DOCTYPE html> - <html data-rebase="${path.relative(directory, outputPath)}"> + <html data-rebase-localized="${to.site('')}" data-rebase-shared="${to.root('')}"> <head> <title>${title}</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${escapeAttributeValue(value)}">`).join('\n')} ${canonical && `<link rel="canonical" href="${canonical}">`} - <link rel="stylesheet" href="${C.STATIC_DIRECTORY}/site.css?${CACHEBUST}"> + <link rel="stylesheet" href="${to.staticFile(`site.css?${CACHEBUST}`)}"> ${(theme || stylesheet) && fixWS` <style> ${theme} ${stylesheet} </style> `} - <script src="${C.STATIC_DIRECTORY}/lazy-loading.js?${CACHEBUST}"></script> + <script src="${to.staticFile(`lazy-loading.js?${CACHEBUST}`)}"></script> </head> <body ${attributes({style: body.style || ''})}> <div id="page-container"> ${mainHTML && fixWS` <div id="skippers"> - <span class="skipper"><a href="#content">Skip to content</a></span> - ${sidebarLeftHTML && `<span class="skipper"><a href="#sidebar-left">Skip to sidebar ${sidebarRightHTML && '(left)'}</a></span>`} - ${sidebarRightHTML && `<span class="skipper"><a href="#sidebar-right">Skip to sidebar ${sidebarLeftHTML && '(right)'}</a></span>`} - ${footerHTML && `<span class="skipper"><a href="#footer">Skip to footer</a></span>`} + ${[ + ['#content', strings('misc.skippers.skipToContent')], + sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML + ? strings('misc.skippers.skipToSidebar.left') + : strings('misc.skippers.skipToSidebar'))], + sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML + ? strings('misc.skippers.skipToSidebar.right') + : strings('misc.skippers.skipToSidebar'))], + footerHTML && ['#footer', strings('misc.skippers.skipToFooter')] + ].filter(Boolean).map(([ href, title ]) => fixWS` + <span class="skipper"><a href="${href}">${title}</a></span> + `).join('\n')} </div> `} ${layoutHTML} </div> - <script src="${C.COMMON_DIRECTORY}/common.js?${CACHEBUST}"></script> - <script src="${C.STATIC_DIRECTORY}/client.js?${CACHEBUST}"></script> + <script src="${to.commonFile(`common.js?${CACHEBUST}`)}"></script> + <script src="${to.staticFile(`client.js?${CACHEBUST}`)}"></script> </body> </html> - `))); -} + `); +}; + +writePage.write = async (content, {paths}) => { + await mkdirp(paths.outputDirectory); + await writeFile(paths.outputFile, content); +}; + +writePage.paths = (baseDirectory, urlKey, directory) => { + const prefix = baseDirectory ? baseDirectory + '/' : ''; + + const pathname = prefix + urlSpec.localized[urlKey].replace('<>', directory); + + // Needed for the rare directory which itself contains a slash, e.g. for + // listings, with directories like 'albums/by-name'. + const prefixToLocalized = '../'.repeat(directory.split('/').length - 1); + const prefixToShared = (baseDirectory ? '../' : '') + prefixToLocalized; + + const outputDirectory = path.join(outputPath, pathname); + const outputFile = path.join(outputDirectory, 'index.html'); + + return { + pathname, + prefixToLocalized, prefixToShared, + outputDirectory, outputFile + }; +}; function getGridHTML({ + strings, entries, srcFn, hrefFn, altFn = () => '', - details = false, + detailsFn = null, lazy = true }) { return entries.map(({ large, item }, i) => fixWS` @@ -1703,37 +2204,33 @@ function getGridHTML({ alt: altFn(item), lazy: (typeof lazy === 'number' ? i >= lazy : lazy), square: true, - reveal: getRevealString(item.artTags) + reveal: getRevealString(item.artTags, {strings}) })} <span>${item.name}</span> - ${details && fixWS` - <span>(${s(item.tracks.length, 'track')}, ${getDurationString(getTotalDuration(item.tracks))})</span> - `} + ${detailsFn && `<span>${detailsFn(item)}</span>`} </a> `).join('\n'); } -function getAlbumGridHTML(props) { +function getAlbumGridHTML({strings, to, details = false, ...props}) { return getGridHTML({ - srcFn: getAlbumCover, - hrefFn: album => `${C.ALBUM_DIRECTORY}/${album.directory}/`, + strings, + srcFn: album => getAlbumCover(album, {to}), + hrefFn: album => to.album(album.directory), + detailsFn: details && (album => strings('misc.albumGridDetails', { + tracks: strings.count.tracks(album.tracks.length, {unit: true}), + time: strings.count.duration(getTotalDuration(album.tracks)) + })), ...props }); } -function getAlbumGridHTML(props) { +function getFlashGridHTML({strings, to, ...props}) { return getGridHTML({ - srcFn: getAlbumCover, - hrefFn: album => `${C.ALBUM_DIRECTORY}/${album.directory}/`, - ...props - }); -} - -function getFlashGridHTML(props) { - return getGridHTML({ - srcFn: getFlashCover, - hrefFn: flash => `${C.FLASH_DIRECTORY}/${flash.directory}/`, - altFn: () => 'flash art', + strings, + srcFn: flash => to.flashArt(flash.directory), + hrefFn: flash => to.flash(flash.directory), + altFn: () => strings('misc.alt.flashArt'), ...props }); } @@ -1773,8 +2270,37 @@ function writeSymlinks() { } } -async function writeHomepage() { - await writePage([], { +function writeSharedFilesAndPages({strings}) { + return progressPromiseAll(`Writing files & pages shared across languages.`, [ + // THESE REDIRECT PAGES ARE TECH DEBT! Oops. + + groupData?.some(group => group.directory === 'fandom') && + mkdirp(path.join(outputPath, 'albums', 'fandom')) + .then(() => writeFile(path.join(outputPath, 'albums', 'fandom', 'index.html'), + generateRedirectPage('Fandom - Gallery', `/${C.GROUP_DIRECTORY}/fandom/gallery/`, {strings}))), + + groupData?.some(group => group.directory === 'official') && + mkdirp(path.join(outputPath, 'albums', 'official')) + .then(() => writeFile(path.join(outputPath, 'albums', 'official', 'index.html'), + generateRedirectPage('Official - Gallery', `/${C.GROUP_DIRECTORY}/official/gallery/`, {strings}))), + + wikiInfo.features.listings && + mkdirp(path.join(outputPath, C.LISTING_DIRECTORY, 'all-commentary')) + .then(() => writeFile(path.join(outputPath, C.LISTING_DIRECTORY, 'all-commentary', 'index.html'), + generateRedirectPage('Album Commentary', `/${C.COMMENTARY_DIRECTORY}/`, {strings}))), + + writeFile(path.join(outputPath, 'data.json'), fixWS` + { + "albumData": ${stringifyAlbumData()}, + ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData()},`} + "artistData": ${stringifyArtistData()} + } + `) + ]); +} + +function writeHomepage() { + return ({strings, writePage}) => writePage('home', '', ({to}) => ({ title: wikiInfo.name, meta: { @@ -1791,22 +2317,23 @@ async function writeHomepage() { ${row.type === 'albums' && fixWS` <div class="grid-listing"> ${getAlbumGridHTML({ + strings, to, entries: ( row.group === 'new-releases' ? getNewReleases(row.groupCount) : - ((getLinkedGroup(row.group)?.albums || []) + ((search.group(row.group)?.albums || []) .slice() .reverse() .slice(0, row.groupCount) .map(album => ({item: album}))) ).concat(row.albums - .map(getLinkedAlbum) + .map(search.album) .map(album => ({item: album})) ), lazy: i > 0 })} ${row.actions.length && fixWS` <div class="grid-actions"> - ${row.actions.map(action => action + ${row.actions.map(action => transformInline(action, {strings, to}) .replace('<a', '<a class="box grid-item"')).join('\n')} </div> `} @@ -1830,13 +2357,16 @@ async function writeHomepage() { // // And no, I will not make [[news]] into part of transformMultiline // (even though that would 8e hilarious). - content: transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__')).replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS` - <h1>News</h1> + content: transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__'), {strings, to}).replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS` + <h1>${strings('homepage.news.title')}</h1> ${newsData.slice(0, 3).map((entry, i) => fixWS` <article ${classes('news-entry', i === 0 && 'first-news-entry')}> - <h2><time>${getDateString(entry)}</time> <a href="${C.NEWS_DIRECTORY}/${entry.directory}/">${entry.name}</a></h2> - ${transformMultiline(entry.bodyShort)} - ${entry.bodyShort !== entry.body && `<a href="${C.NEWS_DIRECTORY}/${entry.directory}/">(View rest of entry!)</a>`} + <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2> + ${transformMultiline(entry.bodyShort, {strings, to})} + ${entry.bodyShort !== entry.body && strings.link.newsEntry(entry, { + to, + text: strings('homepage.news.entry.viewRest') + })} </article> `).join('\n')} ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`) @@ -1845,23 +2375,23 @@ async function writeHomepage() { nav: { content: fixWS` <h2 class="dot-between-spans"> - <span><a class="current" href="./">${wikiInfo.shortName}</a></span> - ${wikiInfo.features.listings && fixWS` - <span><a href="${C.LISTING_DIRECTORY}/">Listings</a></span> - `} - ${wikiInfo.features.news && fixWS` - <span><a href="${C.NEWS_DIRECTORY}/">News</a></span> - `} - ${wikiInfo.features.flashesAndGames && fixWS` - <span><a href="${C.FLASH_DIRECTORY}/">Flashes & Games</a></span> - `} - ${staticPageData.filter(page => page.listed).map(page => fixWS` - <span><a href="${page.directory}/">${page.shortName}</a></span> - `).join('\n')} + ${[ + strings.link.home('', {text: wikiInfo.shortName, class: 'current', to}), + strings.link.listingIndex('', {text: strings('listingIndex.title'), to}), + strings.link.newsIndex('', {text: strings('newsIndex.title'), to}), + strings.link.flashIndex('', {text: strings('flashIndex.title'), to}), + ...staticPageData.filter(page => page.listed).map(page => strings.link.staticPage(page, {to})) + ].map(link => `<span>${link}</span>`).join('\n')} </h2> ` } - }); + })); +} + +function writeMiscellaneousPages() { + return [ + writeHomepage() + ]; } function writeNewsPages() { @@ -1869,160 +2399,129 @@ function writeNewsPages() { return; } - return progressPromiseAll('Writing news pages.', queue([ - writeNewsIndex, - ...newsData.map(curry(writeNewsPage)) - ], queueSize)); + return [ + writeNewsIndex(), + ...newsData.map(writeNewsEntryPage) + ]; } -async function writeNewsIndex() { - await writePage([C.NEWS_DIRECTORY], { - title: 'News', +function writeNewsIndex() { + return ({strings, writePage}) => writePage('newsIndex', '', ({to}) => ({ + title: strings('newsIndex.title'), + main: { content: fixWS` <div class="long-content news-index"> - <h1>News</h1> + <h1>${strings('newsIndex.title')}</h1> ${newsData.map(entry => fixWS` <article id="${entry.directory}"> - <h2><time>${getDateString(entry)}</time> <a href="${C.NEWS_DIRECTORY}/${entry.directory}/">${entry.name}</a></h2> - ${transformMultiline(entry.bodyShort)} - ${entry.bodyShort !== entry.body && `<p><a href="${C.NEWS_DIRECTORY}/${entry.directory}/">(View rest of entry!)</a></p>`} + <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2> + ${transformMultiline(entry.bodyShort, {strings, to})} + ${entry.bodyShort !== entry.body && `<p>${strings.link.newsEntry(entry, { + to, + text: strings('newsIndex.entry.viewRest') + })}</p>`} </article> `).join('\n')} </div> ` }, + nav: { links: [ - ['./', wikiInfo.shortName], - [`${C.NEWS_DIRECTORY}/`, 'News'] + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: '', + title: strings('newsIndex.title') + } ] } - }); + })); } -async function writeNewsPage(entry) { - // The newsData list is sorted reverse chronologically (newest ones first), - // so the way we find next/previous entries is flipped from normal. - const index = newsData.indexOf(entry) - const previous = newsData[index + 1]; - const next = newsData[index - 1]; - const nextPreviousLinks = [ - previous && `<a href="${C.NEWS_DIRECTORY}/${previous.directory}/" id="previous-button" title="${previous.name}">Previous</a>`, - next && `<a href="${C.NEWS_DIRECTORY}/${next.directory}/" id="next-button" title="${next.name}">Next</a>` - ].filter(Boolean).join(', '); +function writeNewsEntryPage(entry) { + return ({strings, writePage}) => writePage('newsEntry', entry.directory, ({to}) => ({ + title: strings('newsEntryPage.title', {entry: entry.name}), - await writePage([C.NEWS_DIRECTORY, entry.directory], { - title: `News - ${entry.name}`, main: { content: fixWS` <div class="long-content"> - <h1>${entry.name}</h1> - <p>(Published ${getDateString(entry)}.)</p> - ${transformMultiline(entry.body)} + <h1>${strings('newsEntryPage.title', {entry: entry.name})}</h1> + <p>${strings('newsEntryPage.published', {date: strings.count.date(entry.date)})}</p> + ${transformMultiline(entry.body, {strings, to})} </div> ` }, - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.NEWS_DIRECTORY}/`, 'News'], - [null, getDateString(entry) + ':'], - [`${C.NEWS_DIRECTORY}/${entry.directory}/`, entry.name], - nextPreviousLinks && [null, `(${nextPreviousLinks})`] - ] - } - }); -} - -function writeMiscellaneousPages() { - return progressPromiseAll('Writing miscellaneous pages.', [ - writeHomepage(), - groupData?.some(group => group.directory === 'fandom') && - mkdirp(path.join(outputPath, 'albums', 'fandom')) - .then(() => writeFile(path.join(outputPath, 'albums', 'fandom', 'index.html'), - generateRedirectPage('Fandom - Gallery', `/${C.GROUP_DIRECTORY}/fandom/gallery/`))), + nav: generateNewsEntryNav(entry, {strings, to}) + })); +} - groupData?.some(group => group.directory === 'official') && - mkdirp(path.join(outputPath, 'albums', 'official')) - .then(() => writeFile(path.join(outputPath, 'albums', 'official', 'index.html'), - generateRedirectPage('Official - Gallery', `/${C.GROUP_DIRECTORY}/official/gallery/`))), +function generateNewsEntryNav(entry, {strings, to}) { + // The newsData list is sorted reverse chronologically (newest ones first), + // so the way we find next/previous entries is flipped from normal. + const previousNextLinks = generatePreviousNextLinks('newsEntry', entry, newsData.slice().reverse(), {strings, to}); - wikiInfo.features.flashesAndGames && - writePage([C.FLASH_DIRECTORY], { - title: `Flashes & Games`, - main: { - classes: ['flash-index'], - content: fixWS` - <h1>Flashes & Games</h1> - <div class="long-content"> - <p class="quick-info">Jump to:</p> - <ul class="quick-info"> - ${flashData.filter(act => act.act8r8k && act.jump).map(({ anchor, jump, jumpColor }) => fixWS` - <li><a href="#${anchor}" style="${getLinkThemeString({color: jumpColor})}">${jump}</a></li> - `).join('\n')} - </ul> - </div> - ${flashData.filter(flash => flash.act8r8k).map((act, i) => fixWS` - <h2 id="${act.anchor}" style="${getLinkThemeString(act)}"><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flashData.find(f => !f.act8r8k && f.act === act.act))}/">${act.act}</a></h2> - <div class="grid-listing"> - ${getFlashGridHTML({ - entries: (flashData - .filter(flash => !flash.act8r8k && flash.act === act.act) - .map(flash => ({item: flash}))), - lazy: i === 0 ? 4 : true - })} - </div> - `).join('\n')} - ` + return { + links: [ + { + href: to.home(), + title: wikiInfo.shortName }, - - /* - sidebarLeft: { - content: generateSidebarForFlashes(null) + { + href: to.newsIndex(), + title: strings('newsEntryPage.nav.news') }, - */ - - nav: {simple: true} - }), - - writeFile(path.join(outputPath, 'data.json'), fixWS` { - "albumData": ${stringifyAlbumData()}, - ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData()},`} - "artistData": ${stringifyArtistData()} + html: strings('newsEntryPage.nav.entry', { + date: strings.count.date(entry.date), + entry: strings.link.newsEntry(entry, {class: 'current', to}) + }) + }, + previousNextLinks && + { + divider: false, + html: `(${previousNextLinks})` } - `) - ].filter(Boolean)); + ] + }; } function writeStaticPages() { - return progressPromiseAll(`Writing static pages.`, queue(staticPageData.map(curry(writeStaticPage)), queueSize)); + return staticPageData.map(writeStaticPage); } -async function writeStaticPage(staticPage) { - await writePage([staticPage.directory], { +function writeStaticPage(staticPage) { + return ({strings, writePage}) => writePage('staticPage', staticPage.directory, ({to}) => ({ title: staticPage.name, stylesheet: staticPage.stylesheet, + main: { content: fixWS` <div class="long-content"> <h1>${staticPage.name}</h1> - ${transformMultiline(staticPage.content, staticPage.treatAsHTML)} + ${transformMultiline(staticPage.content, {strings, to})} </div> ` }, + nav: {simple: true} - }); + })); } -function getRevealString(tags = []) { - return tags.some(tag => tag.isCW) && ( - 'cw: ' + tags.filter(tag => tag.isCW).map(tag => `<span class="reveal-tag">${tag.name}</span>`).join(', ')) + '<br><span class="reveal-interaction">click to show</span>' +function getRevealString(tags, {strings}) { + return tags && tags.some(tag => tag.isCW) && ( + strings('misc.contentWarnings', { + warnings: tags.filter(tag => tag.isCW).map(tag => `<span class="reveal-tag">${tag.name}</span>`).join(', ') + }) + `<br><span class="reveal-interaction">${strings('misc.contentWarnings.reveal')}</span>` + ); } function generateCoverLink({ + strings, to, src, alt, tags = [] @@ -2035,13 +2534,17 @@ function generateCoverLink({ id: 'cover-art', link: true, square: true, - reveal: getRevealString(tags) + reveal: getRevealString(tags, {strings}) })} - ${wikiInfo.features.artTagUI && tags.filter(tag => !tag.isCW).length && `<p class="tags">Tags: - ${tags.filter(tag => !tag.isCW).map(tag => fixWS` - <a href="${C.TAG_DIRECTORY}/${tag.directory}/" style="${getLinkThemeString(tag)}">${tag.name}</a> - `).join(',\n')} - </p>`} + ${wikiInfo.features.artTagUI && tags.filter(tag => !tag.isCW).length && fixWS` + <p class="tags"> + ${strings('releaseInfo.artTags')} + ${(tags + .filter(tag => !tag.isCW) + .map(tag => strings.link.tag(tag, {to})) + .join(',\n'))} + </p> + `} </div> `; } @@ -2058,94 +2561,156 @@ function writeIndexAndTrackPagesForAlbum(album) { */ function writeAlbumPages() { - return progressPromiseAll(`Writing album pages.`, queue(albumData.map(curry(writeAlbumPage)), queueSize)); + return albumData.map(writeAlbumPage); } -async function writeAlbumPage(album) { - const trackToListItem = track => fixWS` - <li style="${getLinkThemeString(track)}"> - (${getDurationString(track.duration)}) - <a href="${C.TRACK_DIRECTORY}/${track.directory}/">${track.name}</a> - ${track.artists !== album.artists && fixWS` - <span class="by">by ${getArtistString(track.artists)}</span> - `} - </li> - `; +function writeAlbumPage(album) { + const trackToListItem = (track, {strings, to}) => { + const itemOpts = { + duration: strings.count.duration(track.duration), + track: strings.link.track(track, {to}) + }; + return `<li style="${getLinkThemeString(track)}">${ + (track.artists === album.artists + ? strings('trackList.item.withDuration', itemOpts) + : strings('trackList.item.withDuration.withArtists', { + ...itemOpts, + by: `<span class="by">${ + strings('trackList.item.withArtists.by', { + artists: getArtistString(track.artists, {strings, to}) + }) + }</span>` + })) + }</li>`; + }; const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length; const listTag = getAlbumListTag(album); - await writePage([C.ALBUM_DIRECTORY, album.directory], { - title: album.name, - stylesheet: getAlbumStylesheet(album), + + return ({strings, writePage}) => writePage('album', album.directory, ({to}) => ({ + title: strings('albumPage.title', {album: album.name}), + stylesheet: getAlbumStylesheet(album, {to}), theme: getThemeString(album, [ `--album-directory: ${album.directory}` ]), + main: { content: fixWS` ${generateCoverLink({ - src: getAlbumCover(album), - alt: 'album cover', + strings, to, + src: to.albumCover(album.directory), + alt: strings('misc.alt.albumCover'), tags: album.artTags })} - <h1>${album.name}</h1> + <h1>${strings('albumPage.title', {album: album.name})}</h1> <p> - ${album.artists && `By ${getArtistString(album.artists, true)}.<br>`} - ${album.coverArtists && `Cover art by ${getArtistString(album.coverArtists, true)}.<br>`} - ${album.wallpaperArtists && `Wallpaper art by ${getArtistString(album.wallpaperArtists, true)}.<br>`} - Released ${getDateString(album)}. - ${+album.coverArtDate !== +album.date && `<br>Art released ${getDateString({date: album.coverArtDate})}.`} - <br>Duration: ~${getDurationString(getTotalDuration(album.tracks))}. + ${[ + album.artists && strings('releaseInfo.by', { + artists: getArtistString(album.artists, { + strings, to, + showContrib: true, + showIcons: true + }) + }), + album.coverArtists && strings('releaseInfo.coverArtBy', { + artists: getArtistString(album.coverArtists, { + strings, to, + showContrib: true, + showIcons: true + }) + }), + album.wallpaperArtists && strings('releaseInfo.wallpaperArtBy', { + artists: getArtistString(album.wallpaperArtists, { + strings, to, + showContrib: true, + showIcons: true + }) + }), + strings('releaseInfo.released', { + date: strings.count.date(album.date) + }), + +album.coverArtDate !== +album.date && strings('releaseInfo.artReleased', { + date: strings.count.date(album.coverArtDate) + }), + strings('releaseInfo.duration', { + duration: strings.count.duration(getTotalDuration(album.tracks), {approximate: album.tracks.length > 1}) + }) + ].filter(Boolean).join('<br>\n')} </p> - ${commentaryEntries && `<p>View <a href="${C.COMMENTARY_DIRECTORY}/${C.ALBUM_DIRECTORY}/${album.directory}/">commentary page</a>!</p>`} - ${album.urls.length && `<p>Listen on ${joinNoOxford(album.urls.map(url => fancifyURL(url, {album: true})), 'or')}.</p>`} - ${album.usesGroups ? fixWS` + ${commentaryEntries && `<p>${ + strings('releaseInfo.viewCommentary', { + link: `<a href="${to.albumCommentary(album.directory)}">${ + strings('releaseInfo.viewCommentary.link') + }</a>` + }) + }</p>`} + ${album.urls.length && `<p>${ + strings('releaseInfo.listenOn', { + links: strings.list.or(album.urls.map(url => fancifyURL(url, {album: true, strings}))) + }) + }</p>`} + ${album.trackGroups ? fixWS` <dl class="album-group-list"> - ${album.tracks.flatMap((track, i, arr) => [ - (i > 0 && track.group !== arr[i - 1].group) && `</${listTag}></dd>`, - (i === 0 || track.group !== arr[i - 1].group) && fixWS` - ${track.group && `<dt>${track.group} (~${getDurationString(getTotalDuration(album.tracks.filter(({ group }) => group === track.group)))}):</dt>`} - <dd><${listTag === 'ol' ? `ol start="${i + 1}"` : listTag}> - `, - trackToListItem(track), - i === arr.length && `</${listTag}></dd>` - ].filter(Boolean)).join('\n')} + ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS` + <dt>${ + strings('trackList.group', { + duration: strings.count.duration(getTotalDuration(tracks), {approximate: tracks.length > 1}), + group: name + }) + }</dt> + <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}> + ${tracks.map(t => trackToListItem(t, {strings, to})).join('\n')} + </${listTag}></dd> + `).join('\n')} </dl> ` : fixWS` <${listTag}> - ${album.tracks.map(trackToListItem).join('\n')} + ${album.tracks.map(t => trackToListItem(t, {strings, to})).join('\n')} </${listTag}> `} ${album.commentary && fixWS` - <p>Artist commentary:</p> + <p>${strings('releaseInfo.artistCommentary')}</p> <blockquote> - ${transformMultiline(album.commentary)} + ${transformMultiline(album.commentary, {strings, to})} </blockquote> `} ` }, - sidebarLeft: generateSidebarForAlbum(album), - sidebarRight: generateSidebarRightForAlbum(album), + + sidebarLeft: generateSidebarForAlbum(album, null, {strings, to}), + sidebarRight: generateSidebarRightForAlbum(album, null, {strings, to}), + nav: { links: [ - ['./', wikiInfo.shortName], - [`${C.ALBUM_DIRECTORY}/${album.directory}/`, album.name], - [null, generateAlbumNavLinks(album)] + { + href: to.home(), + title: wikiInfo.shortName + }, + { + html: strings('albumPage.nav.album', { + album: strings.link.album(album, {class: 'current', to}) + }) + }, + { + divider: false, + html: generateAlbumNavLinks(album, null, {strings, to}) + } ], content: fixWS` <div> - ${generateAlbumChronologyLinks(album)} + ${generateAlbumChronologyLinks(album, null, {strings, to})} </div> ` } - }); + })); } -function getAlbumStylesheet(album) { +function getAlbumStylesheet(album, {to}) { if (album.wallpaperArtists) { return fixWS` body::before { - background-image: url("${C.MEDIA_DIRECTORY}/${C.MEDIA_ALBUM_ART_DIRECTORY}/${album.directory}/bg.jpg"); + background-image: url("${to.albumWallpaper(album.directory)}"); ${album.wallpaperStyle} } `; @@ -2155,10 +2720,10 @@ function getAlbumStylesheet(album) { } function writeTrackPages() { - return progressPromiseAll(`Writing track pages.`, queue(trackData.map(curry(writeTrackPage)), queueSize)); + return trackData.map(writeTrackPage); } -async function writeTrackPage(track) { +function writeTrackPage(track) { const { album } = track; const tracksThatReference = track.referencedBy; const ttrFanon = tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY)); @@ -2173,356 +2738,576 @@ async function writeTrackPage(track) { .flatMap(track => track.flashes.map(flash => ({flash, as: track})))); } - const generateTrackList = tracks => fixWS` + const generateTrackList = (tracks, {strings, to}) => fixWS` <ul> - ${tracks.map(track => fixWS` - <li ${classes(track.aka && 'rerelease')}> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - <span class="by">by ${getArtistString(track.artists)}</span> - ${track.aka && `<span class="rerelease-label">(re-release)</span>`} - </li> - `).join('\n')} + ${tracks.map(track => + // vim doesnt like this code much lol + (({ + line = strings('trackList.item.withArtists', { + track: strings.link.track(track, {to}), + by: `<span class="by">${strings('trackList.item.withArtists.by', { + artists: getArtistString(track.artists, {strings, to}) + })}</span>` + }) + }) => ( + (track.aka + ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>` + : `<li>${line}</li>`) + ))({}) + ).join('\n')} </ul> `; - const commentary = [ - track.commentary, - ...otherReleases.map(track => - (track.commentary?.split('\n') - .filter(line => line.replace(/<\/b>/g, '').includes(':</i>')) - .flatMap(line => [line, `<i>See <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a>!</i>`]) - .join('\n'))) - ].filter(Boolean).join('\n'); + const hasCommentary = track.commentary || otherReleases.some(t => t.commentary); + const generateCommentary = ({strings, to}) => transformMultiline( + [ + track.commentary, + ...otherReleases.map(track => + (track.commentary?.split('\n') + .filter(line => line.replace(/<\/b>/g, '').includes(':</i>')) + .map(line => fixWS` + ${line} + ${strings('releaseInfo.artistCommentary.seeOriginalRelease', { + original: strings.link.track(track, {to}) + })} + `) + .join('\n'))) + ].filter(Boolean).join('\n'), + {strings, to}); - await writePage([C.TRACK_DIRECTORY, track.directory], { - title: track.name, - stylesheet: getAlbumStylesheet(track.album), + return ({strings, writePage}) => writePage('track', track.directory, ({to}) => ({ + title: strings('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(track.album, {to}), theme: getThemeString(track, [ `--album-directory: ${album.directory}`, `--track-directory: ${track.directory}` ]), - sidebarLeft: generateSidebarForAlbum(album, track), - sidebarRight: generateSidebarRightForAlbum(album, track), - - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.ALBUM_DIRECTORY}/${album.directory}/`, album.name], - listTag === 'ol' && [null, album.tracks.indexOf(track) + 1 + '.'], - [`${C.TRACK_DIRECTORY}/${track.directory}/`, track.name], - [null, generateAlbumNavLinks(album, track)] - ].filter(Boolean), - content: fixWS` - <div> - ${generateAlbumChronologyLinks(album, track)} - </div> - ` - }, - main: { content: fixWS` ${generateCoverLink({ - src: getTrackCover(track), - alt: 'track cover', + strings, to, + src: getTrackCover(track, {to}), + alt: strings('misc.alt.trackCover'), tags: track.artTags })} - <h1>${track.name}</h1> + <h1>${strings('trackPage.title', {track: track.name})}</h1> <p> - By ${getArtistString(track.artists, true)}. - ${track.coverArtists && `<br>Cover art by ${getArtistString(track.coverArtists, true)}.`} - ${album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && `<br>Released ${getDateString(track)}.`} - ${+track.coverArtDate !== +track.date && `<br>Art released ${getDateString({date: track.coverArtDate})}.`} - ${track.duration && `<br>Duration: ${getDurationString(track.duration)}.`} + ${[ + strings('releaseInfo.by', { + artists: getArtistString(track.artists, { + strings, to, + showContrib: true, + showIcons: true + }) + }), + track.coverArtists && strings('releaseInfo.coverArtBy', { + artists: getArtistString(track.coverArtists, { + strings, to, + showContrib: true, + showIcons: true + }) + }), + album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && strings('releaseInfo.released', { + date: strings.count.date(track.date) + }), + +track.coverArtDate !== +track.date && strings('releaseInfo.artReleased', { + date: strings.count.date(track.coverArtDate) + }), + track.duration && strings('releaseInfo.duration', { + duration: strings.count.duration(track.duration) + }) + ].filter(Boolean).join('<br>\n')} </p> - ${track.urls.length ? fixWS` - <p>Listen on ${joinNoOxford(track.urls.map(fancifyURL), 'or')}.</p> - ` : fixWS` - <p>This track has no URLs at which it can be listened.</p> - `} + <p>${ + (track.urls.length + ? strings('releaseInfo.listenOn', { + links: strings.list.or(track.urls.map(url => fancifyURL(url, {strings}))) + }) + : strings('releaseInfo.listenOn.noLinks')) + }</p> ${otherReleases.length && fixWS` - <p>Also released as:</p> + <p>${strings('releaseInfo.alsoReleasedAs')}</p> <ul> ${otherReleases.map(track => fixWS` - <li> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - (on <a href="${C.ALBUM_DIRECTORY}/${track.album.directory}/" style="${getLinkThemeString(track.album)}">${track.album.name}</a>) - </li> + <li>${strings('releaseInfo.alsoReleasedAs.item', { + track: strings.link.track(track, {to}), + album: strings.link.album(track.album, {to}) + })}</li> `).join('\n')} </ul> `} ${track.contributors.textContent && fixWS` - <p>Contributors:<br>${transformInline(track.contributors.textContent)}</p> + <p> + ${strings('releaseInfo.contributors')} + <br> + ${transformInline(track.contributors.textContent, {strings, to})} + </p> `} ${track.contributors.length && fixWS` - <p>Contributors:</p> + <p>${strings('releaseInfo.contributors')}</p> <ul> - ${track.contributors.map(contrib => `<li>${getArtistString([contrib], true)}</li>`).join('\n')} + ${(track.contributors + .map(contrib => `<li>${getArtistString([contrib], { + strings, to, + showContrib: true, + showIcons: true + })}</li>`) + .join('\n'))} </ul> `} ${tracksReferenced.length && fixWS` - <p>Tracks that <i>${track.name}</i> references:</p> - ${generateTrackList(tracksReferenced)} + <p>${strings('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p> + ${generateTrackList(tracksReferenced, {strings, to})} `} ${tracksThatReference.length && fixWS` - <p>Tracks that reference <i>${track.name}</i>:</p> + <p>${strings('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p> <dl> ${ttrOfficial.length && fixWS` <dt>Official:</dt> - <dd>${generateTrackList(ttrOfficial)}</dd> + <dd>${generateTrackList(ttrOfficial, {strings, to})}</dd> `} ${ttrFanon.length && fixWS` <dt>Fandom:</dt> - <dd>${generateTrackList(ttrFanon)}</dd> + <dd>${generateTrackList(ttrFanon, {strings, to})}</dd> `} </dl> `} ${wikiInfo.features.flashesAndGames && flashesThatFeature.length && fixWS` - <p>Flashes & games that feature <i>${track.name}</i>:</p> + <p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p> <ul> ${flashesThatFeature.map(({ flash, as }) => fixWS` - <li ${classes(as !== track && 'rerelease')}> - ${getFlashLinkHTML(flash)} - ${as !== track && fixWS` - (as <a href="${C.TRACK_DIRECTORY}/${as.directory}/" style="${getLinkThemeString(as)}">${as.name}</a>) - `} - </li> + <li ${classes(as !== track && 'rerelease')}>${ + (as === track + ? strings('releaseInfo.flashesThatFeature.item', { + flash: strings.link.flash(flash, {to}) + }) + : strings('releaseInfo.flashesThatFeature.item.asDifferentRelease', { + flash: strings.link.flash(flash, {to}), + track: strings.link.track(as, {to}) + })) + }</li> `).join('\n')} </ul> `} ${track.lyrics && fixWS` - <p>Lyrics:</p> + <p>${strings('releaseInfo.lyrics')}</p> <blockquote> - ${transformLyrics(track.lyrics)} + ${transformLyrics(track.lyrics, {strings, to})} </blockquote> `} - ${commentary && fixWS` - <p>Artist commentary:</p> + ${hasCommentary && fixWS` + <p>${strings('releaseInfo.artistCommentary')}</p> <blockquote> - ${transformMultiline(commentary)} + ${transformMultiline(generateCommentary({strings, to}), {strings, to})} </blockquote> `} ` + }, + + sidebarLeft: generateSidebarForAlbum(album, track, {strings, to}), + sidebarRight: generateSidebarRightForAlbum(album, track, {strings, to}), + + nav: { + links: [ + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: to.album(album.directory), + title: album.name + }, + listTag === 'ol' ? { + html: strings('trackPage.nav.track.withNumber', { + number: album.tracks.indexOf(track) + 1, + track: strings.link.track(track, {class: 'current', to}) + }) + } : { + html: strings('trackPage.nav.track', { + track: strings.link.track(track, {class: 'current', to}) + }) + }, + { + divider: false, + html: generateAlbumNavLinks(album, track, {strings, to}) + } + ].filter(Boolean), + content: fixWS` + <div> + ${generateAlbumChronologyLinks(album, track, {strings, to})} + </div> + ` } - }); + })); } -async function writeArtistPages() { - await progressPromiseAll('Writing artist pages.', queue(artistData.map(curry(writeArtistPage)), queueSize)); +function writeArtistPages() { + return [ + ...artistData.map(writeArtistPage), + ...artistAliasData.map(writeArtistAliasPage) + ]; } -async function writeArtistPage(artist) { - if (artist.alias) { - return writeArtistAliasPage(artist); - } - +function writeArtistPage(artist) { const { name, urls = [], note = '' } = artist; - const artThingsAll = C.sortByDate(unique([...artist.tracks.asCoverArtist, ...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist])); + const artThingsAll = C.sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.tracks.asCoverArtist])); const artThingsGallery = C.sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]); const commentaryThings = C.sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]); - let flashes; - if (wikiInfo.features.flashesAndGames) { - flashes = artist.flashes.asContributor; - } - - const unreleasedTracks = unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]) - .filter(track => track.album.directory === C.UNRELEASED_TRACKS_DIRECTORY); - const releasedTracks = unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]) - .filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + const hasGallery = artThingsGallery.length > 0; - const generateTrackList = tracks => albumChunkedList(tracks, (track, i) => { - const contrib = { - who: artist, - what: track.contributors.filter(({ who }) => who === artist).map(({ what }) => what).join(', ') - }; - const { flashes } = track; - return fixWS` - <li ${classes(track.aka && 'rerelease')} title="${th(i + 1)} track by ${name}; ${th(track.album.tracks.indexOf(track) + 1)} in ${track.album.name}"> - ${track.duration && `(${getDurationString(track.duration)})`} - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - ${track.artists.some(({ who }) => who === artist) && track.artists.length > 1 && `<span class="contributed">(with ${getArtistString(track.artists.filter(({ who }) => who !== artist))})</span>`} - ${contrib.what && `<span class="contributed">(${getContributionString(contrib) || 'contributed'})</span>`} - ${wikiInfo.features.flashesAndGames && flashes.length && `<br><span class="flashes">(Featured in ${joinNoOxford(flashes.map(flash => getFlashLinkHTML(flash)))})</span></br>`} - ${track.aka && `<span class="rerelease-label">(re-release)</span>`} - </li> - `; + const getArtistsAndContrib = (thing, key) => ({ + artists: thing[key]?.filter(({ who }) => who !== artist), + contrib: thing[key]?.find(({ who }) => who === artist), + key }); - // Shish! - const index = `${C.ARTIST_DIRECTORY}/${artist.directory}/`; - const avatarPath = path.join(C.MEDIA_ARTIST_AVATAR_DIRECTORY, artist.directory + '.jpg'); - await writePage([C.ARTIST_DIRECTORY, artist.directory], { - title: name, + const artListChunks = chunkByProperties(artThingsAll.flatMap(thing => + (['coverArtists', 'wallpaperArtists'] + .map(key => getArtistsAndContrib(thing, key)) + .filter(({ contrib }) => contrib) + .map(props => ({ + album: thing.album || thing, + track: thing.album ? thing : null, + ...props + }))) + ), ['album']); + + const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({ + album: thing.album || thing, + track: thing.album ? thing : null + })), ['album']); + + const allTracks = C.sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor])); + const unreleasedTracks = allTracks.filter(track => track.album.directory === C.UNRELEASED_TRACKS_DIRECTORY); + const releasedTracks = allTracks.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + + const chunkTracks = tracks => ( + chunkByProperties(tracks.map(track => ({ + track, + album: track.album, + duration: track.duration, + artists: (track.artists.some(({ who }) => who === artist) + ? track.artists.filter(({ who }) => who !== artist) + : track.contributors.filter(({ who }) => who !== artist)), + contrib: { + who: artist, + what: [ + track.artists.find(({ who }) => who === artist)?.what, + track.contributors.find(({ who }) => who === artist)?.what + ].filter(Boolean).join(', ') + } + })), ['album']) + .map(({album, chunk}) => ({ + album, chunk, + duration: getTotalDuration(chunk), + }))); - main: { - content: fixWS` - ${(wikiInfo.features.artistAvatars && - await access(path.join(mediaPath, avatarPath)).then(() => true, () => false) && - generateCoverLink({ - src: path.join(C.MEDIA_DIRECTORY, avatarPath), - alt: 'artist avatar' + const unreleasedTrackListChunks = chunkTracks(unreleasedTracks); + const releasedTrackListChunks = chunkTracks(releasedTracks); + + const totalReleasedDuration = getTotalDuration(releasedTracks); + + let flashes, flashListChunks; + if (wikiInfo.features.flashesAndGames) { + flashes = C.sortByDate(artist.flashes.asContributor.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.contributors.find(({ who }) => who === artist) + })), ['act']) + .map(({ act, chunk }) => ({ + act, chunk, + dateFirst: chunk[0].date, + dateLast: chunk[chunk.length - 1].date + }))); + } + + const generateEntryAccents = ({ aka, entry, artists, contrib, strings, to }) => + (aka + ? strings('artistPage.creditList.entry.rerelease', {entry}) + : (artists.length + ? (contrib.what + ? strings('artistPage.creditList.entry.withArtists.withContribution', { + entry, + artists: getArtistString(artists, {strings, to}), + contribution: contrib }) - )} - <h1>${name}</h1> - ${note && fixWS` - <p>Note:</p> - <blockquote> - ${transformMultiline(note)} - </blockquote> - <hr> - `} - ${urls.length && `<p>Visit on ${joinNoOxford(urls.map(fancifyURL), 'or')}.</p>`} - ${artThingsGallery.length && `<p>View <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/gallery/">art gallery</a>!</p>`} - <p>Jump to: ${[ - [ - [...releasedTracks, ...unreleasedTracks].length && `<a href="${index}#tracks">Tracks</a>`, - unreleasedTracks.length && `(<a href="${index}#unreleased-tracks">Unreleased Tracks</a>)` - ].filter(Boolean).join(' '), - artThingsAll.length && `<a href="${index}#art">Art</a>`, - wikiInfo.features.flashesAndGames && flashes.length && `<a href="${index}#flashes">Flashes & Games</a>`, - commentaryThings.length && `<a href="${index}#commentary">Commentary</a>` - ].filter(Boolean).join(', ')}.</p> - ${[...releasedTracks, ...unreleasedTracks].length && fixWS` - <h2 id="tracks">Tracks</h2> - `} - ${releasedTracks.length && fixWS` - <p>${name} has contributed ~${getDurationString(getTotalDuration(releasedTracks))} ${getTotalDuration(releasedTracks) > 3600 ? 'hours' : 'minutes'} of music collected on this wiki.</p> - ${generateTrackList(releasedTracks)} - `} - ${unreleasedTracks.length && fixWS` - <h3 id="unreleased-tracks">Unreleased Tracks</h3> - ${generateTrackList(unreleasedTracks)} - `} - ${artThingsAll.length && fixWS` - <h2 id="art">Art</h2> - ${artThingsGallery.length && `<p>View <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/gallery/">art gallery</a>! Or browse the list:</p>`} - ${albumChunkedList(artThingsAll, (thing, i) => { - const cover = { - artists: thing.coverArtists, - contrib: thing.coverArtists.find(({ who }) => who === artist) - }; - const bg = { - artists: thing.wallpaperArtists, - contrib: thing.wallpaperArtists?.find(({ who }) => who === artist) - }; - return [cover, bg].filter(x => x.contrib).map(({ artists, contrib }) => fixWS` - <li title="${th(i + 1)} art by ${name}${thing.album && `; ${th(thing.album.tracks.indexOf(thing) + 1)} track in ${thing.album.name}`}"> - ${thing.album ? fixWS` - <a href="${C.TRACK_DIRECTORY}/${thing.directory}/" style="${getLinkThemeString(thing)}">${thing.name}</a> - ` : (contrib === bg.contrib) ? '<i>(wallpaper art)</i>' : '<i>(cover art)</i>'} - ${artists.length > 1 && `<span class="contributed">(with ${getArtistString(artists.filter(({ who }) => who !== artist))})</span>`} - ${contrib.what && `<span class="contributed">(${getContributionString(contrib)})</span>`} - </li> - `).join('\n'); - }, true, 'coverArtDate')} - `} - ${wikiInfo.features.flashesAndGames && flashes.length && fixWS` - <h2 id="flashes">Flashes & Games</h2> - ${actChunkedList(flashes, flash => { - const contributionString = flash.contributors.filter(({ who }) => who === artist).map(getContributionString).join(' '); - return fixWS` - <li> - <a href="${C.FLASH_DIRECTORY}/${flash.directory}/" style="${getLinkThemeString(flash)}">${flash.name}</a> - ${contributionString && `<span class="contributed">(${contributionString})</span>`} - (${getDateString({date: flash.date})}) - </li> - ` - })} - `} - ${commentaryThings.length && fixWS` - <h2 id="commentary">Commentary</h2> - ${albumChunkedList(commentaryThings, thing => { - const { flashes } = thing; - return fixWS` - <li> - ${thing.album ? fixWS` - <a href="${C.TRACK_DIRECTORY}/${thing.directory}/" style="${getLinkThemeString(thing)}">${thing.name}</a> - ` : '(album commentary)'} - ${wikiInfo.features.flashesAndGames && flashes?.length && `<br><span class="flashes">(Featured in ${joinNoOxford(flashes.map(flash => getFlashLinkHTML(flash)))})</span></br>`} - </li> - ` - }, false)} - </ul> - `} - ` - }, + : strings('artistPage.creditList.entry.withArtists', { + entry, + artists: getArtistString(artists, {strings, to}) + })) + : (contrib.what + ? strings('artistPage.creditList.entry.withContribution', { + entry, + contribution: contrib + }) + : entry))); - nav: { - links: [ - ['./', wikiInfo.shortName], - wikiInfo.features.listings && [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [null, 'Artist:'], - [`${C.ARTIST_DIRECTORY}/${artist.directory}/`, name], - artThingsGallery.length && [null, `(${[ - `<a href="${C.ARTIST_DIRECTORY}/${artist.directory}/" class="current">Info</a>`, - `<a href="${C.ARTIST_DIRECTORY}/${artist.directory}/gallery/">Gallery</a>` - ].join(', ')})`] - ] - } - }); + const generateTrackList = (chunks, {strings, to}) => fixWS` + <dl> + ${chunks.map(({album, chunk, duration}) => fixWS` + <dt>${strings('artistPage.creditList.album.withDate.withDuration', { + album: strings.link.album(album, {to}), + date: strings.count.date(album.date), + duration: strings.count.duration(duration, {approximate: true}) + })}</dt> + <dd><ul> + ${(chunk + .map(({track, ...props}) => ({ + aka: track.aka, + entry: strings('artistPage.creditList.entry.track.withDuration', { + track: strings.link.track(track, {to}), + duration: strings.count.duration(track.duration, {to}) + }), + ...props + })) + .map(({aka, ...opts}) => `<li ${classes(aka && 'rerelease')}>${generateEntryAccents({strings, to, aka, ...opts})}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `; - if (artThingsGallery.length) { - await writePage([C.ARTIST_DIRECTORY, artist.directory, 'gallery'], { - title: name + ' - Gallery', + const avatarPath = path.join(C.MEDIA_ARTIST_AVATAR_DIRECTORY, artist.directory + '.jpg'); + let avatarFileExists = null; + + return async ({strings, writePage}) => { + // The outer step, used for gathering data, is always sync. This is + // normally fine 8ecause pretty much all the data we will ever need + // across 8uilds is available for synchronous access - 8ut this here + // is an exception, and we have to evaluate it asynchronously. Still, + // we don't want to perform that access() oper8tion any more than + // necessary, so we cache the value in a varia8le shared across calls + // to this 8uild function. + avatarFileExists = avatarFileExists ?? (wikiInfo.features.artistAvatars && + await access(path.join(mediaPath, avatarPath)).then(() => true, () => false)); + + await writePage('artist', artist.directory, ({to}) => ({ + title: strings('artistPage.title', {artist: name}), main: { - classes: ['top-index'], content: fixWS` - <h1>${name} - Gallery</h1> - <p class="quick-info">(Contributed to ${s(artThingsGallery.length, 'cover art')})</p> - <div class="grid-listing"> - ${getGridHTML({ - entries: artThingsGallery.map(item => ({item})), - srcFn: thing => (thing.album - ? getTrackCover(thing) - : getAlbumCover(thing)), - hrefFn: thing => (thing.album - ? `${C.TRACK_DIRECTORY}/${thing.directory}/` - : `${C.ALBUM_DIRECTORY}/${thing.directory}`) - })} - </div> + ${avatarFileExists && generateCoverLink({ + strings, to, + src: `/${C.MEDIA_DIRECTORY}/${avatarPath}`, + alt: strings('misc.alt.artistAvatar') + })} + <h1>${strings('artistPage.title', {artist: name})}</h1> + ${note && fixWS` + <p>${strings('releaseInfo.note')}</p> + <blockquote> + ${transformMultiline(note, {strings, to})} + </blockquote> + <hr> + `} + ${urls.length && `<p>${strings('releaseInfo.visitOn', { + links: strings.list.or(urls.map(url => fancifyURL(url, {strings}))) + })}</p>`} + ${hasGallery && `<p>${strings('artistPage.viewArtGallery', { + link: strings.link.artistGallery(artist, { + to, + text: strings('artistPage.viewArtGallery.link') + }) + })}</p>`} + <p>${strings('misc.jumpTo.withLinks', { + links: strings.list.comma([ + [ + [...releasedTracks, ...unreleasedTracks].length && `<a href="#tracks">${strings('artistPage.trackList.title')}</a>`, + unreleasedTracks.length && `(<a href="#unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</a>)` + ].filter(Boolean).join(' '), + artThingsAll.length && `<a href="#art">${strings('artistPage.artList.title')}</a>`, + wikiInfo.features.flashesAndGames && flashes.length && `<a href="#flashes">${strings('artistPage.flashList.title')}</a>`, + commentaryThings.length && `<a href="#commentary">${strings('artistPage.commentaryList.title')}</a>` + ].filter(Boolean)) + })}</p> + ${(releasedTracks.length || unreleasedTracks.length) && fixWS` + <h2 id="tracks">${strings('artistPage.trackList.title')}</h2> + `} + ${releasedTracks.length && fixWS` + <p>${strings('artistPage.contributedDurationLine', { + artist: artist.name, + duration: strings.count.duration(totalReleasedDuration, {approximate: true, unit: true}) + })}</p> + ${generateTrackList(releasedTrackListChunks, {strings, to})} + `} + ${unreleasedTracks.length && fixWS` + <h3 id="unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</h3> + ${generateTrackList(unreleasedTrackListChunks, {strings, to})} + `} + ${artThingsAll.length && fixWS` + <h2 id="art">${strings('artistPage.artList.title')}</h2> + ${hasGallery && `<p>${strings('artistPage.viewArtGallery.orBrowseList', { + link: strings.link.artistGallery(artist, { + to, + text: strings('artistPage.viewArtGallery.link') + }) + })}</p>`} + <dl> + ${artListChunks.map(({album, chunk}) => fixWS` + <dt>${strings('artistPage.creditList.album.withDate', { + album: strings.link.album(album, {to}), + date: strings.count.date(album.date) + })}</dt> + <dd><ul> + ${(chunk + .map(({album, track, key, ...props}) => ({ + entry: (track + ? strings('artistPage.creditList.entry.track', { + track: strings.link.track(track, {to}) + }) + : `<i>${strings('artistPage.creditList.entry.album.' + { + wallpaperArtists: 'wallpaperArt', + coverArtists: 'coverArt' + }[key])}</i>`), + ...props + })) + .map(opts => generateEntryAccents({strings, to, ...opts})) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `} + ${wikiInfo.features.flashesAndGames && flashes.length && fixWS` + <h2 id="flashes">${strings('artistPage.flashList.title')}</h2> + <dl> + ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS` + <dt>${strings('artistPage.creditList.flashAct.withDateRange', { + act: strings.link.flash(chunk[0].flash, {to, text: act.name}), + dateRange: strings.count.dateRange([dateFirst, dateLast]) + })}</dt> + <dd><ul> + ${(chunk + .map(({flash, ...props}) => ({ + entry: strings('artistPage.creditList.entry.flash', { + flash: strings.link.flash(flash, {to}) + }), + ...props + })) + .map(opts => generateEntryAccents({strings, to, ...opts})) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `} + ${commentaryThings.length && fixWS` + <h2 id="commentary">${strings('artistPage.commentaryList.title')}</h2> + <dl> + ${commentaryListChunks.map(({album, chunk}) => fixWS` + <dt>${strings('artistPage.creditList.album', { + album: strings.link.album(album, {to}) + })}</dt> + <dd><ul> + ${(chunk + .map(({album, track, ...props}) => track + ? strings('artistPage.creditList.entry.track', { + track: strings.link.track(track, {to}) + }) + : `<i>${strings('artistPage.creditList.entry.album.commentary')}</i>`) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `} ` }, - nav: { - links: [ - ['./', wikiInfo.shortName], - wikiInfo.features.listings && [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [null, 'Artist:'], - [`${C.ARTIST_DIRECTORY}/${artist.directory}/`, name], - [null, `(${[ - `<a href="${C.ARTIST_DIRECTORY}/${artist.directory}/">Info</a>`, - `<a href="${C.ARTIST_DIRECTORY}/${artist.directory}/gallery/" class="current">Gallery</a>` - ].join(', ')})`] - ] - } - }); + nav: generateNavForArtist(artist, {strings, to, isGallery: false, hasGallery}) + })); + + if (hasGallery) { + await writePage('artistGallery', artist.directory, ({to}) => ({ + title: strings('artistGalleryPage.title', {artist: name}), + + main: { + classes: ['top-index'], + content: fixWS` + <h1>${strings('artistGalleryPage.title', {artist: name})}</h1> + <p class="quick-info">${strings('artistGalleryPage.infoLine', { + coverArts: strings.count.coverArts(artThingsGallery.length, {unit: true}) + })}</p> + <div class="grid-listing"> + ${getGridHTML({ + strings, to, + entries: artThingsGallery.map(item => ({item})), + srcFn: thing => (thing.album + ? getTrackCover(thing, {to}) + : getAlbumCover(thing, {to})), + hrefFn: thing => (thing.album + ? to.track(thing.directory) + : to.album(thing.directory)) + })} + </div> + ` + }, + + nav: generateNavForArtist(artist, {strings, to, isGallery: true, hasGallery}) + })); + } } } -async function writeArtistAliasPage(artist) { - const { alias } = artist; +function generateNavForArtist(artist, {strings, to, isGallery, hasGallery}) { + const infoGalleryLinks = (hasGallery && + generateInfoGalleryLinks('artist', 'artistGallery', artist, isGallery, {strings, to})) - const directory = path.join(outputPath, C.ARTIST_DIRECTORY, artist.directory); - const file = path.join(directory, 'index.html'); - const target = `/${C.ARTIST_DIRECTORY}/${alias.directory}/`; + return { + links: [ + { + href: to.home(), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to.listingIndex(), + title: strings('listingIndex.title') + }, + { + html: strings('artistPage.nav.artist', { + artist: strings.link.artist(artist, {class: 'current', to}) + }) + }, + hasGallery && + { + divider: false, + html: `(${infoGalleryLinks})` + } + ] + }; +} - await mkdirp(directory); - await writeFile(file, generateRedirectPage(alias.name, target)); +function writeArtistAliasPage(artist) { + const { alias } = artist; + + return async ({baseDirectory, strings, writePage}) => { + const { code } = strings; + const paths = writePage.paths(baseDirectory, 'artist', alias.directory); + const content = generateRedirectPage(alias.name, paths.pathname, {strings}); + await writePage.write(content, {paths}); + }; } -function generateRedirectPage(title, target) { +function generateRedirectPage(title, target, {strings}) { return fixWS` <!DOCTYPE html> <html> <head> - <title>Moved to ${title}</title> + <title>${strings('redirectPage.title', {title})}</title> <meta charset="utf-8"> <meta http-equiv="refresh" content="0;url=${target}"> <link rel="canonical" href="${target}"> @@ -2530,666 +3315,1049 @@ function generateRedirectPage(title, target) { </head> <body> <main> - <h1>Moved to ${title}</h1> - <p>This page has been moved to <a href="${target}">${target}</a>.</p> + <h1>${strings('redirectPage.title', {title})}</h1> + <p>${strings('redirectPage.infoLine', { + target: `<a href="${target}">${target}</a>` + })}</p> </main> </body> </html> `; } -function albumChunkedList(tracks, getLI, showDate = true, datePropertyOrFn = 'date') { - const getAlbum = thing => thing.album ? thing.album : thing; - const dateFn = (typeof datePropertyOrFn === 'function' - ? datePropertyOrFn - : track => track[datePropertyOrFn]); - return fixWS` - <dl> - ${tracks.slice().sort((a, b) => dateFn(a) - dateFn(b)).map((thing, i, sorted) => { - const li = getLI(thing, i); - const album = getAlbum(thing); - const previous = sorted[i - 1]; - if (i === 0 || album !== getAlbum(previous) || (showDate && +dateFn(thing) !== +dateFn(previous))) { - const heading = fixWS` - <dt> - <a href="${C.ALBUM_DIRECTORY}/${getAlbum(thing).directory}/" style="${getLinkThemeString(getAlbum(thing))}">${getAlbum(thing).name}</a> - ${showDate && `(${getDateString({date: dateFn(thing)})})`} - </dt> - <dd><ul> - `; - if (i > 0) { - return ['</ul></dd>', heading, li]; - } else { - return [heading, li]; - } - } else { - return [li]; - } - }).reduce((acc, arr) => acc.concat(arr), []).join('\n')} - </dl> - `; -} +function writeFlashPages() { + if (!wikiInfo.features.flashesAndGames) { + return; + } -function actChunkedList(flashes, getLI, showDate = true, dateProperty = 'date') { - return fixWS` - <dl> - ${flashes.slice().sort((a, b) => a[dateProperty] - b[dateProperty]).map((flash, i, sorted) => { - const li = getLI(flash, i); - const act = flash.act; - const previous = sorted[i - 1]; - if (i === 0 || act !== previous.act) { - const heading = fixWS` - <dt> - <a href="${C.FLASH_DIRECTORY}/${sorted.find(flash => !flash.act8r8k && flash.act === act).directory}/" style="${getLinkThemeString(flash)}">${flash.act}</a> - </dt> - <dd><ul> - `; - if (i > 0) { - return ['</ul></dd>', heading, li]; - } else { - return [heading, li]; - } - } else { - return [li]; - } - }).reduce((acc, arr) => acc.concat(arr), []).join('\n')} - </dl> - `; + return [ + writeFlashIndex(), + ...flashData.map(writeFlashPage) + ]; } -async function writeFlashPages() { - await progressPromiseAll('Writing Flash pages.', queue(flashData - .filter(flash => !flash.act8r8k) - .map(curry(writeFlashPage)), queueSize)); -} +function writeFlashIndex() { + return ({strings, writePage}) => writePage('flashIndex', '', ({to}) => ({ + title: strings('flashIndex.title'), -async function writeFlashPage(flash) { - const kebab = getFlashDirectory(flash); + main: { + classes: ['flash-index'], + content: fixWS` + <h1>${strings('flashIndex.title')}</h1> + <div class="long-content"> + <p class="quick-info">${strings('misc.jumpTo')}</p> + <ul class="quick-info"> + ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS` + <li><a href="#${anchor}" style="${getLinkThemeString({color: jumpColor})}">${jump}</a></li> + `).join('\n')} + </ul> + </div> + ${flashActData.map((act, i) => fixWS` + <h2 id="${act.anchor}" style="${getLinkThemeString(act)}"><a href="${to.flash(act.flashes[0].directory)}">${act.name}</a></h2> + <div class="grid-listing"> + ${getFlashGridHTML({ + strings, to, + entries: act.flashes.map(flash => ({item: flash})), + lazy: i === 0 ? 4 : true + })} + </div> + `).join('\n')} + ` + }, - const flashes = flashData.filter(flash => !flash.act8r8k); - const index = flashes.indexOf(flash); - const previous = flashes[index - 1]; - const next = flashes[index + 1]; - const parts = [ - previous && `<a href="${getHrefOfAnythingMan(previous)}" id="previous-button" title="${previous.name}">Previous</a>`, - next && `<a href="${getHrefOfAnythingMan(next)}" id="next-button" title="${next.name}">Next</a>` - ].filter(Boolean); + nav: {simple: true} + })); +} - await writePage([C.FLASH_DIRECTORY, kebab], { - title: flash.name, +function writeFlashPage(flash) { + return ({strings, writePage}) => writePage('flash', flash.directory, ({to}) => ({ + title: strings('flashPage.title', {flash: flash.name}), theme: getThemeString(flash, [ `--flash-directory: ${flash.directory}` ]), + main: { content: fixWS` - <h1>${flash.name}</h1> + <h1>${strings('flashPage.title', {flash: flash.name})}</h1> ${generateCoverLink({ - src: getFlashCover(flash), - alt: 'cover art' + strings, to, + src: to.flashArt(flash.directory), + alt: strings('misc.alt.flashArt') })} - <p>Released ${getDateString(flash)}.</p> - ${(flash.page || flash.urls.length) && `<p>Play on ${joinNoOxford( - [ + <p>${strings('releaseInfo.released', {date: strings.count.date(flash.date)})}</p> + ${(flash.page || flash.urls.length) && `<p>${strings('releaseInfo.playOn', { + links: strings.list.or([ flash.page && getFlashLink(flash), ...flash.urls - ].map(url => fancifyFlashURL(url, flash)), 'or')}.</p>`} - ${flash.contributors.textContent && fixWS` - <p>Contributors:<br>${transformInline(flash.contributors.textContent)}</p> - `} + ].map(url => fancifyFlashURL(url, flash, {strings}))) + })}</p>`} ${flash.tracks.length && fixWS` <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p> <ul> ${flash.tracks.map(track => fixWS` <li> <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - <span class="by">by ${getArtistString(track.artists)}</span> + <span class="by">by ${getArtistString(track.artists, {strings, to})}</span> </li> `).join('\n')} </ul> `} + ${flash.contributors.textContent && fixWS` + <p> + ${strings('releaseInfo.contributors')} + <br> + ${transformInline(flash.contributors.textContent, {strings, to})} + </p> + `} ${flash.contributors.length && fixWS` - <p>Contributors:</p> + <p>${strings('releaseInfo.contributors')}</p> <ul> - ${flash.contributors.map(contrib => fixWS`<li>${getArtistString([contrib], true)}</li>`).join('\n')} + ${flash.contributors + .map(contrib => `<li>${getArtistString([contrib], { + strings, to, + showContrib: true, + showIcons: true + })}</li>`) + .join('\n')} </ul> `} ` }, - sidebarLeft: { - content: generateSidebarForFlashes(flash) - }, - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.FLASH_DIRECTORY}/`, `Flashes & Games`], - [`${C.FLASH_DIRECTORY}/${kebab}/`, flash.name], - parts.length && [null, `(${parts.join(', ')})`] - ].filter(Boolean), - content: fixWS` - <div> - ${chronologyLinks(flash, { - headingWord: 'flash/game', - sourceData: flashData, - filters: [ - { - mapProperty: 'contributors', - toArtist: ({ who }) => who - } - ] - })} - </div> - ` - } - }); + + sidebarLeft: generateSidebarForFlash(flash, {strings, to}), + nav: generateNavForFlash(flash, {strings, to}) + })); +} + +function generateNavForFlash(flash, {strings, to}) { + const previousNextLinks = generatePreviousNextLinks('flash', flash, flashData, {strings, to}); + + return { + links: [ + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: to.flashIndex(), + title: strings('flashIndex.title') + }, + { + html: strings('flashPage.nav.flash', { + flash: strings.link.flash(flash, {class: 'current', to}) + }) + }, + previousNextLinks && + { + divider: false, + html: `(${previousNextLinks})` + } + ], + + content: fixWS` + <div> + ${chronologyLinks(flash, { + strings, to, + headingWord: strings('misc.chronology.flash'), + contribKey: 'contributors', + getThings: artist => artist.flashes.asContributor + })} + </div> + ` + }; } -function generateSidebarForFlashes(flash) { - const act6 = flashData.findIndex(f => f.act.startsWith('Act 6')); - const postCanon = flashData.findIndex(f => f.act.includes('Post Canon')); - const outsideCanon = postCanon + flashData.slice(postCanon).findIndex(f => !f.act.includes('Post Canon')); - const index = flashData.indexOf(flash); +function generateSidebarForFlash(flash, {strings, to}) { + // all hard-coded, sorry :( + // this doesnt have a super portable implementation/design...yet!! + + const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6')); + const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon')); + const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon')); + const actIndex = flashActData.indexOf(flash.act); const side = ( - (index < 0) ? 0 : - (index < act6) ? 1 : - (index <= outsideCanon) ? 2 : + (actIndex < 0) ? 0 : + (actIndex < act6) ? 1 : + (actIndex <= outsideCanon) ? 2 : 3 ); const currentAct = flash && flash.act; - return fixWS` - <h1><a href="${C.FLASH_DIRECTORY}/">Flashes & Games</a></h1> - <dl> - ${flashData.filter(f => f.act8r8k).filter(({ act }) => - act.startsWith('Act 1') || - act.startsWith('Act 6 Act 1') || - act.startsWith('Hiveswap') || - ( - flashData.findIndex(f => f.act === act) < act6 ? side === 1 : - flashData.findIndex(f => f.act === act) < outsideCanon ? side === 2 : - true - ) - ).flatMap(({ act, color }) => [ - act.startsWith('Act 1') && `<dt ${classes('side', side === 1 && 'current')}><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flashData.find(f => !f.act8r8k && f.act.startsWith('Act 1')))}/" style="--primary-color: #4ac925">Side 1 (Acts 1-5)</a></dt>` - || act.startsWith('Act 6 Act 1') && `<dt ${classes('side', side === 2 && 'current')}><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flashData.find(f => !f.act8r8k && f.act.startsWith('Act 6')))}/" style="--primary-color: #1076a2">Side 2 (Acts 6-7)</a></dt>` - || act.startsWith('Hiveswap Act 1') && `<dt ${classes('side', side === 3 && 'current')}><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flashData.find(f => !f.act8r8k && f.act.startsWith('Hiveswap')))}/" style="--primary-color: #008282">Outside Canon (Misc. Games)</a></dt>`, - ( - flashData.findIndex(f => f.act === act) < act6 ? side === 1 : - flashData.findIndex(f => f.act === act) < outsideCanon ? side === 2 : - true - ) && `<dt ${classes(act === currentAct && 'current')}><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flashData.find(f => !f.act8r8k && f.act === act))}/" style="${getLinkThemeString({color})}">${act}</a></dt>`, - act === currentAct && fixWS` - <dd><ul> - ${flashData.filter(f => !f.act8r8k && f.act === act).map(f => fixWS` - <li ${classes(f === flash && 'current')}><a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(f)}/" style="${getLinkThemeString(f)}">${f.name}</a></li> - `).join('\n')} - </ul></dd> - ` - ]).filter(Boolean).join('\n')} - </dl> - `; + return { + content: fixWS` + <h1>${strings.link.flashIndex('', {to, text: strings('flashIndex.title')})}</h1> + <dl> + ${flashActData.filter(act => + act.name.startsWith('Act 1') || + act.name.startsWith('Act 6 Act 1') || + act.name.startsWith('Hiveswap') || + // Sorry not sorry -Yiffy + (({index = flashActData.indexOf(act)} = {}) => ( + index < act6 ? side === 1 : + index < outsideCanon ? side === 2 : + true + ))() + ).flatMap(act => [ + act.name.startsWith('Act 1') && `<dt ${classes('side', side === 1 && 'current')}><a href="${to.flash(act.flashes[0].directory)}" style="--primary-color: #4ac925">Side 1 (Acts 1-5)</a></dt>` + || act.name.startsWith('Act 6 Act 1') && `<dt ${classes('side', side === 2 && 'current')}><a href="${to.flash(act.flashes[0].directory)}" style="--primary-color: #1076a2">Side 2 (Acts 6-7)</a></dt>` + || act.name.startsWith('Hiveswap Act 1') && `<dt ${classes('side', side === 3 && 'current')}><a href="${to.flash(act.flashes[0].directory)}" style="--primary-color: #008282">Outside Canon (Misc. Games)</a></dt>`, + (({index = flashActData.indexOf(act)} = {}) => ( + index < act6 ? side === 1 : + index < outsideCanon ? side === 2 : + true + ))() + && `<dt ${classes(act === currentAct && 'current')}><a href="${to.flash(act.flashes[0].directory)}" style="${getLinkThemeString(act)}">${act.name}</a></dt>`, + act === currentAct && fixWS` + <dd><ul> + ${act.flashes.map(f => fixWS` + <li ${classes(f === flash && 'current')}>${strings.link.flash(f, {to})}</li> + `).join('\n')} + </ul></dd> + ` + ]).filter(Boolean).join('\n')} + </dl> + ` + }; } -function writeListingPages() { - if (!wikiInfo.features.listings) { - return; - } +const listingSpec = [ + { + directory: 'albums/by-name', + title: ({strings}) => strings('listingPage.listAlbums.byName.title'), - const reversedTracks = trackData.slice().reverse(); - const reversedArtThings = justEverythingSortedByArtDateMan.slice().reverse(); + data() { + return albumData.slice() + .sort(sortByName); + }, - const getAlbumLI = (album, extraText = '') => fixWS` - <li> - <a href="${C.ALBUM_DIRECTORY}/${album.directory}/" style="${getLinkThemeString(album)}">${album.name}</a> - ${extraText} - </li> - `; + row(album, {strings, to}) { + return strings('listingPage.listAlbums.byName.item', { + album: strings.link.album(album, {to}), + tracks: strings.count.tracks(album.tracks.length, {unit: true}) + }); + } + }, - const sortByName = (a, b) => { - let an = a.name.toLowerCase(); - let bn = b.name.toLowerCase(); - if (an.startsWith('the ')) an = an.slice(4); - if (bn.startsWith('the ')) bn = bn.slice(4); - return an < bn ? -1 : an > bn ? 1 : 0; - }; + { + directory: 'albums/by-tracks', + title: ({strings}) => strings('listingPage.listAlbums.byTracks.title'), - const listingDescriptors = [ - [['albums', 'by-name'], `Albums - by Name`, albumData.slice() - .sort(sortByName) - .map(album => getAlbumLI(album, `(${album.tracks.length} tracks)`))], - [['albums', 'by-tracks'], `Albums - by Tracks`, albumData.slice() - .sort((a, b) => b.tracks.length - a.tracks.length) - .map(album => getAlbumLI(album, `(${s(album.tracks.length, 'track')})`))], - [['albums', 'by-duration'], `Albums - by Duration`, albumData.slice() - .map(album => ({album, duration: getTotalDuration(album.tracks)})) - .sort((a, b) => b.duration - a.duration) - .map(({ album, duration }) => getAlbumLI(album, `(${getDurationString(duration)})`))], - [['albums', 'by-date'], `Albums - by Date`, C.sortByDate(albumData.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)) - .map(album => getAlbumLI(album, `(${getDateString(album)})`))], - [['artists', 'by-name'], `Artists - by Name`, artistData - .filter(artist => !artist.alias) - .sort(sortByName) - .map(artist => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/">${artist.name}</a> - (${'' + C.getArtistNumContributions(artist)} <abbr title="contributions (to ${joinNoOxford(['music', 'art', wikiInfo.features.flashesAndGames && 'flashes'])})">c.</abbr>) - </li> - `)], - [['artists', 'by-contribs'], `Artists - by Contributions`, fixWS` - <div class="content-columns"> - <div class="column"> - <h2>Track Contributors</h2> - <ul> - ${artistData - .filter(artist => !artist.alias) - .map(artist => ({ - name: artist.name, - contribs: ( - artist.tracks.asContributor.length + - artist.tracks.asArtist.length - ) - })) - .sort((a, b) => b.contribs - a.contribs) - .filter(({ contribs }) => contribs) - .map(({ name, contribs }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${C.getArtistDirectory(name)}">${name}</a> - (${contribs} <abbr title="contributions (to track music)">c.</abbr>) - </li> - `) - .join('\n') - } - </ul> - </div> - <div class="column"> - <h2>Art${wikiInfo.features.flashesAndGames ? ` & Flash` : ''} Contributors</h2> - <ul> - ${artistData - .filter(artist => !artist.alias) - .map(artist => ({ - artist, - contribs: ( - artist.tracks.asCoverArtist.length + - artist.albums.asCoverArtist.length + - artist.albums.asWallpaperArtist.length + - (wikiInfo.features.flashesAndGames ? artist.flashes.asContributor.length : 0) - ) - })) - .sort((a, b) => b.contribs - a.contribs) - .filter(({ contribs }) => contribs) - .map(({ artist, contribs }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}">${artist.name}</a> - (${contribs} <abbr title="contributions (to art${wikiInfo.features.flashesAndGames ? ' and flashes' : ''})">c.</abbr>) - </li> - `) - .join('\n') - } - </ul> - </div> - </div> - `], - [['artists', 'by-commentary'], `Artists - by Commentary Entries`, artistData - .filter(artist => !artist.alias) - .map(artist => ({artist, commentary: artist.tracks.asCommentator.length + artist.albums.asCommentator.length})) - .filter(({ commentary }) => commentary > 0) - .sort((a, b) => b.commentary - a.commentary) - .map(({ artist, commentary }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/#commentary">${artist.name}</a> - (${commentary} ${commentary === 1 ? 'entry' : 'entries'}) - </li> - `)], - [['artists', 'by-duration'], `Artists - by Duration`, artistData - .filter(artist => !artist.alias) - .map(artist => ({artist, duration: getTotalDuration( - [...artist.tracks.asArtist, ...artist.tracks.asContributor].filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)) - })) - .filter(({ duration }) => duration > 0) - .sort((a, b) => b.duration - a.duration) - .map(({ artist, duration }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/#tracks">${artist.name}</a> - (~${getDurationString(duration)}) - </li> - `)], - [['artists', 'by-latest'], `Artists - by Latest Contribution`, fixWS` - <div class="content-columns"> - <div class="column"> - <h2>Track Contributors</h2> - <ul> - ${C.sortByDate(artistData - .filter(artist => !artist.alias) - .map(artist => ({ - artist, - date: reversedTracks.find(({ album, artists, contributors }) => ( - album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && - [...artists, ...contributors].some(({ who }) => who === artist) - ))?.date - })) - .filter(({ date }) => date) - .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0) - ).reverse().map(({ artist, date }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/">${artist.name}</a> - (${getDateString({date})}) - </li> - `).join('\n')} - </ul> + data() { + return albumData.slice() + .sort((a, b) => b.tracks.length - a.tracks.length); + }, + + row(album, {strings, to}) { + return strings('listingPage.listAlbums.byTracks.item', { + album: strings.link.album(album, {to}), + tracks: strings.count.tracks(album.tracks.length, {unit: true}) + }); + } + }, + + { + directory: 'albums/by-duration', + title: ({strings}) => strings('listingPage.listAlbums.byDuration.title'), + + data() { + return albumData.slice() + .map(album => ({album, duration: getTotalDuration(album.tracks)})) + .sort((a, b) => b.duration - a.duration); + }, + + row({album, duration}, {strings, to}) { + return strings('listingPage.listAlbums.byDuration.item', { + album: strings.link.album(album, {to}), + duration: strings.count.duration(duration) + }); + } + }, + + { + directory: 'albums/by-date', + title: ({strings}) => strings('listingPage.listAlbums.byDate.title'), + + data() { + return C.sortByDate(albumData + .filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)); + }, + + row(album, {strings, to}) { + return strings('listingPage.listAlbums.byDate.item', { + album: strings.link.album(album, {to}), + date: strings.count.date(album.date) + }); + } + }, + + { + directory: 'artists/by-name', + title: ({strings}) => strings('listingPage.listArtists.byName.title'), + + data() { + return artistData.slice() + .sort(sortByName) + .map(artist => ({artist, contributions: C.getArtistNumContributions(artist)})); + }, + + row({artist, contributions}, {strings, to}) { + return strings('listingPage.listArtists.byName.item', { + artist: strings.link.artist(artist, {to}), + contributions: strings.count.contributions(contributions, {to, unit: true}) + }); + } + }, + + { + directory: 'artists/by-contribs', + title: ({strings}) => strings('listingPage.listArtists.byContribs.title'), + + data() { + return { + toTracks: (artistData + .map(artist => ({ + artist, + contributions: ( + artist.tracks.asContributor.length + + artist.tracks.asArtist.length + ) + })) + .sort((a, b) => b.contributions - a.contributions) + .filter(({ contributions }) => contributions)), + + toArtAndFlashes: (artistData + .map(artist => ({ + artist, + contributions: ( + artist.tracks.asCoverArtist.length + + artist.albums.asCoverArtist.length + + artist.albums.asWallpaperArtist.length + + (wikiInfo.features.flashesAndGames + ? artist.flashes.asContributor.length + : 0) + ) + })) + .sort((a, b) => b.contributions - a.contributions) + .filter(({ contributions }) => contributions)) + }; + }, + + html({toTracks, toArtAndFlashes}, {strings, to}) { + return fixWS` + <div class="content-columns"> + <div class="column"> + <h2>${strings('listingPage.misc.trackContributors')}</h2> + <ul> + ${(toTracks + .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', { + artist: strings.link.artist(artist, {to}), + contributions: strings.count.contributions(contributions, {unit: true}) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul> + </div> + <div class="column"> + <h2>${strings('listingPage.misc' + + (wikiInfo.features.flashesAndGames + ? '.artAndFlashContributors' + : '.artContributors'))}</h2> + <ul> + ${(toArtAndFlashes + .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', { + artist: strings.link.artist(artist, {to}), + contributions: strings.count.contributions(contributions, {unit: true}) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul> + </div> </div> - <div class="column"> - <h2>Art${wikiInfo.features.flashesAndGames ? ` & Flash` : ''} Contributors</h2> - <ul> - ${C.sortByDate(artistData - .filter(artist => !artist.alias) - .map(artist => { - const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => ( - album?.directory !== C.UNRELEASED_TRACKS_DIRECTORY && - [...coverArtists || [], ...!album && contributors || []].some(({ who }) => who === artist) - )); - return thing && { - artist, - date: (thing.coverArtists?.some(({ who }) => who === artist) - ? thing.coverArtDate - : thing.date) - }; - }) - .filter(Boolean) - .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0) - ).reverse().map(({ artist, date }) => fixWS` - <li> - <a href="${C.ARTIST_DIRECTORY}/${artist.directory}">${artist.name}</a> - (${getDateString({date})}) - </li> - `).join('\n')} - </ul> + `; + } + }, + + { + directory: 'artists/by-commentary', + title: ({strings}) => strings('listingPage.listArtists.byCommentary.title'), + + data() { + return artistData + .map(artist => ({artist, entries: artist.tracks.asCommentator.length + artist.albums.asCommentator.length})) + .filter(({ entries }) => entries) + .sort((a, b) => b.entries - a.entries); + }, + + row({artist, entries}, {strings, to}) { + return strings('listingPage.listArtists.byCommentary.item', { + artist: strings.link.artist(artist, {to}), + entries: strings.count.commentaryEntries(entries, {unit: true}) + }); + } + }, + + { + directory: 'artists/by-duration', + title: ({strings}) => strings('listingPage.listArtists.byDuration.title'), + + data() { + return artistData + .map(artist => ({artist, duration: getTotalDuration( + [...artist.tracks.asArtist, ...artist.tracks.asContributor].filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)) + })) + .filter(({ duration }) => duration > 0) + .sort((a, b) => b.duration - a.duration); + }, + + row({artist, duration}, {strings, to}) { + return strings('listingPage.listArtists.byDuration.item', { + artist: strings.link.artist(artist, {to}), + duration: strings.count.duration(duration) + }); + } + }, + + { + directory: 'artists/by-latest', + title: ({strings}) => strings('listingPage.listArtists.byLatest.title'), + + data() { + const reversedTracks = trackData.slice().reverse(); + const reversedArtThings = justEverythingSortedByArtDateMan.slice().reverse(); + + return { + toTracks: C.sortByDate(artistData + .filter(artist => !artist.alias) + .map(artist => ({ + artist, + date: reversedTracks.find(({ album, artists, contributors }) => ( + album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && + [...artists, ...contributors].some(({ who }) => who === artist) + ))?.date + })) + .filter(({ date }) => date) + .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)).reverse(), + + toArtAndFlashes: C.sortByDate(artistData + .filter(artist => !artist.alias) + .map(artist => { + const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => ( + album?.directory !== C.UNRELEASED_TRACKS_DIRECTORY && + [...coverArtists || [], ...!album && contributors || []].some(({ who }) => who === artist) + )); + return thing && { + artist, + date: (thing.coverArtists?.some(({ who }) => who === artist) + ? thing.coverArtDate + : thing.date) + }; + }) + .filter(Boolean) + .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0) + ).reverse() + }; + }, + + html({toTracks, toArtAndFlashes}, {strings, to}) { + return fixWS` + <div class="content-columns"> + <div class="column"> + <h2>${strings('listingPage.misc.trackContributors')}</h2> + <ul> + ${(toTracks + .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', { + artist: strings.link.artist(artist, {to}), + date: strings.count.date(date) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul> + </div> + <div class="column"> + <h2>${strings('listingPage.misc' + + (wikiInfo.features.flashesAndGames + ? '.artAndFlashContributors' + : '.artContributors'))}</h2> + <ul> + ${(toArtAndFlashes + .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', { + artist: strings.link.artist(artist, {to}), + date: strings.count.date(date) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul> + </div> </div> - </div> - `], - wikiInfo.features.groupUI && - [['groups', 'by-name'], `Groups - by Name`, groupData - .filter(x => x.isGroup) - .sort(sortByName) - .map(group => fixWS` - <li><a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a></li> - `)], - wikiInfo.features.groupUI && - [['groups', 'by-category'], `Groups - by Category`, fixWS` - <dl> - ${groupData.filter(x => x.isCategory).map(category => fixWS` - <dt><a href="${C.GROUP_DIRECTORY}/${category.groups[0].directory}/" style="${getLinkThemeString(category)}">${category.name}</a></li> - <dd><ul> - ${category.groups.map(group => fixWS` - <li><a href="${C.GROUP_DIRECTORY}/${group.directory}/gallery/" style="${getLinkThemeString(group)}">${group.name}</a></li> - `).join('\n')} - </ul></dd> - `).join('\n')} - </dl> - `], - wikiInfo.features.groupUI && - [['groups', 'by-albums'], `Groups - by Albums`, groupData - .filter(x => x.isGroup) - .map(group => ({group, albums: group.albums.length})) - .sort((a, b) => b.albums - a.albums) - .map(({ group, albums }) => fixWS` - <li><a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a> (${s(albums, 'album')})</li> - `)], - wikiInfo.features.groupUI && - [['groups', 'by-tracks'], `Groups - by Tracks`, groupData - .filter(x => x.isGroup) - .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)})) - .sort((a, b) => b.tracks - a.tracks) - .map(({ group, tracks }) => fixWS` - <li><a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a> (${s(tracks, 'track')})</li> - `)], - wikiInfo.features.groupUI && - [['groups', 'by-duration'], `Groups - by Duration`, groupData - .filter(x => x.isGroup) - .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))})) - .sort((a, b) => b.duration - a.duration) - .map(({ group, duration }) => fixWS` - <li><a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a> (${getDurationString(duration)})</li> - `)], - wikiInfo.features.groupUI && - [['groups', 'by-latest'], `Groups - by Latest Album`, C.sortByDate(groupData - .filter(x => x.isGroup) - .map(group => ({group, date: group.albums[group.albums.length - 1].date})) - // So this is kinda tough to explain, 8ut 8asically, when we reverse the list after sorting it 8y d8te - // (so that the latest d8tes come first), it also flips the order of groups which share the same d8te. - // This happens mostly when a single al8um is the l8test in two groups. So, say one such al8um is in - // the groups "Fandom" and "UMSPAF". Per category order, Fandom is meant to show up 8efore UMSPAF, 8ut - // when we do the reverse l8ter, that flips them, and UMSPAF ends up displaying 8efore Fandom. So we do - // an extra reverse here, which will fix that and only affect groups that share the same d8te (8ecause - // groups that don't will 8e moved 8y the sortByDate call surrounding this). - .reverse() - ).reverse().map(({ group, date }) => fixWS` - <li> - <a href="${C.GROUP_DIRECTORY}/${group.directory}/" style="${getLinkThemeString(group)}">${group.name}</a> - (${getDateString({date})}) - </li> - `)], - [['tracks', 'by-name'], `Tracks - by Name`, trackData.slice() - .sort(sortByName) - .map(track => fixWS` - <li><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></li> - `)], - [['tracks', 'by-album'], `Tracks - by Album`, fixWS` + `; + } + }, + + { + directory: 'groups/by-name', + title: ({strings}) => strings('listingPage.listGroups.byName.title'), + condition: () => wikiInfo.features.groupUI, + + data() { + return groupData.slice().sort(sortByName); + }, + + row(group, {strings, to}) { + return strings('listingPage.listGroups.byCategory.group', { + group: strings.link.groupInfo(group, {to}), + gallery: strings.link.groupGallery(group, { + to, + text: strings('listingPage.listGroups.byCategory.group.gallery') + }) + }); + } + }, + + { + directory: 'groups/by-category', + title: ({strings}) => strings('listingPage.listGroups.byCategory.title'), + condition: () => wikiInfo.features.groupUI, + + html({strings, to}) { + return fixWS` + <dl> + ${groupCategoryData.map(category => fixWS` + <dt>${strings('listingPage.listGroups.byCategory.category', { + category: strings.link.groupInfo(category.groups[0], {to, text: category.name}) + })}</dt> + <dd><ul> + ${(category.groups + .map(group => strings('listingPage.listGroups.byCategory.group', { + group: strings.link.groupInfo(group, {to}), + gallery: strings.link.groupGallery(group, { + to, + text: strings('listingPage.listGroups.byCategory.group.gallery') + }) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `; + } + }, + + { + directory: 'groups/by-albums', + title: ({strings}) => strings('listingPage.listGroups.byAlbums.title'), + condition: () => wikiInfo.features.groupUI, + + data() { + return groupData + .map(group => ({group, albums: group.albums.length})) + .sort((a, b) => b.albums - a.albums); + }, + + row({group, albums}, {strings, to}) { + return strings('listingPage.listGroups.byAlbums.item', { + group: strings.link.groupInfo(group, {to}), + albums: strings.count.albums(albums, {unit: true}) + }); + } + }, + + { + directory: 'groups/by-tracks', + title: ({strings}) => strings('listingPage.listGroups.byTracks.title'), + condition: () => wikiInfo.features.groupUI, + + data() { + return groupData + .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)})) + .sort((a, b) => b.tracks - a.tracks); + }, + + row({group, tracks}, {strings, to}) { + return strings('listingPage.listGroups.byTracks.item', { + group: strings.link.groupInfo(group, {to}), + tracks: strings.count.tracks(tracks, {unit: true}) + }); + } + }, + + { + directory: 'groups/by-duration', + title: ({strings}) => strings('listingPage.listGroups.byDuration.title'), + condition: () => wikiInfo.features.groupUI, + + data() { + return groupData + .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))})) + .sort((a, b) => b.duration - a.duration); + }, + + row({group, duration}, {strings, to}) { + return strings('listingPage.listGroups.byDuration.item', { + group: strings.link.groupInfo(group, {to}), + duration: strings.count.duration(duration) + }); + } + }, + + { + directory: 'groups/by-latest-album', + title: ({strings}) => strings('listingPage.listGroups.byLatest.title'), + condition: () => wikiInfo.features.groupUI, + + data() { + return C.sortByDate(groupData + .map(group => ({group, date: group.albums[group.albums.length - 1].date})) + // So this is kinda tough to explain, 8ut 8asically, when we reverse the list after sorting it 8y d8te + // (so that the latest d8tes come first), it also flips the order of groups which share the same d8te. + // This happens mostly when a single al8um is the l8test in two groups. So, say one such al8um is in + // the groups "Fandom" and "UMSPAF". Per category order, Fandom is meant to show up 8efore UMSPAF, 8ut + // when we do the reverse l8ter, that flips them, and UMSPAF ends up displaying 8efore Fandom. So we do + // an extra reverse here, which will fix that and only affect groups that share the same d8te (8ecause + // groups that don't will 8e moved 8y the sortByDate call surrounding this). + .reverse()).reverse() + }, + + row({group, date}, {strings, to}) { + return strings('listingPage.listGroups.byLatest.item', { + group: strings.link.groupInfo(group, {to}), + date: strings.count.date(date) + }); + } + }, + + { + directory: 'tracks/by-name', + title: ({strings}) => strings('listingPage.listTracks.byName.title'), + + data() { + return trackData.slice().sort(sortByName); + }, + + row(track, {strings, to}) { + return strings('listingPage.listTracks.byName.item', { + track: strings.link.track(track, {to}) + }); + } + }, + + { + directory: 'tracks/by-album', + title: ({strings}) => strings('listingPage.listTracks.byAlbum.title'), + + html({strings, to}) { + return fixWS` <dl> ${albumData.map(album => fixWS` - <dt><a href="${C.ALBUM_DIRECTORY}/${album.directory}/" style="${getLinkThemeString(album)}">${album.name}</a></dt> + <dt>${strings('listingPage.listTracks.byAlbum.album', { + album: strings.link.album(album, {to}) + })}</dt> <dd><ol> - ${album.tracks.map(track => fixWS` - <li><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></li> - `).join('\n')} + ${(album.tracks + .map(track => strings('listingPage.listTracks.byAlbum.track', { + track: strings.link.track(track, {to}) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} </ol></dd> `).join('\n')} </dl> - `], - [['tracks', 'by-date'], `Tracks - by Date`, albumChunkedList( - C.sortByDate(trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)), - track => fixWS` - <li ${classes(track.aka && 'rerelease')}><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> ${track.aka && `<span class="rerelease-label">(re-release)</span>`}</li> - `)], - [['tracks', 'by-duration'], `Tracks - by Duration`, C.sortByDate(trackData.slice()) - .filter(track => track.duration > 0) - .sort((a, b) => b.duration - a.duration) - .map(track => fixWS` - <li> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - (${getDurationString(track.duration)}) - </li> - `)], - [['tracks', 'by-duration-in-album'], `Tracks - by Duration (in Album)`, albumChunkedList(albumData.flatMap(album => album.tracks) - .filter(track => track.duration > 0) - .sort((a, b) => ( - b.album !== a.album ? 0 : - b.duration - a.duration - )), - track => fixWS` - <li> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - (${getDurationString(track.duration)}) - </li> - `, - false, - null)], - [['tracks', 'by-times-referenced'], `Tracks - by Times Referenced`, C.sortByDate(trackData.slice()) - .filter(track => track.referencedBy.length > 0) - .sort((a, b) => b.referencedBy.length - a.referencedBy.length) - .map(track => fixWS` - <li> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a> - (${s(track.referencedBy.length, 'time')} referenced) - </li> - `)], - wikiInfo.features.flashesAndGames && - [['tracks', 'in-flashes', 'by-album'], `Tracks - in Flashes & Games (by Album)`, albumChunkedList( - C.sortByDate(trackData.slice()).filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && track.flashes.length > 0), - track => `<li><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></li>`)], - wikiInfo.features.flashesAndGames && - [['tracks', 'in-flashes', 'by-flash'], `Tracks - in Flashes & Games (by Flash)`, fixWS` - <dl> - ${C.sortByDate(flashData.filter(flash => !flash.act8r8k)) - .map(flash => fixWS` - <dt> - <a href="${C.FLASH_DIRECTORY}/${flash.directory}/" style="${getLinkThemeString(flash)}">${flash.name}</a> - (${getDateString(flash)}) - </dt> + `; + } + }, + + { + directory: 'tracks/by-date', + title: ({strings}) => strings('listingPage.listTracks.byDate.title'), + + data() { + return chunkByProperties( + C.sortByDate(trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)), + ['album', 'date'] + ); + }, + + html(chunks, {strings, to}) { + return fixWS` + <dl> + ${chunks.map(({album, date, chunk: tracks}) => fixWS` + <dt>${strings('listingPage.listTracks.byDate.album', { + album: strings.link.album(album, {to}), + date: strings.count.date(date) + })}</dt> <dd><ul> - ${flash.tracks.map(track => fixWS` - <li><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></li> - `).join('\n')} + ${(tracks + .map(track => track.aka + ? `<li class="rerelease">${strings('listingPage.listTracks.byDate.track.rerelease', { + track: strings.link.track(track, {to}) + })}</li>` + : `<li>${strings('listingPage.listTracks.byDate.track', { + track: strings.link.track(track, {to}) + })}</li>`) + .join('\n'))} </ul></dd> - `) - .join('\n')} - </dl> - `], - [['tracks', 'with-lyrics'], `Tracks - with Lyrics`, albumChunkedList( - C.sortByDate(trackData.slice()) - .filter(track => track.lyrics), - track => fixWS` - <li><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></li> - `)], - wikiInfo.features.artTagUI && - [['tags', 'by-name'], 'Tags - by Name', tagData.slice().sort(sortByName) - .filter(tag => !tag.isCW) - .map(tag => `<li><a href="${C.TAG_DIRECTORY}/${tag.directory}/" style="${getLinkThemeString(tag)}">${tag.name}</a></li>`)], - wikiInfo.features.artTagUI && - [['tags', 'by-uses'], 'Tags - by Uses', tagData.slice().sort(sortByName) - .filter(tag => !tag.isCW) - .map(tag => ({tag, timesUsed: tag.things.length})) - .sort((a, b) => b.timesUsed - a.timesUsed) - .map(({ tag, timesUsed }) => `<li><a href="${C.TAG_DIRECTORY}/${tag.directory}/" style="${getLinkThemeString(tag)}">${tag.name}</a> (${s(timesUsed, 'time')})</li>`)] - ].filter(Boolean); + `).join('\n')} + </dl> + `; + } + }, - const releasedTracks = trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); - const releasedAlbums = albumData.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + { + directory: 'tracks/by-duration', + title: ({strings}) => strings('listingPage.listTracks.byDuration.title'), + + data() { + return trackData + .map(track => ({track, duration: track.duration})) + .filter(({ duration }) => duration > 0) + .sort((a, b) => b.duration - a.duration); + }, - return progressPromiseAll(`Writing listing pages.`, [ - writePage([C.LISTING_DIRECTORY], { - title: `Listings Index`, + row({track, duration}, {strings, to}) { + return strings('listingPage.listTracks.byDuration.item', { + track: strings.link.track(track, {to}), + duration: strings.count.duration(duration) + }); + } + }, - main: { - content: fixWS` - <h1>Listings</h1> - <p>${wikiInfo.name}: <b>${releasedTracks.length}</b> tracks across <b>${releasedAlbums.length}</b> albums, totaling <b>~${getDurationString(getTotalDuration(releasedTracks))}</b> ${getTotalDuration(releasedTracks) > 3600 ? 'hours' : 'minutes'}.</p> - <hr> - <p>Feel free to explore any of the listings linked below and in the sidebar!</p> - ${generateLinkIndexForListings(listingDescriptors)} - ` - }, + { + directory: 'tracks/by-duration-in-album', + title: ({strings}) => strings('listingPage.listTracks.byDurationInAlbum.title'), + + data() { + return albumData.map(album => ({ + album, + tracks: album.tracks.slice().sort((a, b) => b.duration - a.duration) + })); + }, - sidebarLeft: { - content: generateSidebarForListings(listingDescriptors) - }, + html(albums, {strings, to}) { + return fixWS` + <dl> + ${albums.map(({album, tracks}) => fixWS` + <dt>${strings('listingPage.listTracks.byDurationInAlbum.album', { + album: strings.link.album(album, {to}) + })}</dt> + <dd><ul> + ${(tracks + .map(track => strings('listingPage.listTracks.byDurationInAlbum.track', { + track: strings.link.track(track, {to}), + duration: strings.count.duration(track.duration) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </dd></ul> + `).join('\n')} + </dl> + `; + } + }, - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.LISTINGS_DIRECTORY}/`, 'Listings'] - ] - } - }), + { + directory: 'tracks/by-times-referenced', + title: ({strings}) => strings('listingPage.listTracks.byTimesReferenced.title'), + + data() { + return trackData + .map(track => ({track, timesReferenced: track.referencedBy.length})) + .filter(({ timesReferenced }) => timesReferenced > 0) + .sort((a, b) => b.timesReferenced - a.timesReferenced); + }, - mkdirp(path.join(outputPath, C.LISTING_DIRECTORY, 'all-commentary')) - .then(() => writeFile(path.join(outputPath, C.LISTING_DIRECTORY, 'all-commentary', 'index.html'), - generateRedirectPage('Album Commentary', `/${C.COMMENTARY_DIRECTORY}/`))), + row({track, timesReferenced}, {strings, to}) { + return strings('listingPage.listTracks.byTimesReferenced.item', { + track: strings.link.track(track, {to}), + timesReferenced: strings.count.timesReferenced(timesReferenced, {unit: true}) + }); + } + }, - writePage([C.LISTING_DIRECTORY, 'random'], { - title: 'Random Pages', + { + directory: 'tracks/in-flashes/by-album', + title: ({strings}) => strings('listingPage.listTracks.inFlashes.byAlbum.title'), + condition: () => wikiInfo.features.flashesAndGames, - main: { - content: fixWS` - <h1>Random Pages</h1> - <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p> - <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p> - <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p> - <dl> - <dt>Miscellaneous:</dt> + data() { + return chunkByProperties(trackData.filter(t => t.flashes.length > 0), ['album']) + .filter(({ album }) => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + }, + + html(chunks, {strings, to}) { + return fixWS` + <dl> + ${chunks.map(({album, chunk: tracks}) => fixWS` + <dt>${strings('listingPage.listTracks.inFlashes.byAlbum.album', { + album: strings.link.album(album, {to}), + date: strings.count.date(album.date) + })}</dt> <dd><ul> - <li> - <a href="${C.JS_DISABLED_DIRECTORY}/" data-random="artist">Random Artist</a> - (<a href="${C.JS_DISABLED_DIRECTORY}/" data-random="artist-more-than-one-contrib">>1 contribution</a>) - </li> - <li><a href="${C.JS_DISABLED_DIRECTORY}/" data-random="album">Random Album (whole site)</a></li> - <li><a href="${C.JS_DISABLED_DIRECTORY}/" data-random="track">Random Track (whole site)</a></li> + ${(tracks + .map(track => strings('listingPage.listTracks.inFlashes.byAlbum.track', { + track: strings.link.track(track, {to}), + flashes: strings.list.and(track.flashes.map(flash => strings.link.flash(flash, {to}))) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </dd></ul> + `).join('\n')} + </dl> + `; + } + }, + + { + directory: 'tracks/in-flashes/by-flash', + title: ({strings}) => strings('listingPage.listTracks.inFlashes.byFlash.title'), + condition: () => wikiInfo.features.flashesAndGames, + + html({strings, to}) { + return fixWS` + <dl> + ${C.sortByDate(flashData.slice()).map(flash => fixWS` + <dt>${strings('listingPage.listTracks.inFlashes.byFlash.flash', { + flash: strings.link.flash(flash, {to}), + date: strings.count.date(flash.date) + })}</dt> + <dd><ul> + ${(flash.tracks + .map(track => strings('listingPage.listTracks.inFlashes.byFlash.track', { + track: strings.link.track(track, {to}), + album: strings.link.album(track.album, {to}) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} </ul></dd> - ${[ - {name: 'Official', albumData: officialAlbumData, code: 'official'}, - {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'} - ].map(category => fixWS` - <dt>${category.name}: (<a href="${C.JS_DISABLED_DIRECTORY}/" data-random="album-in-${category.code}">Random Album</a>, <a href="${C.JS_DISABLED_DIRECTORY}/" data-random="track-in-${category.code}">Random Track</a>)</dt> - <dd><ul>${category.albumData.map(album => fixWS` - <li><a style="${getLinkThemeString(album)}; --album-directory: ${album.directory}" href="${C.JS_DISABLED_DIRECTORY}/" data-random="track-in-album">${album.name}</a></li> - `).join('\n')}</ul></dd> - `).join('\n')} - </dl> - ` - }, + `).join('\n')} + </dl> + `; + } + }, - sidebarLeft: { - content: generateSidebarForListings(listingDescriptors, 'all-commentary') - }, + { + directory: 'tracks/with-lyrics', + title: ({strings}) => strings('listingPage.listTracks.withLyrics.title'), - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [`${C.LISTING_DIRECTORY}/random`, 'Random Pages'] - ] - } - }), + data() { + return chunkByProperties(trackData.filter(t => t.lyrics), ['album']); + }, - ...listingDescriptors.map(entry => writeListingPage(...entry, listingDescriptors)) - ]); + html(chunks, {strings, to}) { + return fixWS` + <dl> + ${chunks.map(({album, chunk: tracks}) => fixWS` + <dt>${strings('listingPage.listTracks.withLyrics.album', { + album: strings.link.album(album, {to}), + date: strings.count.date(album.date) + })}</dt> + <dd><ul> + ${(tracks + .map(track => strings('listingPage.listTracks.withLyrics.track', { + track: strings.link.track(track, {to}), + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </dd></ul> + `).join('\n')} + </dl> + `; + } + }, + + { + directory: 'tags/by-name', + title: ({strings}) => strings('listingPage.listTags.byName.title'), + condition: () => wikiInfo.features.artTagUI, + + data() { + return tagData + .filter(tag => !tag.isCW) + .sort(sortByName) + .map(tag => ({tag, timesUsed: tag.things.length})); + }, + + row({tag, timesUsed}, {strings, to}) { + return strings('listingPage.listTags.byName.item', { + tag: strings.link.tag(tag, {to}), + timesUsed: strings.count.timesUsed(timesUsed, {unit: true}) + }); + } + }, + + { + directory: 'tags/by-useds', + title: ({strings}) => strings('listingPage.listTags.byUses.title'), + condition: () => wikiInfo.features.artTagUI, + + data() { + return tagData + .filter(tag => !tag.isCW) + .map(tag => ({tag, timesUsed: tag.things.length})) + .sort((a, b) => b.timesUsed - a.timesUsed); + }, + + row({tag, timesUsed}, {strings, to}) { + return strings('listingPage.listTags.byUses.item', { + tag: strings.link.tag(tag, {to}), + timesUsed: strings.count.timesUsed(timesUsed, {unit: true}) + }); + } + }, + + { + directory: 'random', + title: ({strings}) => `Random Pages`, + html: ({strings, to}) => fixWS` + <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p> + <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p> + <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p> + <dl> + <dt>Miscellaneous:</dt> + <dd><ul> + <li> + <a href="#" data-random="artist">Random Artist</a> + (<a href="#" data-random="artist-more-than-one-contrib">>1 contribution</a>) + </li> + <li><a href="#" data-random="album">Random Album (whole site)</a></li> + <li><a href="#" data-random="track">Random Track (whole site)</a></li> + </ul></dd> + ${[ + {name: 'Official', albumData: officialAlbumData, code: 'official'}, + {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'} + ].map(category => fixWS` + <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt> + <dd><ul>${category.albumData.map(album => fixWS` + <li><a style="${getLinkThemeString(album)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li> + `).join('\n')}</ul></dd> + `).join('\n')} + </dl> + ` + } +]; + +function writeListingPages() { + if (!wikiInfo.features.listings) { + return; + } + + return [ + writeListingIndex(), + ...listingSpec.map(writeListingPage).filter(Boolean) + ]; +} + +function writeListingIndex() { + const releasedTracks = trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + const releasedAlbums = albumData.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); + const duration = getTotalDuration(releasedTracks); + + return ({strings, writePage}) => writePage('listingIndex', '', ({to}) => ({ + title: strings('listingIndex.title'), + + main: { + content: fixWS` + <h1>${strings('listingIndex.title')}</h1> + <p>${strings('listingIndex.infoLine', { + wiki: wikiInfo.name, + tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`, + albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`, + duration: `<b>${strings.count.duration(duration, {approximate: true, unit: true})}</b>` + })}</p> + <hr> + <p>${strings('listingIndex.exploreList')}</p> + ${generateLinkIndexForListings(null, {strings, to})} + ` + }, + + sidebarLeft: { + content: generateSidebarForListings(null, {strings, to}) + }, + + nav: { + links: [ + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: to.listingIndex(), + title: strings('listingIndex.title') + } + ] + } + })) } -function writeListingPage(directoryParts, title, items, listingDescriptors) { - return writePage([C.LISTING_DIRECTORY, ...directoryParts], { - title, +function writeListingPage(listing) { + if (listing.condition && !listing.condition()) { + return null; + } + + const data = (listing.data + ? listing.data() + : null); + + return ({strings, writePage}) => writePage('listing', listing.directory, ({to}) => ({ + title: listing.title({strings}), main: { content: fixWS` - <h1>${title}</h1> - ${typeof items === 'string' ? items : fixWS` + <h1>${listing.title({strings})}</h1> + ${listing.html && (listing.data + ? listing.html(data, {strings, to}) + : listing.html({strings, to}))} + ${listing.row && fixWS` <ul> - ${items.join('\n')} + ${(data + .map(item => listing.row(item, {strings, to})) + .map(row => `<li>${row}</li>`) + .join('\n'))} </ul> `} ` }, sidebarLeft: { - content: generateSidebarForListings(listingDescriptors, directoryParts) + content: generateSidebarForListings(listing, {strings, to}) }, nav: { links: [ - ['./', wikiInfo.shortName], - [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [`${C.LISTING_DIRECTORY}/${directoryParts.join('/')}/`, title] + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: to.listingIndex(), + title: strings('listingIndex.title') + }, + { + href: to.listing(listing.directory), + title: listing.title({strings}) + } ] } - }); + })); } -function generateSidebarForListings(listingDescriptors, currentDirectoryParts) { +function generateSidebarForListings(currentListing, {strings, to}) { return fixWS` - <h1><a href="${C.LISTING_DIRECTORY}/">Listings</a></h1> - ${generateLinkIndexForListings(listingDescriptors, currentDirectoryParts)} + <h1>${strings.link.listingIndex('', {text: strings('listingIndex.title'), to})}</h1> + ${generateLinkIndexForListings(currentListing, {strings, to})} `; } -function generateLinkIndexForListings(listingDescriptors, currentDirectoryParts) { +function generateLinkIndexForListings(currentListing, {strings, to}) { return fixWS` <ul> - ${listingDescriptors.map(([ ldDirectoryParts, ldTitle ]) => fixWS` - <li ${classes(currentDirectoryParts === ldDirectoryParts && 'current')}> - <a href="${C.LISTING_DIRECTORY}/${ldDirectoryParts.join('/')}/">${ldTitle}</a> - </li> - `).join('\n')} - <li ${classes(currentDirectoryParts === 'random' && 'current')}> - <a href="${C.LISTING_DIRECTORY}/random/">Random Pages</a> - </li> + ${(listingSpec + .filter(({ condition }) => !condition || condition()) + .map(listing => fixWS` + <li ${classes(listing === currentListing && 'current')}> + <a href="${to.listing(listing.directory)}">${listing.title({strings})}</a> + </li> + `) + .join('\n'))} </ul> `; } @@ -3198,83 +4366,93 @@ function filterAlbumsByCommentary() { return albumData.filter(album => [album, ...album.tracks].some(x => x.commentary)); } -function getWordCount(str) { - const wordCount = str.split(' ').length; - return `${Math.floor(wordCount / 100) / 10}k`; -} - function writeCommentaryPages() { if (!filterAlbumsByCommentary().length) { return; } - return progressPromiseAll('Writing commentary pages.', queue([ - writeCommentaryIndex, - ...filterAlbumsByCommentary().map(curry(writeAlbumCommentaryPage)) - ], queueSize)); + return [ + writeCommentaryIndex(), + ...filterAlbumsByCommentary().map(writeAlbumCommentaryPage) + ]; } -async function writeCommentaryIndex() { - await writePage([C.COMMENTARY_DIRECTORY], { - title: 'Commentary', +function writeCommentaryIndex() { + const data = filterAlbumsByCommentary() + .map(album => ({ + album, + entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary) + })) + .map(({ album, entries }) => ({ + album, entries, + words: entries.join(' ').split(' ').length + })); + + const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0); + const totalWords = data.reduce((acc, {words}) => acc + words, 0); + + return ({strings, writePage}) => writePage('commentaryIndex', '', ({to}) => ({ + title: strings('commentaryIndex.title'), main: { content: fixWS` <div class="long-content"> - <h1>Commentary</h1> - <p><strong>${getWordCount(albumData.reduce((acc, a) => acc + [a, ...a.tracks].filter(x => x.commentary).map(x => x.commentary).join(' ')))}</strong> words across <strong>${albumData.reduce((acc, a) => acc + [a, ...a.tracks].filter(x => x.commentary).length, 0)}</strong> entries, in all.</p> - <p>Choose an album:</p> + <h1>${strings('commentaryIndex.title')}</h1> + <p>${strings('commentaryIndex.infoLine', { + words: `<b>${strings.count.words(totalWords, {unit: true})}</b>`, + entries: `<b>${strings.count.commentaryEntries(totalEntries, {unit: true})}</b>` + })}</p> + <p>${strings('commentaryIndex.albumList.title')}</p> <ul> - ${filterAlbumsByCommentary() - .map(album => fixWS` - <li> - <a href="${C.COMMENTARY_DIRECTORY}/${C.ALBUM_DIRECTORY}/${album.directory}" style="${getLinkThemeString(album)}">${album.name}</a> - (${(() => { - const things = [album, ...album.tracks]; - const cThings = things.filter(x => x.commentary); - // const numStr = album.tracks.every(t => t.commentary) ? 'full commentary' : `${cThings.length} entries`; - const numStr = `${cThings.length}/${things.length} entries`; - return `${numStr}; ${getWordCount(cThings.map(x => x.commentary).join(' '))} words`; - })()}) - </li> + ${data + .map(({ album, entries, words }) => fixWS` + <li>${strings('commentaryIndex.albumList.item', { + album: strings.link.albumCommentary(album, {to}), + words: strings.count.words(words, {unit: true}), + entries: strings.count.commentaryEntries(entries.length, {unit: true}) + })}</li> `) - .join('\n') - } + .join('\n')} </ul> </div> ` }, - nav: { - links: [ - ['./', wikiInfo.shortName], - [`${C.COMMENTARY_DIRECTORY}/`, 'Commentary'] - ] - } - }); + nav: {simple: true} + })); } -async function writeAlbumCommentaryPage(album) { - await writePage([C.COMMENTARY_DIRECTORY, C.ALBUM_DIRECTORY, album.directory], { - title: `${album.name} - Commentary`, - stylesheet: getAlbumStylesheet(album), +function writeAlbumCommentaryPage(album) { + const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary); + const words = entries.join(' ').split(' ').length; + + return ({strings, writePage}) => writePage('albumCommentary', album.directory, ({to}) => ({ + title: strings('albumCommentaryPage.title', {album: album.name}), + stylesheet: getAlbumStylesheet(album, {to}), theme: getThemeString(album), main: { content: fixWS` <div class="long-content"> - <h1><a href="${C.ALBUM_DIRECTORY}/${album.directory}/">${album.name}</a> - Commentary</h2> - <p><strong>${getWordCount([album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary).join(' '))}</strong> words across <strong>${[album, ...album.tracks].filter(x => x.commentary).length}</strong> entries.</p> + <h1>${strings('albumCommentaryPage.title', { + album: strings.link.album(album, {to}) + })}</h1> + <p>${strings('albumCommentaryPage.infoLine', { + words: `<b>${strings.count.words(words, {unit: true})}</b>`, + entries: `<b>${strings.count.commentaryEntries(entries.length, {unit: true})}</b>` + })}</p> ${album.commentary && fixWS` - <h3>Album commentary</h3> + <h3>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3> <blockquote> - ${transformMultiline(album.commentary)} + ${transformMultiline(album.commentary, {strings, to})} </blockquote> `} ${album.tracks.filter(t => t.commentary).map(track => fixWS` - <h3 id="${track.directory}"><a href="${C.TRACK_DIRECTORY}/${track.directory}/" style="${getLinkThemeString(track)}">${track.name}</a></h3> + <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', { + track: strings.link.track(track, {to}) + })}</h3> <blockquote style="${getLinkThemeString(track)}"> - ${transformMultiline(track.commentary)} + ${transformMultiline(track.commentary, {strings, to})} </blockquote> `).join('\n')} </div> @@ -3283,13 +4461,22 @@ async function writeAlbumCommentaryPage(album) { nav: { links: [ - ['./', wikiInfo.shortName], - [`${C.COMMENTARY_DIRECTORY}/`, 'Commentary'], - [null, 'Album:'], - [`${C.COMMENTARY_DIRECTORY}/${C.ALBUM_DIRECTORY}/${album.directory}/`, album.name] + { + href: to.home(), + title: wikiInfo.shortName + }, + { + href: to.commentaryIndex(), + title: strings('commentaryIndex.title') + }, + { + html: strings('albumCommentaryPage.nav.album', { + album: strings.link.albumCommentary(album, {class: 'current', to}) + }) + } ] } - }); + })); } function writeTagPages() { @@ -3297,32 +4484,33 @@ function writeTagPages() { return; } - return progressPromiseAll(`Writing tag pages.`, queue(tagData - .filter(tag => !tag.isCW) - .map(curry(writeTagPage)), queueSize)); + return tagData.filter(tag => !tag.isCW).map(writeTagPage); } function writeTagPage(tag) { const { things } = tag; - return writePage([C.TAG_DIRECTORY, tag.directory], { - title: tag.name, + return ({strings, writePage}) => writePage('tag', tag.directory, ({to}) => ({ + title: strings('tagPage.title', {tag: tag.name}), theme: getThemeString(tag), main: { classes: ['top-index'], content: fixWS` - <h1>${tag.name}</h1> - <p class="quick-info">(Appears in ${s(things.length, 'cover art')})</p> + <h1>${strings('tagPage.title', {tag: tag.name})}</h1> + <p class="quick-info">${strings('tagPage.infoLine', { + coverArts: strings.count.coverArts(things.length, {unit: true}) + })}</p> <div class="grid-listing"> ${getGridHTML({ + strings, to, entries: things.map(item => ({item})), srcFn: thing => (thing.album - ? getTrackCover(thing) - : getAlbumCover(thing)), + ? getTrackCover(thing, {to}) + : getAlbumCover(thing, {to})), hrefFn: thing => (thing.album - ? `${C.TRACK_DIRECTORY}/${thing.directory}/` - : `${C.ALBUM_DIRECTORY}/${thing.directory}`) + ? to.track(thing.directory) + : to.album(thing.album)) })} </div> ` @@ -3330,152 +4518,33 @@ function writeTagPage(tag) { nav: { links: [ - ['./', wikiInfo.shortName], - wikiInfo.features.listings && [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [null, 'Tag:'], - [`${C.TAG_DIRECTORY}/${tag.directory}/`, tag.name] + { + href: to.home(), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to.listingIndex(), + title: strings('listingIndex.title') + }, + { + html: strings('tagPage.nav.tag', { + tag: strings.link.tag(tag, {class: 'current', to}) + }) + } ] } - }); -} - -// This function is terri8le. Sorry! -function getContributionString({ what }) { - return what - ? what.replace(/\[(.*?)\]/g, (match, name) => - trackData.some(track => track.name === name) - ? `<i><a href="${C.TRACK_DIRECTORY}/${trackData.find(track => track.name === name).directory}/">${name}</a></i>` - : `<i>${name}</i>`) - : ''; -} - -function getLinkedTrack(ref) { - if (!ref) return null; - - if (ref.includes('track:')) { - ref = ref.replace('track:', ''); - return trackData.find(track => track.directory === ref); - } - - const match = ref.match(/\S:(.*)/); - if (match) { - const dir = match[1]; - return trackData.find(track => track.directory === dir); - } - - let track; - - track = trackData.find(track => track.directory === ref); - if (track) { - return track; - } - - track = trackData.find(track => track.name === ref); - if (track) { - return track; - } - - track = trackData.find(track => track.name.toLowerCase() === ref.toLowerCase()); - if (track) { - console.warn(`\x1b[33mBad capitalization:\x1b[0m`); - console.warn(`\x1b[31m- ${ref}\x1b[0m`); - console.warn(`\x1b[32m+ ${track.name}\x1b[0m`); - return track; - } - - return null; -} - -function getLinkedAlbum(ref) { - if (!ref) return null; - ref = ref.replace('album:', ''); - let album; - album = albumData.find(album => album.directory === ref); - if (!album) album = albumData.find(album => album.name === ref); - if (!album) { - album = albumData.find(album => album.name.toLowerCase() === ref.toLowerCase()); - if (album) { - console.warn(`\x1b[33mBad capitalization:\x1b[0m`); - console.warn(`\x1b[31m- ${ref}\x1b[0m`); - console.warn(`\x1b[32m+ ${album.name}\x1b[0m`); - return album; - } - } - return album; -} - -function getLinkedGroup(ref) { - if (!ref) return null; - ref = ref.replace('group:', ''); - let group; - group = groupData.find(group => group.directory === ref); - if (!group) group = groupData.find(group => group.name === ref); - if (!group) { - group = groupData.find(group => group.name.toLowerCase() === ref.toLowerCase()); - if (group) { - console.warn(`\x1b[33mBad capitalization:\x1b[0m`); - console.warn(`\x1b[31m- ${ref}\x1b[0m`); - console.warn(`\x1b[32m+ ${group.name}\x1b[0m`); - return group; - } - } - return group; -} - -function getLinkedArtist(ref) { - if (!ref) return null; - ref = ref.replace('artist:', ''); - - let artist = artistData.find(artist => C.getArtistDirectory(artist.name) === ref); - if (artist) { - return artist; - } - - artist = artistData.find(artist => artist.name === ref); - if (artist) { - return artist; - } - - return null; -} - -function getLinkedFlash(ref) { - if (!ref) return null; - ref = ref.replace('flash:', ''); - return flashData?.find(flash => flash.directory === ref); -} - -function getLinkedTag(ref) { - if (!ref) return null; - - ref = ref.replace('tag:', ''); - - let tag = tagData.find(tag => tag.directory === ref); - if (tag) { - return tag; - } - - if (ref.startsWith('cw: ')) { - ref = ref.slice(4); - } - - tag = tagData.find(tag => tag.name === ref); - if (tag) { - return tag; - } - - return null; + })); } -function getArtistString(artists, showIcons = false) { - return joinNoOxford(artists.map(({ who, what }) => { - if (!who) console.log(artists); +function getArtistString(artists, {strings, to, showIcons = false, showContrib = false}) { + return strings.list.and(artists.map(({ who, what }) => { const { urls, directory, name } = who; - return ( - `<a href="${C.ARTIST_DIRECTORY}/${directory}/">${name}</a>` + - (what ? ` (${getContributionString({what})})` : '') + - (showIcons && urls.length ? ` <span class="icons">(${urls.map(iconifyURL).join(', ')})</span>` : '') - ); + return [ + strings.link.artist(who, {to}), + showContrib && what && `(${what})`, + showIcons && urls.length && `<span class="icons">(${urls.map(iconifyURL).join(', ')})</span>` + ].join(' '); })); } @@ -3543,37 +4612,43 @@ function getAlbumListTag(album) { } } -function fancifyURL(url, {album = false} = {}) { +function fancifyURL(url, {strings, album = false} = {}) { + const domain = new URL(url).hostname; return fixWS`<a href="${url}" class="nowrap">${ - url.includes('bandcamp.com') ? 'Bandcamp' : - ( - url.includes('music.solatrus.com') - ) ? `Bandcamp (${new URL(url).hostname})` : - ( - url.includes('types.pl') - ) ? `Mastodon (${new URL(url).hostname})` : - url.includes('youtu') ? (album ? ( - url.includes('list=') ? 'YouTube (Playlist)' : 'YouTube (Full Album)' - ) : 'YouTube') : - url.includes('soundcloud') ? 'SoundCloud' : - url.includes('tumblr.com') ? 'Tumblr' : - url.includes('twitter.com') ? 'Twitter' : - url.includes('deviantart.com') ? 'DeviantArt' : - url.includes('wikipedia.org') ? 'Wikipedia' : - url.includes('poetryfoundation.org') ? 'Poetry Foundation' : - url.includes('instagram.com') ? 'Instagram' : - url.includes('patreon.com') ? 'Patreon' : - new URL(url).hostname + domain.includes('bandcamp.com') ? strings('misc.external.bandcamp') : + [ + 'music.solatrux.com' + ].includes(domain) ? strings('misc.external.bandcamp.domain', {domain}) : + [ + 'types.pl' + ].includes(domain) ? strings('misc.external.mastodon.domain', {domain}) : + domain.includes('youtu') ? (album + ? (url.includes('list=') + ? strings('misc.external.youtube.playlist') + : strings('misc.external.youtube.fullAlbum')) + : strings('misc.external.youtube')) : + domain.includes('soundcloud') ? strings('misc.external.soundcloud') : + domain.includes('tumblr.com') ? strings('misc.external.tumblr') : + domain.includes('twitter.com') ? strings('misc.external.twitter') : + domain.includes('deviantart.com') ? strings('misc.external.deviantart') : + domain.includes('wikipedia.org') ? strings('misc.external.wikipedia') : + domain.includes('poetryfoundation.org') ? strings('misc.external.poetryFoundation') : + domain.includes('instagram.com') ? strings('misc.external.instagram') : + domain.includes('patreon.com') ? strings('misc.external.patreon') : + domain }</a>`; } -function fancifyFlashURL(url, flash) { - return `<span class="nowrap">${fancifyURL(url)}` + ( - url.includes('homestuck.com') ? ` (${isNaN(Number(flash.page)) ? 'secret page' : `page ${flash.page}`})` : - url.includes('bgreco.net') ? ` (HQ audio)` : - url.includes('youtu') ? ` (on any device)` : - '' - ) + `</span>`; +function fancifyFlashURL(url, flash, {strings}) { + const link = fancifyURL(url, {strings}); + return `<span class="nowrap">${ + url.includes('homestuck.com') ? (isNaN(Number(flash.page)) + ? strings('misc.external.flash.homestuck.secret', {link}) + : strings('misc.external.flash.homestuck.page', {link, page: flash.page})) : + url.includes('bgreco.net') ? strings('misc.external.flash.bgreco', {link}) : + url.includes('youtu') ? strings('misc.external.flash.youtube', {link}) : + link + }</span>`; } function iconifyURL(url) { @@ -3593,136 +4668,132 @@ function iconifyURL(url) { url.includes('instagram.com') ? ['instagram', 'Instagram'] : ['globe', `External (${new URL(url).hostname})`] ); - return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${C.STATIC_DIRECTORY}/icons.svg#icon-${id}"></use></svg></a>`; + return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="/${C.STATIC_DIRECTORY}/icons.svg#icon-${id}"></use></svg></a>`; } -function chronologyLinks(currentTrack, { - mapProperty, - toArtist, - filters, // {property, toArtist} +function chronologyLinks(currentThing, { + strings, to, headingWord, - sourceData = justEverythingMan + contribKey, + getThings }) { - const artists = Array.from(new Set(filters.flatMap(({ mapProperty, toArtist }) => currentTrack[mapProperty] && currentTrack[mapProperty].map(toArtist)))); - if (artists.length > 8) { - return `<div class="chronology">(See artist pages for chronology info!)</div>`; + const contributions = currentThing[contribKey]; + if (!contributions) { + return ''; } - return artists.map(artist => { - const releasedThings = sourceData.filter(thing => { - const album = albumData.includes(thing) ? thing : thing.album; - if (album && album.directory === C.UNRELEASED_TRACKS_DIRECTORY) { - return false; - } - return filters.some(({ mapProperty, toArtist }) => ( - thing[mapProperty] && - thing[mapProperty].map(toArtist).includes(artist) - )); + if (contributions.length > 8) { + return `<div class="chronology">${strings('misc.chronology.seeArtistPages')}</div>`; + } + + return contributions.map(({ who: artist }) => { + const things = C.sortByDate(unique(getThings(artist))); + const releasedThings = things.filter(thing => { + const album = albumData.includes(thing) ? thing : thing.album; + return !(album && album.directory === C.UNRELEASED_TRACKS_DIRECTORY); }); - const index = releasedThings.indexOf(currentTrack); + const index = releasedThings.indexOf(currentThing); if (index === -1) return ''; + // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks? + // We'd need to make generatePreviousNextLinks use toAnythingMan tho. const previous = releasedThings[index - 1]; const next = releasedThings[index + 1]; const parts = [ - previous && `<a href="${getHrefOfAnythingMan(previous)}" title="${previous.name}">Previous</a>`, - next && `<a href="${getHrefOfAnythingMan(next)}" title="${next.name}">Next</a>` + previous && `<a href="${toAnythingMan(previous, to)}" title="${previous.name}">Previous</a>`, + next && `<a href="${toAnythingMan(next, to)}" title="${next.name}">Next</a>` ].filter(Boolean); - const heading = `${th(index + 1)} ${headingWord} by <a href="${C.ARTIST_DIRECTORY}/${artist.directory}/">${artist.name}</a>`; - return fixWS` <div class="chronology"> - <span class="heading">${heading}</span> + <span class="heading">${strings('misc.chronology.heading', { + index: strings.count.index(index + 1), + word: headingWord, + artist: strings.link.artist(artist, {to}) + })}</span> ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`} </div> `; }).filter(Boolean).join('\n'); } -function generateAlbumNavLinks(album, currentTrack = null) { +function generateAlbumNavLinks(album, currentTrack, {strings, to}) { if (album.tracks.length <= 1) { return ''; } - const index = currentTrack && album.tracks.indexOf(currentTrack) - const previous = currentTrack && album.tracks[index - 1] - const next = currentTrack && album.tracks[index + 1] - - const [ previousLine, nextLine, randomLine ] = [ - previous && `<a href="${C.TRACK_DIRECTORY}/${previous.directory}/" id="previous-button" title="${previous.name}">Previous</a>`, - next && `<a href="${C.TRACK_DIRECTORY}/${next.directory}/" id="next-button" title="${next.name}">Next</a>`, - `<a href="${C.JS_DISABLED_DIRECTORY}/" data-random="track-in-album" id="random-button">${currentTrack ? 'Random' : 'Random Track'}</a>` - ]; + const previousNextLinks = currentTrack && generatePreviousNextLinks('track', currentTrack, album.tracks, {strings, to}) + const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${ + (currentTrack + ? strings('trackPage.nav.random') + : strings('albumPage.nav.randomTrack')) + }</a>`; - if (previousLine || nextLine) { - return `(${[previousLine, nextLine].filter(Boolean).join(', ')}<span class="js-hide-until-data">, ${randomLine}</span>)`; - } else { - return `<span class="js-hide-until-data">(${randomLine})</span>`; - } + return (previousNextLinks + ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)` + : `<span class="js-hide-until-data">(${randomLink})</span>`); } -function generateAlbumChronologyLinks(album, currentTrack = null) { +function generateAlbumChronologyLinks(album, currentTrack, {strings, to}) { return [ currentTrack && chronologyLinks(currentTrack, { - headingWord: 'track', - sourceData: trackData, - filters: [ - { - mapProperty: 'artists', - toArtist: ({ who }) => who - }, - { - mapProperty: 'contributors', - toArtist: ({ who }) => who - } - ] + strings, to, + headingWord: strings('misc.chronology.track'), + contribKey: 'artists', + getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor] }), chronologyLinks(currentTrack || album, { - headingWord: 'cover art', - sourceData: justEverythingSortedByArtDateMan, - filters: [ - { - mapProperty: 'coverArtists', - toArtist: ({ who }) => who - } - ] + strings, to, + headingWord: strings('misc.chronology.coverArt'), + contribKey: 'coverArtists', + getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist] }) ].filter(Boolean).join('\n'); } -function generateSidebarForAlbum(album, currentTrack = null) { - const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}><a href="${C.TRACK_DIRECTORY}/${track.directory}/">${track.name}</a></li>`; +function generateSidebarForAlbum(album, currentTrack, {strings, to}) { const listTag = getAlbumListTag(album); - return {content: fixWS` - <h1><a href="${C.ALBUM_DIRECTORY}/${album.directory}/">${album.name}</a></h1> - ${album.usesGroups ? fixWS` - <dl> - ${album.tracks.flatMap((track, i, arr) => [ - (i > 0 && track.group !== arr[i - 1].group) && `</${listTag}></dd>`, - (i === 0 || track.group !== arr[i - 1].group) && fixWS` - ${track.group && fixWS` - <dt style="${getLinkThemeString(track)}" ${classes(currentTrack && track.group === currentTrack.group && 'current')}> - <a href="${C.TRACK_DIRECTORY}/${track.directory}/">${track.group}</a> - ${listTag === 'ol' ? `(${i + 1}–${arr.length - arr.slice().reverse().findIndex(t => t.group === track.group)})` : `<!-- (here: track number range) -->`} - </dt> + + const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}>${ + strings('albumSidebar.trackList.item', { + track: `<a href="${to.track(track.directory)}">${track.name}</a>` + }) + }</li>`; + + return { + content: fixWS` + <h1><a href="${to.album(album.directory)}">${album.name}</a></h1> + ${album.trackGroups ? fixWS` + <dl> + ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS` + <dt ${classes(tracks.includes(currentTrack) && 'current')}>${ + (listTag === 'ol' + ? strings('albumSidebar.trackList.group.withRange', { + group: `<a href="${to.track(tracks[0].directory)}">${name}</a>`, + range: `${startIndex + 1}–${startIndex + tracks.length}` + }) + : strings('albumSidebar.trackList.group', { + group: `<a href="${to.track(tracks[0].directory)}">${name}</a>` + })) + }</dt> + ${(!currentTrack || tracks.includes(currentTrack)) && fixWS` + <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}> + ${tracks.map(trackToListItem).join('\n')} + </${listTag}></dd> `} - <dd style="${getLinkThemeString(track)}"><${listTag === 'ol' ? `ol start="${i + 1}"` : listTag}> - `, - (!currentTrack || track.group === currentTrack.group) && trackToListItem(track), - i === arr.length && `</${listTag}></dd>` - ].filter(Boolean)).join('\n')} - </dl> - ` : fixWS` - <${listTag}> - ${album.tracks.map(trackToListItem).join('\n')} - </${listTag}> - `} - `}; + `).join('\n')} + </dl> + ` : fixWS` + <${listTag}> + ${album.tracks.map(trackToListItem).join('\n')} + </${listTag}> + `} + ` + }; } -function generateSidebarRightForAlbum(album, currentTrack = null) { +function generateSidebarRightForAlbum(album, currentTrack, {strings, to}) { if (!wikiInfo.features.groupUI) { return null; } @@ -3737,37 +4808,59 @@ function generateSidebarRightForAlbum(album, currentTrack = null) { const previous = group.albums[index - 1]; return {group, next, previous}; }).map(({group, next, previous}) => fixWS` - <h1><a href="${C.GROUP_DIRECTORY}/${group.directory}/">${group.name}</a></h1> - ${!currentTrack && transformMultiline(group.descriptionShort)} - ${group.urls.length && `<p>Visit on ${joinNoOxford(group.urls.map(fancifyURL), 'or')}.</p>`} + <h1>${ + strings('albumSidebar.groupBox.title', { + group: `<a href="${to.groupInfo(group.directory)}">${group.name}</a>` + }) + }</h1> + ${!currentTrack && transformMultiline(group.descriptionShort, {strings, to})} + ${group.urls.length && `<p>${ + strings('releaseInfo.visitOn', { + links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings}))) + }) + }</p>`} ${!currentTrack && fixWS` - ${next && `<p class="group-chronology-link">Next: <a href="${C.ALBUM_DIRECTORY}/${next.directory}/" style="${getLinkThemeString(next)}">${next.name}</a></p>`} - ${previous && `<p class="group-chronology-link">Previous: <a href="${C.ALBUM_DIRECTORY}/${previous.directory}/" style="${getLinkThemeString(previous)}">${previous.name}</a></p>`} + ${next && `<p class="group-chronology-link">${ + strings('albumSidebar.groupBox.next', { + album: `<a href="${to.album(next.directory)}" style="${getLinkThemeString(next)}">${next.name}</a>` + }) + }</p>`} + ${previous && `<p class="group-chronology-link">${ + strings('albumSidebar.groupBox.previous', { + album: `<a href="${to.album(previous.directory)}" style="${getLinkThemeString(previous)}">${previous.name}</a>` + }) + }</p>`} `} `) }; }; } -function generateSidebarForGroup(isGallery = false, currentGroup = null) { +function generateSidebarForGroup(currentGroup, {strings, to, isGallery}) { if (!wikiInfo.features.groupUI) { return null; } + const toGroup = isGallery ? to.groupGallery : to.groupInfo; + return { content: fixWS` - <h1>Groups</h1> + <h1>${strings('groupSidebar.title')}</h1> <dl> - ${groupData.filter(x => x.isCategory).map(category => [ + ${groupCategoryData.map(category => [ fixWS` - <dt ${classes(currentGroup && category === currentGroup.category && 'current')}> - <a href="${C.GROUP_DIRECTORY}/${groupData.find(x => x.isGroup && x.category === category).directory}/${isGallery ? 'gallery/' : ''}" style="${getLinkThemeString(category)}">${category.name}</a> - </dt> + <dt ${classes(category === currentGroup.category && 'current')}>${ + strings('groupSidebar.groupList.category', { + category: `<a href="${toGroup(category.groups[0].directory)}" style="${getLinkThemeString(category)}">${category.name}</a>` + }) + }</dt> <dd><ul> ${category.groups.map(group => fixWS` - <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group)}"> - <a href="${C.GROUP_DIRECTORY}/${group.directory}/${isGallery && 'gallery/'}">${group.name}</a> - </li> + <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group)}">${ + strings('groupSidebar.groupList.item', { + group: `<a href="${toGroup(group.directory)}">${group.name}</a>` + }) + }</li> `).join('\n')} </ul></dd> ` @@ -3777,173 +4870,223 @@ function generateSidebarForGroup(isGallery = false, currentGroup = null) { }; } +function generateInfoGalleryLinks(urlKeyInfo, urlKeyGallery, currentThing, isGallery, {strings, to}) { + return [ + strings.link[urlKeyInfo](currentThing, { + to, + class: isGallery ? '' : 'current', + text: strings('misc.nav.info') + }), + strings.link[urlKeyGallery](currentThing, { + to, + class: isGallery ? 'current' : '', + text: strings('misc.nav.gallery') + }) + ].join(', '); +} + +function generatePreviousNextLinks(urlKey, currentThing, thingData, {strings, to}) { + const toThing = to[urlKey]; + + const index = thingData.indexOf(currentThing); + const previous = thingData[index - 1]; + const next = thingData[index + 1]; + + return [ + previous && `<a href="${toThing(previous.directory)}" id="previous-button" title="${previous.name}">${strings('misc.nav.previous')}</a>`, + next && `<a href="${toThing(next.directory)}" id="next-button" title="${next.name}">${strings('misc.nav.next')}</a>` + ].filter(Boolean).join(', '); +} + +function generateNavForGroup(currentGroup, {strings, to, isGallery}) { + if (!wikiInfo.features.groupUI) { + return {simple: true}; + } + + const urlKey = isGallery ? 'groupGallery' : 'groupInfo'; + const infoGalleryLinks = generateInfoGalleryLinks('groupInfo', 'groupGallery', currentGroup, isGallery, {strings, to}); + const previousNextLinks = generatePreviousNextLinks(urlKey, currentGroup, groupData, {strings, to}) + + return { + links: [ + { + href: to.home(), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to.listingIndex(), + title: strings('listingIndex.title') + }, + { + html: strings('groupPage.nav.group', { + group: strings.link[urlKey](currentGroup, {class: 'current', to}) + }) + }, + { + divider: false, + html: (previousNextLinks + ? `(${infoGalleryLinks}; ${previousNextLinks})` + : `(${previousNextLinks})`) + } + ] + }; +} + function writeGroupPages() { - return progressPromiseAll(`Writing group pages.`, queue(groupData.filter(x => x.isGroup).map(curry(writeGroupPage)), queueSize)); + return groupData.map(writeGroupPage); } -async function writeGroupPage(group) { +function writeGroupPage(group) { const releasedAlbums = group.albums.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY); const releasedTracks = releasedAlbums.flatMap(album => album.tracks); const totalDuration = getTotalDuration(releasedTracks); - const groups = groupData.filter(x => x.isGroup); - const index = groups.indexOf(group); - const previous = groups[index - 1]; - const next = groups[index + 1]; + return async ({strings, writePage}) => { + await writePage('groupInfo', group.directory, ({to}) => ({ + title: strings('groupInfoPage.title', {group: group.name}), + theme: getThemeString(group), - const generateNextPrevious = isGallery => [ - previous && `<a href="${C.GROUP_DIRECTORY}/${previous.directory}/${isGallery ? 'gallery/' : ''}" id="previous-button" title="${previous.name}">Previous</a>`, - next && `<a href="${C.GROUP_DIRECTORY}/${next.directory}/${isGallery ? 'gallery/' : ''}" id="next-button" title="${next.name}">Next</a>` - ].filter(Boolean).join(', '); + main: { + content: fixWS` + <h1>${strings('groupInfoPage.title', {group: group.name})}</h1> + ${group.urls.length && `<p>${ + strings('releaseInfo.visitOn', { + links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings}))) + }) + }</p>`} + <blockquote> + ${transformMultiline(group.description, {strings, to})} + </blockquote> + <h2>${strings('groupInfoPage.albumList.title')}</h2> + <p>${ + strings('groupInfoPage.viewAlbumGallery', { + link: `<a href="${to.groupGallery(group.directory)}">${ + strings('groupInfoPage.viewAlbumGallery.link') + }</a>` + }) + }</p> + <ul> + ${group.albums.map(album => fixWS` + <li>${ + strings('groupInfoPage.albumList.item', { + year: album.date.getFullYear(), + album: `<a href="${to.album(album.directory)}" style="${getLinkThemeString(album)}">${album.name}</a>` + }) + }</li> + `).join('\n')} + </ul> + ` + }, - const npInfo = generateNextPrevious(false); - const npGallery = generateNextPrevious(true); + sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: false}), + nav: generateNavForGroup(group, {strings, to, isGallery: false}) + })); - await writePage([C.GROUP_DIRECTORY, group.directory], { - title: group.name, - theme: getThemeString(group), - main: { - content: fixWS` - <h1>${group.name}</h1> - ${group.urls.length && `<p>Visit on ${joinNoOxford(group.urls.map(fancifyURL), 'or')}.</p>`} - <blockquote> - ${transformMultiline(group.description)} - </blockquote> - <h2>Albums</h2> - <p>View <a href="${C.GROUP_DIRECTORY}/${group.directory}/gallery/">album gallery</a>! Or browse the list:</p> - <ul> - ${group.albums.map(album => fixWS` - <li> - (${album.date.getFullYear()}) - <a href="${C.ALBUM_DIRECTORY}/${album.directory}/" style="${getLinkThemeString(album)}">${album.name}</a> - </li> - `).join('\n')} - </ul> - ` - }, - sidebarLeft: generateSidebarForGroup(false, group), - nav: (wikiInfo.features.groupUI ? { - links: [ - ['./', wikiInfo.shortName], - wikiInfo.features.listings && [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [null, 'Group:'], - [`${C.GROUP_DIRECTORY}/${group.directory}/`, group.name], - [null, `(${[ - `<a href="${C.GROUP_DIRECTORY}/${group.directory}/" class="current">Info</a>`, - `<a href="${C.GROUP_DIRECTORY}/${group.directory}/gallery/">Gallery</a>` - ].join(', ') + (npInfo.length ? '; ' + npInfo : '')})`] - ] - } : {simple: true}) - }); + await writePage('groupGallery', group.directory, ({to}) => ({ + title: strings('groupGalleryPage.title', {group: group.name}), + theme: getThemeString(group), - await writePage([C.GROUP_DIRECTORY, group.directory, 'gallery'], { - title: `${group.name} - Gallery`, - theme: getThemeString(group), - main: { - classes: ['top-index'], - content: fixWS` - <h1>${group.name} - Gallery</h1> - <p class="quick-info"><b>${releasedTracks.length}</b> track${releasedTracks.length === 1 ? '' : 's'} across <b>${releasedAlbums.length}</b> album${releasedAlbums.length === 1 ? '' : 's'}, totaling <b>~${getDurationString(totalDuration)}</b> ${totalDuration > 3600 ? 'hours' : 'minutes'}.</p> - ${wikiInfo.features.groupUI && wikiInfo.features.listings && `<p class="quick-info">(<a href="${C.LISTING_DIRECTORY}/groups/by-category/">Choose another group to filter by!</a>)</p>`} - <div class="grid-listing"> - ${getGridHTML({ - entries: C.sortByDate(group.albums.map(item => ({item}))).reverse(), - srcFn: getAlbumCover, - hrefFn: album => `${C.ALBUM_DIRECTORY}/${album.directory}/`, - details: true - })} - </div> - ` - }, - sidebarLeft: generateSidebarForGroup(true, group), - nav: (wikiInfo.features.groupUI ? { - links: [ - ['./', wikiInfo.shortName], - wikiInfo.features.listings && [`${C.LISTING_DIRECTORY}/`, 'Listings'], - [null, 'Group:'], - [`${C.GROUP_DIRECTORY}/${group.directory}/`, group.name], - [null, `(${[ - `<a href="${C.GROUP_DIRECTORY}/${group.directory}/">Info</a>`, - `<a href="${C.GROUP_DIRECTORY}/${group.directory}/gallery/" class="current">Gallery</a>` - ].join(', ') + (npGallery.length ? '; ' + npGallery : '')})`] - ] - } : {simple: true}) - }); + main: { + classes: ['top-index'], + content: fixWS` + <h1>${strings('groupGalleryPage.title', {group: group.name})}</h1> + <p class="quick-info">${ + strings('groupGalleryPage.infoLine', { + tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`, + albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`, + time: `<b>${strings.count.duration(totalDuration, {unit: true})}</b>` + }) + }</p> + ${wikiInfo.features.groupUI && wikiInfo.features.listings && `<p class="quick-info">(<a href="${to.listing('groups/by-category')}">Choose another group to filter by!</a>)</p>`} + <div class="grid-listing"> + ${getAlbumGridHTML({ + strings, to, + entries: C.sortByDate(group.albums.map(item => ({item}))).reverse(), + details: true + })} + </div> + ` + }, + + sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: true}), + nav: generateNavForGroup(group, {strings, to, isGallery: true}) + })); + }; } -function getHrefOfAnythingMan(anythingMan) { +function toAnythingMan(anythingMan, to) { return ( - albumData.includes(anythingMan) ? C.ALBUM_DIRECTORY : - trackData.includes(anythingMan) ? C.TRACK_DIRECTORY : - flashData?.includes(anythingMan) ? C.FLASH_DIRECTORY : + albumData.includes(anythingMan) ? to.album(anythingMan.directory) : + trackData.includes(anythingMan) ? to.track(anythingMan.directory) : + flashData?.includes(anythingMan) ? to.flash(anythingMan.directory) : 'idk-bud' - ) + '/' + ( - anythingMan.directory - ) + '/'; + ) } -function getAlbumCover(album) { - const file = 'cover.jpg'; - return `${C.MEDIA_DIRECTORY}/${C.MEDIA_ALBUM_ART_DIRECTORY}/${album.directory}/${file}`; +function getAlbumCover(album, {to}) { + return to.albumCover(album.directory); } -function getTrackCover(track) { + +function getTrackCover(track, {to}) { // Some al8ums don't have any track art at all, and in those, every track // just inherits the al8um's own cover art. if (track.coverArtists === null) { - return getAlbumCover(track.album); + return getAlbumCover(track.album, {to}); } else { - const file = `${track.directory}.jpg`; - return `${C.MEDIA_DIRECTORY}/${C.MEDIA_ALBUM_ART_DIRECTORY}/${track.album.directory}/${file}`; + return to.trackCover(track.album.directory, track.directory); } } -function getFlashCover(flash) { - const file = `${getFlashDirectory(flash)}.${flash.jiff === 'Yeah' ? 'gif' : 'jpg'}`; - return `${C.MEDIA_DIRECTORY}/${C.MEDIA_FLASH_ART_DIRECTORY}/${file}`; -} function getFlashLink(flash) { return `https://homestuck.com/story/${flash.page}`; } -function getFlashLinkHTML(flash, name = null) { - if (!name) { - name = flash.name; - } - return `<a href="${C.FLASH_DIRECTORY}/${getFlashDirectory(flash)}/" title="Page ${flash.page}" style="${getLinkThemeString(flash)}">${name}</a>`; +function classes(...args) { + const values = args.filter(Boolean); + return `class="${values.join(' ')}"`; } -function rebaseURLs(directory, html) { - if (directory === '') { - return html; +async function processLanguageFile(file, defaultStrings = null) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; } - return html.replace(/(href|src|data-original)="(.*?)"/g, (match, attr, url) => { - if (url.startsWith('#')) { - return `${attr}="${url}"`; - } - try { - new URL(url); - // no error: it's a full url - } catch (error) { - // caught an error: it's a component! - url = path.relative(directory, path.join(outputPath, url)); - } - return `${attr}="${url}"`; - }).replace(/url\("(.*?)"\)/g, (match, url) => { - // same as above but for CSS url("...")-style values! - try { - new URL(url); - } catch (error) { - url = path.relative(directory, path.join(outputPath, url)); - } - return `url("${url}")`; - }); + let json; + try { + json = JSON.parse(contents); + } catch (error) { + return {error: `Could not parse JSON from ${file} (${error}).`}; + } + + return genStrings(json); } -function classes(...args) { - const values = args.filter(Boolean); - // return values.length ? ` class="${values.join(' ')}"` : ''; - return `class="${values.join(' ')}"`; +// Wrapper function for running a function once for all languages. It provides: +// * the language strings +// * a shadowing writePages function for outputing to the appropriate subdir +// * a shadowing urls object for linking to the appropriate relative paths +async function wrapLanguages(fn) { + for (const key of Object.keys(languages)) { + if (key === 'default') continue; + + const strings = languages[key]; + const baseDirectory = (strings === languages.default ? '' : strings.code); + + const shadow_writePage = (urlKey, directory, pageFn) => writePage(strings, baseDirectory, urlKey, directory, pageFn); + + // 8ring the utility functions over too! + Object.assign(shadow_writePage, writePage); + + await fn({ + baseDirectory, + strings, + writePage: shadow_writePage + }); + } } async function main() { @@ -3962,6 +5105,20 @@ async function main() { type: 'value' }, + // String files! For the most part, this is used for translating the + // site to different languages, though you can also customize strings + // for your own 8uild of the site if you'd like. Files here should all + // match the format in strings-default.json in this repository. (If a + // language file is missing any strings, the site code will fall 8ack + // to what's specified in strings-default.json.) + // + // Unlike the other options here, this one's optional - the site will + // 8uild with the default (English) strings if this path is left + // unspecified. + 'lang': { + type: 'value' + }, + // This is the output directory. It's the one you'll upload online with // rsync or whatever when you're pushing an upd8, and also the one // you'd archive if you wanted to make a 8ackup of the whole dang @@ -3987,6 +5144,7 @@ async function main() { dataPath = miscOptions.data || process.env.HSMUSIC_DATA; mediaPath = miscOptions.media || process.env.HSMUSIC_MEDIA; + langPath = miscOptions.lang || process.env.HSMUSIC_LANG; // Can 8e left unset! outputPath = miscOptions.out || process.env.HSMUSIC_OUT; { @@ -4005,12 +5163,60 @@ async function main() { } } + const defaultStrings = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE)); + if (defaultStrings.error) { + logError`Error loading default strings: ${defaultStrings.error}`; + return; + } + + if (langPath) { + const languageDataFiles = await findFiles(langPath); + const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles + .map(file => processLanguageFile(file, defaultStrings.json))); + + let error = false; + for (const strings of results) { + if (strings.error) { + logError`Error loading provided strings: ${strings.error}`; + error = true; + } + } + if (error) return; + + languages = Object.fromEntries(results.map(strings => [strings.code, strings])); + } else { + languages = {}; + } + + if (!languages[defaultStrings.code]) { + languages[defaultStrings.code] = defaultStrings; + } + wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE)); if (wikiInfo.error) { console.log(`\x1b[31;1m${wikiInfo.error}\x1b[0m`); return; } + // Update languages o8ject with the wiki-specified default language! + // This will make page files for that language 8e gener8ted at the root + // directory, instead of the language-specific su8directory. + if (wikiInfo.defaultLanguage) { + if (Object.keys(languages).includes(wikiInfo.defaultLanguage)) { + languages.default = languages[wikiInfo.defaultLanguage]; + } else { + logError`Wiki info file specified default language is ${wikiInfo.defaultLanguage}, but no such language file exists!`; + if (langPath) { + logError`Check if an appropriate file exists in ${langPath}?`; + } else { + logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`; + } + return; + } + } else { + languages.default = defaultStrings; + } + homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE)); if (homepageInfo.error) { @@ -4052,7 +5258,7 @@ async function main() { // avoiding that in our code 8ecause, again, we want to avoid assuming the // format of the returned paths here - they're only meant to 8e used for // reading as-is. - const albumDataFiles = await findAlbumDataFiles(path.join(dataPath, C.DATA_ALBUM_DIRECTORY)); + const albumDataFiles = await findFiles(path.join(dataPath, C.DATA_ALBUM_DIRECTORY)); // Technically, we could do the data file reading and output writing at the // same time, 8ut that kinda makes the code messy, so I'm not 8othering @@ -4087,6 +5293,9 @@ async function main() { } } + artistAliasData = artistData.filter(x => x.alias); + artistData = artistData.filter(x => !x.alias); + trackData = C.getAllTracks(albumData); if (wikiInfo.features.flashesAndGames) { @@ -4105,6 +5314,9 @@ async function main() { } } + flashActData = flashData.filter(x => x.act8r8k); + flashData = flashData.filter(x => !x.act8r8k); + artistNames = Array.from(new Set([ ...artistData.filter(artist => !artist.alias).map(artist => artist.name), ...[ @@ -4156,6 +5368,9 @@ async function main() { } } + groupCategoryData = groupData.filter(x => x.isCategory); + groupData = groupData.filter(x => x.isGroup); + staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE)); if (staticPageData.error) { console.log(`\x1b[31;1m${staticPageData.error}\x1b[0m`); @@ -4211,9 +5426,9 @@ async function main() { artistNames.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0); - justEverythingMan = C.sortByDate(albumData.concat(trackData, flashData?.filter(flash => !flash.act8r8k) || [])); + justEverythingMan = C.sortByDate([...albumData, ...trackData, ...(flashData || [])]); justEverythingSortedByArtDateMan = C.sortByArtDate(justEverythingMan.slice()); - // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(getHrefOfAnythingMan), null, 2)); + // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2)); { let buffer = []; @@ -4239,7 +5454,7 @@ async function main() { }; let CR4SH = false; for (let name of artistNames) { - const entry = artistData.find(entry => entry.name === name || entry.name.toLowerCase() === name.toLowerCase()); + const entry = [...artistData, ...artistAliasData].find(entry => entry.name === name || entry.name.toLowerCase() === name.toLowerCase()); if (!entry) { clearBuffer(); console.log(`\x1b[31mMissing entry for artist "\x1b[1m${name}\x1b[0;31m"\x1b[0m`); @@ -4309,12 +5524,8 @@ async function main() { { for (const { references, name, album } of trackData) { for (const ref of references) { - // Skip these, for now. - if (ref.includes("by")) { - continue; - } - if (!getLinkedTrack(ref)) { - console.warn(`\x1b[33mTrack not found "${ref}" in ${name} (${album.name})\x1b[0m`); + if (!search.track(ref)) { + logWarn`Track not found "${ref}" in ${name} (${album.name})`; } } } @@ -4332,48 +5543,63 @@ async function main() { // more than once. (We 8uild a few additional links that can't 8e cre8ted // at initial data processing time here too.) - const filterNull = (parent, key) => { + const filterNullArray = (parent, key) => { for (const obj of parent) { const array = obj[key]; for (let i = 0; i < array.length; i++) { - if (!Boolean(array[i])) { + if (!array[i]) { const prev = array[i - 1] && array[i - 1].name; const next = array[i + 1] && array[i + 1].name; - console.log(`\x1b[33mUnexpected null in ${obj.name} (${key}) - prev: ${prev}, next: ${next}\x1b[0m`); + logWarn`Unexpected null in ${obj.name} (${obj.what}) (array key ${key} - prev: ${prev}, next: ${next})`; } } array.splice(0, array.length, ...array.filter(Boolean)); } }; - trackData.forEach(track => mapInPlace(track.references, getLinkedTrack)); - trackData.forEach(track => track.aka = getLinkedTrack(track.aka)); - trackData.forEach(track => mapInPlace(track.artTags, getLinkedTag)); - albumData.forEach(album => mapInPlace(album.groups, getLinkedGroup)); - albumData.forEach(album => mapInPlace(album.artTags, getLinkedTag)); - artistData.forEach(artist => artist.alias = getLinkedArtist(artist.alias)); - contributionData.forEach(contrib => contrib.who = getLinkedArtist(contrib.who)); + const filterNullValue = (parent, key) => { + parent.splice(0, parent.length, ...parent.filter(obj => { + if (!obj[key]) { + logWarn`Unexpected null in ${obj.name} (value key ${key})`; + } + })); + }; - filterNull(trackData, 'references'); - filterNull(albumData, 'groups'); + trackData.forEach(track => mapInPlace(track.references, search.track)); + trackData.forEach(track => track.aka = search.track(track.aka)); + trackData.forEach(track => mapInPlace(track.artTags, search.tag)); + albumData.forEach(album => mapInPlace(album.groups, search.group)); + albumData.forEach(album => mapInPlace(album.artTags, search.tag)); + artistAliasData.forEach(artist => artist.alias = search.artist(artist.alias)); + contributionData.forEach(contrib => contrib.who = search.artist(contrib.who)); + + filterNullArray(trackData, 'references'); + filterNullArray(trackData, 'artTags'); + filterNullArray(albumData, 'groups'); + filterNullArray(albumData, 'artTags'); + filterNullValue(artistAliasData, 'alias'); + filterNullValue(contributionData, 'who'); trackData.forEach(track1 => track1.referencedBy = trackData.filter(track2 => track2.references.includes(track1))); groupData.forEach(group => group.albums = albumData.filter(album => album.groups.includes(group))); tagData.forEach(tag => tag.things = C.sortByArtDate([...albumData, ...trackData]).filter(thing => thing.artTags.includes(tag))); + groupData.forEach(group => group.category = groupCategoryData.find(x => x.name === group.category)); + groupCategoryData.forEach(category => category.groups = groupData.filter(x => x.category === category)); + trackData.forEach(track => track.otherReleases = [ track.aka, ...trackData.filter(({ aka }) => aka === track) ].filter(Boolean)); if (wikiInfo.features.flashesAndGames) { - const actlessFlashData = flashData.filter(flash => !flash.act8r8k); + flashData.forEach(flash => mapInPlace(flash.tracks, search.track)); + flashData.forEach(flash => flash.act = flashActData.find(act => act.name === flash.act)); + flashActData.forEach(act => act.flashes = flashData.filter(flash => flash.act === act)); - actlessFlashData.forEach(flash => mapInPlace(flash.tracks, getLinkedTrack)); + filterNullArray(flashData, 'tracks'); - filterNull(actlessFlashData, 'tracks'); - - trackData.forEach(track => track.flashes = actlessFlashData.filter(flash => flash.tracks.includes(track))); + trackData.forEach(track => track.flashes = flashData.filter(flash => flash.tracks.includes(track))); } artistData.forEach(artist => { @@ -4401,9 +5627,6 @@ async function main() { } }); - groupData.filter(x => x.isGroup).forEach(group => group.category = groupData.find(x => x.isCategory && x.name === group.category)); - groupData.filter(x => x.isCategory).forEach(category => category.groups = groupData.filter(x => x.isGroup && x.category === category)); - officialAlbumData = albumData.filter(album => album.groups.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY)); fandomAlbumData = albumData.filter(album => album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY)); @@ -4439,17 +5662,65 @@ async function main() { const buildAll = !Object.keys(buildFlags).length || buildFlags.all; await writeSymlinks(); - if (buildAll || buildFlags.misc) await writeMiscellaneousPages(); - if (buildAll || buildFlags.static) await writeStaticPages(); - if (buildAll || buildFlags.news) await writeNewsPages(); - if (buildAll || buildFlags.list) await writeListingPages(); - if (buildAll || buildFlags.tag) await writeTagPages(); - if (buildAll || buildFlags.commentary) await writeCommentaryPages(); - if (buildAll || buildFlags.group) await writeGroupPages(); - if (buildAll || buildFlags.album) await writeAlbumPages(); - if (buildAll || buildFlags.track) await writeTrackPages(); - if (buildAll || buildFlags.artist) await writeArtistPages(); - if (buildAll || buildFlags.flash) if (wikiInfo.features.flashesAndGames) await writeFlashPages(); + await writeSharedFilesAndPages({strings: defaultStrings}); + + const buildDictionary = { + misc: writeMiscellaneousPages, + news: writeNewsPages, + list: writeListingPages, + tag: writeTagPages, + commentary: writeCommentaryPages, + static: writeStaticPages, + group: writeGroupPages, + album: writeAlbumPages, + track: writeTrackPages, + artist: writeArtistPages, + flash: writeFlashPages + }; + + const buildSteps = (buildAll + ? Object.values(buildDictionary) + : (Object.entries(buildDictionary) + .filter(([ flag ]) => buildFlags[flag]) + .map(([ flag, fn ]) => fn))); + + // The writeThingPages functions don't actually immediately do any file + // writing themselves; an initial call will only gather the relevant data + // which is *then* used for writing. So the return value is a function + // (or an array of functions) which expects {writePage, strings}, and + // *that's* what we call after -- multiple times, once for each language. + let pageWriteFns; + { + let error = false; + + pageWriteFns = buildSteps.flatMap(fn => { + const fns = fn(); + + // Do a quick valid8tion! If one of the writeThingPages functions go + // wrong, this will stall out early and tell us which did. + if (!Array.isArray(fns)) { + logError`${fn.name} didn't return an array!`; + error = true; + } else if (fns.some(fn => typeof fn !== 'function')) { + logError`${fn.name} didn't return all functions!`; + error = true; + } + + return fns; + }); + + if (error) { + return; + } + } + + await wrapLanguages(async ({strings, ...opts}) => { + console.log(`\x1b[34;1m${strings.code} (-> /${opts.baseDirectory}) ${'-'.repeat(50)}\x1b[0m`); + await progressPromiseAll(`Writing ${strings.code}`, queue( + pageWriteFns.map(fn => () => fn({strings, ...opts})), + queueSize + )); + }); decorateTime.displayTime(); |