diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/gen-thumbs.js | 306 | ||||
-rw-r--r-- | src/static/client.js | 415 | ||||
-rw-r--r-- | src/static/icons.svg | 11 | ||||
-rw-r--r-- | src/static/lazy-loading.js | 51 | ||||
-rw-r--r-- | src/static/site-basic.css | 19 | ||||
-rw-r--r-- | src/static/site.css | 872 | ||||
-rw-r--r-- | src/strings-default.json | 305 | ||||
-rwxr-xr-x | src/upd8.js | 6395 | ||||
-rw-r--r-- | src/util/cli.js | 210 | ||||
-rw-r--r-- | src/util/colors.js | 47 | ||||
-rw-r--r-- | src/util/html.js | 92 | ||||
-rw-r--r-- | src/util/link.js | 67 | ||||
-rw-r--r-- | src/util/node-utils.js | 27 | ||||
-rw-r--r-- | src/util/sugar.js | 70 | ||||
-rw-r--r-- | src/util/urls.js | 102 | ||||
-rw-r--r-- | src/util/wiki-data.js | 126 |
16 files changed, 9115 insertions, 0 deletions
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js new file mode 100644 index 00000000..d636d2fd --- /dev/null +++ b/src/gen-thumbs.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +// Ok, so the d8te is 3 March 2021, and the music wiki was initially released +// on 15 November 2019. That is 474 days or 11376 hours. In my opinion, and +// pro8a8ly the opinions of at least one other person, that is WAY TOO LONG +// to go without media thum8nails!!!! So that's what this file is here to do. +// +// This program takes a path to the media folder (via --media or the environ. +// varia8le HSMUSIC_MEDIA), traverses su8directories to locate image files, +// and gener8tes lower-resolution/file-size versions of all that are new or +// have 8een modified since the last run. We use a JSON-format cache of MD5s +// for each file to perform this comparision; we gener8te files (using ffmpeg) +// in "medium" and "small" sizes adjacent to the existing PNG for easy and +// versatile access in site gener8tion code. +// +// So for example, on the very first run, you might have a media folder which +// looks something like this: +// +// media/ +// album-art/ +// one-year-older/ +// cover.jpg +// firefly-cloud.jpg +// october.jpg +// ... +// flash-art/ +// 413.jpg +// ... +// bg.jpg +// ... +// +// After running gen-thumbs.js with the path to that folder passed, you'd end +// up with something like this: +// +// media/ +// album-art/ +// one-year-older/ +// cover.jpg +// cover.medium.jpg +// cover.small.jpg +// firefly-cloud.jpg +// firefly-cloud.medium.jpg +// firefly-cloud.small.jpg +// october.jpg +// october.medium.jpg +// october.small.jpg +// ... +// flash-art/ +// 413.jpg +// 413.medium.jpg +// 413.small.jpg +// ... +// bg.jpg +// bg.medium.jpg +// bg.small.jpg +// thumbs-cache.json +// ... +// +// (Do note that while 8oth JPG and PNG are supported, gener8ted files will +// always 8e in JPG format and file extension. GIFs are skipped since there +// aren't any super gr8 ways to make those more efficient!) +// +// And then in gener8tion code, you'd reference the medium/small or original +// version of each file, as decided is appropriate. Here are some guidelines: +// +// - Small: Grid tiles on the homepage and in galleries. +// - Medium: Cover art on individual al8um and track pages, etc. +// - Original: Only linked to, not embedded. +// +// The traversal code is indiscrimin8te: there are no special cases to, say, +// not gener8te thum8nails for the bg.jpg file (since those would generally go +// unused). This is just to make the code more porta8le and sta8le, long-term, +// since it avoids a lot of otherwise implic8ted maintenance. + +'use strict'; + +const CACHE_FILE = 'thumbnail-cache.json'; +const WARNING_DELAY_TIME = 10000; + +import { spawn } from 'child_process'; +import { createHash } from 'crypto'; +import * as path from 'path'; + +import { + readdir, + readFile, + writeFile +} from 'fs/promises'; // Whatcha know! Nice. + +import { + createReadStream +} from 'fs'; // Still gotta import from 8oth tho, for createReadStream. + +import { + logError, + logInfo, + logWarn, + parseOptions, + progressPromiseAll +} from './util/cli.js'; + +import { + promisifyProcess, +} from './util/node-utils.js'; + +import { + delay, + queue, +} from './util/sugar.js'; + +function traverse(startDirPath, { + filterFile = () => true, + filterDir = () => true +} = {}) { + const recursive = (names, subDirPath) => Promise + .all(names.map(name => readdir(path.join(startDirPath, subDirPath, name)).then( + names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [], + err => filterFile(name) ? [path.join(subDirPath, name)] : []))) + .then(pathArrays => pathArrays.flatMap(x => x)); + + return readdir(startDirPath) + .then(names => recursive(names, '')); +} + +function readFileMD5(filePath) { + return new Promise((resolve, reject) => { + const md5 = createHash('md5'); + const stream = createReadStream(filePath); + stream.on('data', data => md5.update(data)); + stream.on('end', data => resolve(md5.digest('hex'))); + stream.on('error', err => reject(err)); + }); +} + +function generateImageThumbnails(filePath) { + const dirname = path.dirname(filePath); + const extname = path.extname(filePath); + const basename = path.basename(filePath, extname); + const output = name => path.join(dirname, basename + name + '.jpg'); + + const convert = (name, {size, quality}) => spawn('convert', [ + '-strip', + '-resize', `${size}x${size}>`, + '-interlace', 'Plane', + '-quality', `${quality}%`, + filePath, + output(name) + ]); + + return Promise.all([ + promisifyProcess(convert('.medium', {size: 400, quality: 95}), false), + promisifyProcess(convert('.small', {size: 250, quality: 85}), false) + ]); + + return new Promise((resolve, reject) => { + if (Math.random() < 0.2) { + reject(new Error(`Them's the 8r8ks, kiddo!`)); + } else { + resolve(); + } + }); +} + +export default async function genThumbs(mediaPath, { + queueSize = 0, + quiet = false +} = {}) { + if (!mediaPath) { + throw new Error('Expected mediaPath to be passed'); + } + + const quietInfo = (quiet + ? () => null + : logInfo); + + const filterFile = name => { + // TODO: Why is this not working???????? + // thumbnail-cache.json is 8eing passed through, for some reason. + + const ext = path.extname(name); + if (ext !== '.jpg' && ext !== '.png') return false; + + const rest = path.basename(name, ext); + if (rest.endsWith('.medium') || rest.endsWith('.small')) return false; + + return true; + }; + + const filterDir = name => { + if (name === '.git') return false; + return true; + }; + + let cache, firstRun = false, failedReadingCache = false; + try { + cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE))); + quietInfo`Cache file successfully read.`; + } catch (error) { + cache = {}; + if (error.code === 'ENOENT') { + firstRun = true; + } else { + failedReadingCache = true; + logWarn`Malformed or unreadable cache file: ${error}`; + logWarn`You may want to cancel and investigate this!`; + logWarn`All-new thumbnails and cache will be generated for this run.`; + await delay(WARNING_DELAY_TIME); + } + } + + try { + await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache)); + quietInfo`Writing to cache file appears to be working.`; + } catch (error) { + logWarn`Test of cache file writing failed: ${error}`; + if (cache) { + logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`; + } else if (firstRun) { + logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`; + } else { + logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`; + } + logWarn`You may want to cancel and investigate this!`; + await delay(WARNING_DELAY_TIME); + } + + const imagePaths = await traverse(mediaPath, {filterFile, filterDir}); + + const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue( + imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then( + md5 => [imagePath, md5], + error => [imagePath, {error}] + )), + queueSize + )); + + { + let error = false; + for (const entry of imageToMD5Entries) { + if (entry[1].error) { + logError`Failed to read ${entry[0]}: ${entry[1].error}`; + error = true; + } + } + if (error) { + logError`Failed to read at least one image file!`; + logError`This implies a thumbnail probably won't be generatable.`; + logError`So, exiting early.`; + return false; + } else { + quietInfo`All image files successfully read.`; + } + } + + // Technically we could pro8a8ly mut8te the cache varia8le in-place? + // 8ut that seems kinda iffy. + const updatedCache = Object.assign({}, cache); + + const entriesToGenerate = imageToMD5Entries + .filter(([filePath, md5]) => md5 !== cache[filePath]); + + if (entriesToGenerate.length === 0) { + logInfo`All image thumbnails are already up-to-date - nice!`; + return true; + } + + const failed = []; + const succeeded = []; + const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`; + + // This is actually sort of a lie, 8ecause we aren't doing synchronicity. + // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll, + // 'cuz the progress indic8tor is very cool and good. + await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) => + () => generateImageThumbnails(path.join(mediaPath, filePath)).then( + () => { + updatedCache[filePath] = md5; + succeeded.push(filePath); + }, + error => { + failed.push([filePath, error]); + } + ) + ))); + + if (failed.length > 0) { + for (const [path, error] of failed) { + logError`Thumbnails failed to generate for ${path} - ${error}`; + } + logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`; + logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`; + } else { + logInfo`Generated all (updated) thumbnails successfully!`; + } + + try { + await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache)); + quietInfo`Updated cache file successfully written!`; + } catch (error) { + logWarn`Failed to write updated cache file: ${error}`; + logWarn`Any newly (re)generated thumbnails will be regenerated next run.`; + logWarn`Sorry about that!`; + } + + return true; +} diff --git a/src/static/client.js b/src/static/client.js new file mode 100644 index 00000000..c12ff355 --- /dev/null +++ b/src/static/client.js @@ -0,0 +1,415 @@ +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. +// +// Upd8: As of 04/02/2021, it's now used for info cards too! Nice. + +import { + getColors +} from '../util/colors.js'; + +let albumData, artistData, flashData; +let officialAlbumData, fandomAlbumData, artistNames; + +let ready = false; + +// Localiz8tion nonsense ---------------------------------- + +const language = document.documentElement.getAttribute('lang'); + +let list; +if ( + typeof Intl === 'object' && + typeof Intl.ListFormat === 'function' +) { + const getFormat = type => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit') + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = array => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock + }; +} + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, key) { + return getComputedStyle(el).getPropertyValue(key).trim(); +} + +function getRefDirectory(ref) { + return ref.split(':')[1]; +} + +function getAlbum(el) { + const directory = cssProp(el, '--album-directory'); + return albumData.find(album => album.directory === directory); +} + +function getFlash(el) { + const directory = cssProp(el, '--flash-directory'); + return flashData.find(flash => flash.directory === directory); +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. +const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); +const openAlbum = d => rebase(`album/${d}`); +const openTrack = d => rebase(`track/${d}`); +const openArtist = d => rebase(`artist/${d}`); +const openFlash = d => rebase(`flash/${d}`); + +function getTrackListAndIndex() { + const album = getAlbum(document.body); + const directory = cssProp(document.body, '--track-directory'); + if (!directory && !album) return {}; + if (!directory) return {list: album.tracks}; + const trackIndex = album.tracks.findIndex(track => track.directory === directory); + return {list: album.tracks, index: trackIndex}; +} + +function openRandomTrack() { + const { list } = getTrackListAndIndex(); + if (!list) return; + return openTrack(pick(list)); +} + +function getFlashListAndIndex() { + const list = flashData.filter(flash => !flash.act8r8k) + const flash = getFlash(document.body); + if (!flash) return {list}; + const flashIndex = list.indexOf(flash); + return {list, index: flashIndex}; +} + +// TODO: This should also use urlSpec. +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')) + .then(res => res.json()); +} + +// JS-based links ----------------------------------------- + +for (const a of document.body.querySelectorAll('[data-random]')) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } + + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + switch (a.dataset.random) { + case 'album': return a.href = openAlbum(pick(albumData).directory); + case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory); + case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory); + case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), [])))); + case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); + case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); + case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); + case 'artist': return a.href = openArtist(pick(artistData).directory); + case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => C.getArtistNumContributions(artist) > 1)).directory); + } + }); +} + +const next = document.getElementById('next-button'); +const previous = document.getElementById('previous-button'); +const random = document.getElementById('random-button'); + +const prependTitle = (el, prepend) => { + const existing = el.getAttribute('title'); + if (existing) { + el.setAttribute('title', prepend + ' ' + existing); + } else { + el.setAttribute('title', prepend); + } +}; + +if (next) prependTitle(next, '(Shift+N)'); +if (previous) prependTitle(previous, '(Shift+P)'); +if (random) prependTitle(random, '(Shift+R)'); + +document.addEventListener('keypress', event => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + if (next) next.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + if (previous) previous.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + if (random && ready) random.click(); + } + } +}); + +for (const reveal of document.querySelectorAll('.reveal')) { + reveal.addEventListener('click', event => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + } + }); +} + +const elements1 = document.getElementsByClassName('js-hide-once-data'); +const elements2 = document.getElementsByClassName('js-show-once-data'); + +for (const element of elements1) element.style.display = 'block'; + +fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => { + albumData = data.albumData; + artistData = data.artistData; + flashData = data.flashData; + + officialAlbumData = albumData.filter(album => album.groups.includes('group:official')); + fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official')); + artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name); + + for (const element of elements1) element.style.display = 'none'; + for (const element of elements2) element.style.display = 'block'; + + ready = true; +}); + +// Data & info card --------------------------------------- + +const NORMAL_HOVER_INFO_DELAY = 750; +const FAST_HOVER_INFO_DELAY = 250; +const END_FAST_HOVER_DELAY = 500; +const HIDE_HOVER_DELAY = 250; + +let fastHover = false; +let endFastHoverTimeout = null; + +function colorLink(a, color) { + if (color) { + const { primary, dim } = getColors(color); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map(el => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then(data => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements('conjunction', data.artists.map(({ artist }) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + })); + + const coverArtistParagraph = container.querySelector('.info-card-cover-artists'); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements('conjunction', data.coverArtists.map(({ artist }) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + })); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [ containerNoReveal, containerReveal ] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal') + ]; + + const [ containerShow, containerHide ] = (data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]); + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide + }; +})(); + +function makeInfoCardLinkHandlers(type) { + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout(() => { + fastHover = true; + infoCard.show(type, evt.target); + }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; + + infoCard.cancelHide(); + }, + + mouseleave(evt) { + clearTimeout(hoverTimeout); + + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } + + infoCard.readyHide(); + } + }; +} + +const infoCardLinkHandlers = { + track: makeInfoCardLinkHandlers('track') +}; + +function addInfoCardLinkHandlers(type) { + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) { + a.addEventListener(eventName, handler); + } + } +} + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} diff --git a/src/static/icons.svg b/src/static/icons.svg new file mode 100644 index 00000000..1e4351bf --- /dev/null +++ b/src/static/icons.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" display="none" width="0" height="0"> + <symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol> + <symbol id="icon-bandcamp" viewBox="0 0 40 40"><path d="M7.1,13.3c5.6,0,11.1,0,16.7,0c0,1.5,0,3.1,0,4.6c0.7-0.7,1.5-1.5,3.2-1.3c2.6,0.3,3.8,3,3.6,5.6c-0.1,1.1-0.5,2.4-1.3,3.1 c-0.9,0.9-2.9,1.4-4.6,0.5c-0.4-0.2-0.7-0.6-1-1.1c0,0.4,0,0.8,0,1.3c-0.6,0-1.3,0-1.9,0c0-4.2,0-8.3,0-12.5 c-2.3,3.9-4.6,8.4-6.9,12.5c-4.9,0-9.8,0-14.7,0C2.5,21.7,4.8,17.5,7.1,13.3L7.1,13.3z M24.3,19c-1.4,1.9-0.4,6.7,2.8,5.5 c2.4-0.9,2-6.6-1.2-6.3C25.2,18.3,24.7,18.5,24.3,19L24.3,19z"/> <path d="M39.7,19.9c-0.6,0-1.3,0-2,0c0-1.6-1.9-2-3.1-1.5c-2.3,1.1-1.8,7.1,1.6,6.2c0.8-0.2,1.2-0.9,1.4-2c0.6,0,1.3,0,2,0 c-0.1,2.4-2.1,3.9-4.4,3.7c-2.1-0.1-3.8-1.8-4-4.2c-0.2-2.9,1.3-5.9,5-5.5C38.3,16.8,39.6,17.9,39.7,19.9z"/></symbol> + <symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></symbol> + <symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol> + <symbol id="icon-tumblr" viewBox="0 0 40 40"><path d="M26.8,29.7l1.6,4.6c-0.3,0.5-1,0.9-2.2,1.3s-2.3,0.6-3.4,0.6c-1.4,0-2.6-0.1-3.7-0.5s-2.1-0.8-2.8-1.4 c-0.7-0.6-1.3-1.3-1.9-2.1c-0.5-0.8-0.9-1.6-1.1-2.3c-0.2-0.8-0.3-1.5-0.3-2.3V16.9H9.7v-4.2c0.9-0.3,1.8-0.8,2.5-1.4 s1.3-1.1,1.8-1.8s0.8-1.3,1.1-2c0.3-0.7,0.5-1.4,0.7-1.9S16,4.6,16.1,4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.1-0.1h4.8V12h6.5v4.9h-6.5V27 c0,0.4,0,0.8,0.1,1.1c0.1,0.3,0.2,0.7,0.4,1s0.5,0.6,1,0.8c0.4,0.2,1,0.3,1.6,0.3C25.2,30.2,26.1,30,26.8,29.7L26.8,29.7z"/></symbol> + <symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol> + <symbol id="icon-youtube" viewBox="0 0 40 40"><path d="M24.3,27v4.2c0,0.9-0.3,1.3-0.8,1.3c-0.3,0-0.6-0.1-0.9-0.4v-6c0.3-0.3,0.6-0.4,0.9-0.4C24,25.6,24.3,26.1,24.3,27L24.3,27z M31.1,27v0.9h-1.8V27c0-0.9,0.3-1.4,0.9-1.4C30.8,25.6,31.1,26.1,31.1,27L31.1,27z M11.7,22.6h2.1v-1.9H7.6v1.9h2.1v11.4h2 L11.7,22.6L11.7,22.6z M17.5,34.1h1.8v-9.9h-1.8v7.6c-0.4,0.6-0.8,0.8-1.1,0.8c-0.2,0-0.4-0.1-0.4-0.4c0,0,0-0.3,0-0.7v-7.3h-1.8V32 c0,0.7,0.1,1.1,0.2,1.5c0.2,0.5,0.5,0.7,1.2,0.7c0.6,0,1.3-0.4,2-1.2L17.5,34.1L17.5,34.1z M26.1,31.1v-4c0-1-0.1-1.6-0.2-2 c-0.2-0.7-0.7-1.1-1.4-1.1c-0.7,0-1.3,0.4-1.9,1.1v-4.4h-1.8v13.3h1.8v-1c0.6,0.7,1.2,1.1,1.9,1.1c0.7,0,1.2-0.4,1.4-1.1 C26,32.7,26.1,32.1,26.1,31.1L26.1,31.1z M32.9,30.9v-0.3H31c0,0.7,0,1.1,0,1.2c-0.1,0.5-0.4,0.7-0.8,0.7c-0.6,0-0.9-0.5-0.9-1.4 v-1.7h3.6v-2.1c0-1.1-0.2-1.8-0.5-2.3c-0.5-0.7-1.2-1-2.1-1c-0.9,0-1.6,0.3-2.1,1c-0.4,0.5-0.6,1.3-0.6,2.3v3.5 c0,1.1,0.2,1.8,0.6,2.3c0.5,0.7,1.2,1,2.2,1c1,0,1.7-0.4,2.2-1.1c0.2-0.4,0.4-0.7,0.4-1.1C32.9,31.9,32.9,31.5,32.9,30.9L32.9,30.9z M20.7,12.5V8.3c0-0.9-0.3-1.4-0.9-1.4c-0.6,0-0.9,0.5-0.9,1.4v4.2c0,0.9,0.3,1.4,0.9,1.4C20.4,14,20.7,13.5,20.7,12.5z M35.1,27.6 c0,3.1-0.2,5.5-0.5,7c-0.2,0.8-0.6,1.5-1.2,2c-0.6,0.5-1.3,0.8-2,0.9c-2.5,0.3-6.2,0.4-11.1,0.4s-8.7-0.1-11.1-0.4 c-0.8-0.1-1.5-0.4-2.1-0.9c-0.6-0.5-1-1.2-1.2-2c-0.3-1.5-0.5-3.8-0.5-7c0-3.1,0.2-5.5,0.5-7c0.2-0.8,0.6-1.5,1.2-2 c0.6-0.5,1.3-0.9,2.1-0.9c2.5-0.3,6.2-0.4,11.1-0.4s8.7,0.1,11.1,0.4c0.8,0.1,1.5,0.4,2.1,0.9c0.6,0.5,1,1.2,1.2,2 C34.9,22.1,35.1,24.4,35.1,27.6z M15.1,2h2l-2.4,8v5.4h-2V10c-0.2-1-0.6-2.4-1.2-4.3c-0.5-1.4-0.9-2.6-1.3-3.8h2.1l1.4,5.3L15.1,2z M22.5,8.7v3.5c0,1.1-0.2,1.9-0.6,2.4c-0.5,0.7-1.2,1-2.1,1c-0.9,0-1.6-0.3-2.1-1c-0.4-0.5-0.6-1.3-0.6-2.4V8.7 c0-1.1,0.2-1.9,0.6-2.3c0.5-0.7,1.2-1,2.1-1c0.9,0,1.6,0.3,2.1,1C22.3,6.8,22.5,7.6,22.5,8.7z M29.2,5.4v10h-1.8v-1.1 c-0.7,0.8-1.4,1.2-2.1,1.2c-0.6,0-1-0.2-1.2-0.7C24,14.5,24,14,24,13.4V5.4h1.8v7.4c0,0.4,0,0.7,0,0.7c0,0.3,0.2,0.4,0.4,0.4 c0.4,0,0.7-0.3,1.1-0.9V5.4C27.4,5.4,29.2,5.4,29.2,5.4z"/></symbol> + <symbol id="icon-instagram" viewBox="0 0 40 40"><path d="M20,7c4.2,0,4.7,0,6.3,0.1c1.5,0.1,2.3,0.3,3,0.5C30,8,30.5,8.3,31.1,8.9c0.5,0.5,0.9,1.1,1.2,1.8c0.2,0.5,0.5,1.4,0.5,3 C33,15.3,33,15.8,33,20s0,4.7-0.1,6.3c-0.1,1.5-0.3,2.3-0.5,3c-0.3,0.7-0.6,1.2-1.2,1.8c-0.5,0.5-1.1,0.9-1.8,1.2 c-0.5,0.2-1.4,0.5-3,0.5C24.7,33,24.2,33,20,33s-4.7,0-6.3-0.1c-1.5-0.1-2.3-0.3-3-0.5C10,32,9.5,31.7,8.9,31.1 C8.4,30.6,8,30,7.7,29.3c-0.2-0.5-0.5-1.4-0.5-3C7,24.7,7,24.2,7,20s0-4.7,0.1-6.3c0.1-1.5,0.3-2.3,0.5-3C8,10,8.3,9.5,8.9,8.9 C9.4,8.4,10,8,10.7,7.7c0.5-0.2,1.4-0.5,3-0.5C15.3,7.1,15.8,7,20,7z M20,4.3c-4.3,0-4.8,0-6.5,0.1c-1.6,0-2.8,0.3-3.8,0.7 C8.7,5.5,7.8,6,6.9,6.9C6,7.8,5.5,8.7,5.1,9.7c-0.4,1-0.6,2.1-0.7,3.8c-0.1,1.7-0.1,2.2-0.1,6.5s0,4.8,0.1,6.5 c0,1.6,0.3,2.8,0.7,3.8c0.4,1,0.9,1.9,1.8,2.8c0.9,0.9,1.7,1.4,2.8,1.8c1,0.4,2.1,0.6,3.8,0.7c1.6,0.1,2.2,0.1,6.5,0.1 s4.8,0,6.5-0.1c1.6-0.1,2.9-0.3,3.8-0.7c1-0.4,1.9-0.9,2.8-1.8c0.9-0.9,1.4-1.7,1.8-2.8c0.4-1,0.6-2.1,0.7-3.8 c0.1-1.6,0.1-2.2,0.1-6.5s0-4.8-0.1-6.5c-0.1-1.6-0.3-2.9-0.7-3.8c-0.4-1-0.9-1.9-1.8-2.8c-0.9-0.9-1.7-1.4-2.8-1.8 c-1-0.4-2.1-0.6-3.8-0.7C24.8,4.3,24.3,4.3,20,4.3L20,4.3L20,4.3z"/><path d="M20,11.9c-4.5,0-8.1,3.7-8.1,8.1s3.7,8.1,8.1,8.1s8.1-3.7,8.1-8.1S24.5,11.9,20,11.9z M20,25.2c-2.9,0-5.2-2.3-5.2-5.2 s2.3-5.2,5.2-5.2s5.2,2.3,5.2,5.2S22.9,25.2,20,25.2z"/><path d="M30.6,11.6c0,1-0.8,1.9-1.9,1.9c-1,0-1.9-0.8-1.9-1.9s0.8-1.9,1.9-1.9C29.8,9.7,30.6,10.5,30.6,11.6z"/></symbol> + <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol> +</svg> diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js new file mode 100644 index 00000000..a403d7ca --- /dev/null +++ b/src/static/lazy-loading.js @@ -0,0 +1,51 @@ +// Lazy loading! Roll your own. Woot. +// This file includes a 8unch of fall8acks and stuff like that, and is written +// with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser +// with JS ena8led. (If it's disa8led, there are gener8ted <noscript> tags to +// work there.) + +var observer; + +function loadImage(image) { + image.src = image.dataset.original; +} + +function lazyLoad(elements) { + for (var i = 0; i < elements.length; i++) { + var item = elements[i]; + if (item.intersectionRatio > 0) { + observer.unobserve(item.target); + loadImage(item.target); + } + } +} + +function lazyLoadMain() { + // This is a live HTMLCollection! We can't iter8te over it normally 'cuz + // we'd 8e mutating its value just 8y interacting with the DOM elements it + // contains. A while loop works just fine, even though you'd think reading + // over this code that this would 8e an infinitely hanging loop. It isn't! + var elements = document.getElementsByClassName('js-hide'); + while (elements.length) { + elements[0].classList.remove('js-hide'); + } + + var lazyElements = document.getElementsByClassName('lazy'); + if (window.IntersectionObserver) { + observer = new IntersectionObserver(lazyLoad, { + rootMargin: '200px', + threshold: 1.0 + }); + for (var i = 0; i < lazyElements.length; i++) { + observer.observe(lazyElements[i]); + } + } else { + for (var i = 0; i < lazyElements.length; i++) { + var element = lazyElements[i]; + var original = element.getAttribute('data-original'); + element.setAttribute('src', original); + } + } +} + +document.addEventListener('DOMContentLoaded', lazyLoadMain); diff --git a/src/static/site-basic.css b/src/static/site-basic.css new file mode 100644 index 00000000..d26584ae --- /dev/null +++ b/src/static/site-basic.css @@ -0,0 +1,19 @@ +/** + * For redirects and stuff like that. + * Small file, not so much helped 8y this comment. + */ + +html { + background-color: #222222; + color: white; +} + +body { + padding: 15px; +} + +main { + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted white; + padding: 20px; +} diff --git a/src/static/site.css b/src/static/site.css new file mode 100644 index 00000000..ae41f88d --- /dev/null +++ b/src/static/site.css @@ -0,0 +1,872 @@ +/* A frontend file! Wow. + * This file is just loaded statically 8y <link>s in the HTML files, so there's + * no need to re-run upd8.js when tweaking values here. Handy! + */ + +:root { + --primary-color: #0088ff; +} + +body { + background: black; + margin: 10px; + overflow-y: scroll; +} + +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + + background-image: url("../media/bg.jpg"); + background-position: center; + background-size: cover; + opacity: 0.5; +} + +#page-container { + background-color: rgba(35, 35, 35, 0.80); + backdrop-filter: blur(4px); + color: #ffffff; + + max-width: 1100px; + margin: 10px auto 50px; + padding: 15px 0; + + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); +} + +#page-container > * { + margin-left: 15px; + margin-right: 15px; +} + +#banner { + margin: 10px 0; + width: 100%; + background: black; + background-color: var(--dim-color); + border-bottom: 1px solid var(--primary-color); + position: relative; +} + +#banner::after { + content: ""; + box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +#banner.dim img { + opacity: 0.8; +} + +#banner img { + display: block; + width: 100%; + height: auto; +} + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +#skippers { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; +} + +#skippers:focus-within { + position: static; + width: unset; + height: unset; +} + +#skippers > .skipper:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +.layout-columns { + display: flex; +} + +#header, #skippers, #footer { + padding: 5px; + font-size: 0.85em; +} + +#header, #skippers { + margin-bottom: 10px; +} + +#footer { + margin-top: 10px; +} + +#header { + display: flex; +} + +#header > h2 { + font-size: 1em; + margin: 0 20px 0 0; + font-weight: normal; +} + +#header > h2 a.current { + font-weight: 800; +} + +#header > h2.dot-between-spans > span:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +#header > h2 > span { + white-space: nowrap; +} + +#header > div { + flex-grow: 1; +} + +#header > div > *:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +#header .chronology { + display: inline-block; +} + +#header .chronology .heading, +#header .chronology .buttons { + display: inline-block; +} + +footer { + text-align: center; + font-style: oblique; +} + +footer > :first-child { + margin-top: 0; +} + +footer > :last-child { + margin-bottom: 0; +} + +.nowrap { + white-space: nowrap; +} + +.icons { + font-style: normal; + white-space: nowrap; +} + +.icon { + display: inline-block; + width: 24px; + height: 1em; + position: relative; +} + +.icon > svg { + width: 24px; + height: 24px; + top: -0.25em; + position: absolute; + fill: var(--primary-color); +} + +.rerelease { + opacity: 0.7; + font-style: oblique; +} + +.content-columns { + columns: 2; +} + +.content-columns .column { + break-inside: avoid; +} + +.content-columns .column h2 { + margin-top: 0; + font-size: 1em; +} + +.sidebar, #content, #header, #skippers, #footer { + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted var(--primary-color); + border-radius: 3px; +} + +.sidebar-column { + flex: 1 1 20%; + min-width: 150px; + max-width: 250px; + flex-basis: 250px; + height: 100%; +} + +.sidebar-multiple { + display: flex; + flex-direction: column; +} + +.sidebar-multiple .sidebar:not(:first-child) { + margin-top: 10px; +} + +.sidebar { + padding: 5px; + font-size: 0.85em; +} + +#sidebar-left { + margin-right: 10px; +} + +#sidebar-right { + margin-left: 10px; +} + +.sidebar.wide { + max-width: 350px; + flex-basis: 300px; + flex-shrink: 0; + flex-grow: 1; +} + +#content { + padding: 20px; + flex-grow: 1; + flex-shrink: 3; +} + +.sidebar > h1, +.sidebar > h2, +.sidebar > h3, +.sidebar > p { + text-align: center; +} + +.sidebar h1 { + font-size: 1.25em; +} + +.sidebar h2 { + font-size: 1.1em; + margin: 0; +} + +.sidebar h3 { + font-size: 1.1em; + font-style: oblique; + font-variant: small-caps; + margin-top: 0.3em; + margin-bottom: 0em; +} + +.sidebar > p { + margin: 0.5em 0; + padding: 0 5px; +} + +.sidebar hr { + color: #555; + margin: 10px 5px; +} + +.sidebar > ol, .sidebar > ul { + padding-left: 30px; + padding-right: 15px; +} + +.sidebar > dl { + padding-right: 15px; + padding-left: 0; +} + +.sidebar > dl dt { + padding-left: 10px; + margin-top: 0.5em; +} + +.sidebar > dl dt.current { + font-weight: 800; +} + +.sidebar > dl dd { + margin-left: 0; +} + +.sidebar > dl dd ul { + padding-left: 30px; + margin-left: 0; +} + +.sidebar > dl .side { + padding-left: 10px; +} + +.sidebar li.current { + font-weight: 800; +} + +.sidebar li { + overflow-wrap: break-word; +} + +.sidebar article { + text-align: left; + margin: 5px 5px 15px 5px; +} + +.sidebar article:last-child { + margin-bottom: 5px; +} + +.sidebar article h2, +.news-index h2 { + border-bottom: 1px dotted; +} + +.sidebar article h2 time, +.news-index time { + float: right; + font-weight: normal; +} + +#cover-art-container { + float: right; + width: 40%; + max-width: 400px; + margin: 0 0 10px 10px; + font-size: 0.8em; +} + +#cover-art img { + display: block; + width: 100%; + height: 100%; +} + +#cover-art-container p { + margin-top: 5px; +} + +.image-container { + border: 2px solid var(--primary-color); + box-sizing: border-box; + position: relative; + padding: 5px; + text-align: left; + background-color: var(--dim-color); + color: white; + display: inline-block; + width: 100%; + height: 100%; +} + +.image-inner-area { + overflow: hidden; + width: 100%; + height: 100%; +} + +img { + object-fit: cover; + /* these unfortunately dont take effect while loading, so... + text-align: center; + line-height: 2em; + text-shadow: 0 0 5px black; + font-style: oblique; + */ +} + +.js-hide, +.js-show-once-data, +.js-hide-once-data { + display: none; +} + +a.box:focus { + outline: 3px double var(--primary-color); +} + +a.box:focus:not(:focus-visible) { + outline: none; +} + +a.box img { + display: block; + width: 100%; + height: 100%; +} + +h1 { + font-size: 1.5em; +} + +#content li { + margin-bottom: 4px; +} + +#content li i { + white-space: nowrap; +} + +.grid-listing { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; +} + +.grid-item { + display: inline-block; + margin: 15px; + text-align: center; + background-color: #111111; + border: 1px dotted var(--primary-color); + border-radius: 2px; + padding: 5px; +} + +.grid-item img { + width: 100%; + height: 100%; + margin-top: auto; + margin-bottom: auto; +} + +.grid-item span { + overflow-wrap: break-word; + hyphens: auto; +} + +.grid-item:hover { + text-decoration: none; +} + +.grid-actions .grid-item:hover { + text-decoration: underline; +} + +.grid-item > span:first-of-type { + margin-top: 0.45em; + display: block; +} + +.grid-item:hover > span:first-of-type { + text-decoration: underline; +} + +.grid-listing > .grid-item { + flex: 1 1 26%; +} + +.grid-actions { + display: flex; + flex-direction: column; + margin: 15px; +} + +.grid-actions > .grid-item { + flex-basis: unset !important; + margin: 5px; + --primary-color: inherit !important; + --dim-color: inherit !important; +} + +.grid-item { + flex-basis: 240px; + min-width: 200px; + max-width: 240px; +} + +.grid-item:not(.large-grid-item) { + flex-basis: 180px; + min-width: 120px; + max-width: 180px; + font-size: 0.9em; +} + +.square { + position: relative; + width: 100%; +} + +.square::after { + content: ""; + display: block; + padding-bottom: 100%; +} + +.square-content { + position: absolute; + width: 100%; + height: 100%; +} + +.vertical-square { + position: relative; + height: 100%; +} + +.vertical-square::after { + content: ""; + display: block; + padding-right: 100%; +} + +.reveal { + position: relative; + width: 100%; + height: 100%; +} + +.reveal img { + filter: blur(20px); + opacity: 0.4; +} + +.reveal-text { + color: white; + position: absolute; + top: 15px; + left: 10px; + right: 10px; + text-align: center; + font-weight: bold; +} + +.reveal-interaction { + opacity: 0.8; +} + +.reveal.revealed img { + filter: none; + opacity: 1; +} + +.reveal.revealed .reveal-text { + display: none; +} + +#content.top-index h1, +#content.flash-index h1 { + text-align: center; + font-size: 2em; +} + +#content.flash-index h2 { + text-align: center; + font-size: 2.5em; + font-variant: small-caps; + font-style: oblique; + margin-bottom: 0; + text-align: center; + width: 100%; +} + +#content.top-index h2 { + text-align: center; + font-size: 2em; + font-weight: normal; + margin-bottom: 0.25em; +} + +.quick-info { + text-align: center; +} + +ul.quick-info { + list-style: none; + padding-left: 0; +} + +ul.quick-info li { + display: inline-block; +} + +ul.quick-info li:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +#intro-menu { + margin: 24px 0; + padding: 10px; + background-color: #222222; + text-align: center; + border: 1px dotted var(--primary-color); + border-radius: 2px; +} + +#intro-menu p { + margin: 12px 0; +} + +#intro-menu a { + margin: 0 6px; +} + +li .by { + white-space: nowrap; + font-style: oblique; +} + +p code { + font-size: 1em; + font-family: 'courier new'; + font-weight: 800; +} + +blockquote { + max-width: 600px; + margin-right: 0; +} + +.long-content { + margin-left: 12%; + margin-right: 12%; +} + +p img { + max-width: 100%; + height: auto; +} + +dl dt { + padding-left: 2em; +} + +dl dt { + margin-bottom: 2px; +} + +dl dd { + margin-bottom: 1em; +} + +dl ul, dl ol { + margin-top: 0; + margin-bottom: 0; +} + +.album-group-list dt { + font-style: oblique; + padding-left: 0; +} + +.album-group-list dd { + margin-left: 0; +} + +.group-chronology-link { + font-style: oblique; +} + +hr.split::before { + content: "(split)"; + color: #808080; +} + +hr.split { + position: relative; + overflow: hidden; + border: none; +} + +hr.split::after { + display: inline-block; + content: ""; + border: 1px inset #808080; + width: 100%; + position: absolute; + top: 50%; + margin-top: -2px; + margin-left: 10px; +} + +li > ul { + margin-top: 5px; +} + +#info-card-container { + position: absolute; + + left: 0; + right: 10px; + + pointer-events: none; /* Padding area shouldn't 8e interactive. */ + display: none; +} + +#info-card-container.show, +#info-card-container.hide { + display: flex; +} + +#info-card-container > * { + flex-basis: 400px; +} + +#info-card-container.show { + animation: 0.2s linear forwards info-card-show; + transition: top 0.1s, left 0.1s; +} + +#info-card-container.hide { + animation: 0.2s linear forwards info-card-hide; +} + +@keyframes info-card-show { + 0% { + opacity: 0; + margin-top: -5px; + } + + 100% { + opacity: 1; + margin-top: 0; + } +} + +@keyframes info-card-hide { + 0% { + opacity: 1; + margin-top: 0; + } + + 100% { + opacity: 0; + margin-top: 5px; + display: none !important; + } +} + +.info-card-decor { + padding-left: 3ch; + border-top: 1px solid white; +} + +.info-card { + background-color: black; + color: white; + + border: 1px dotted var(--primary-color); + border-radius: 3px; + box-shadow: 0 5px 5px black; + + padding: 5px; + font-size: 0.9em; + + pointer-events: none; +} + +.info-card::after { + content: ""; + display: block; + clear: both; +} + +#info-card-container.show .info-card { + animation: 0.01s linear 0.2s forwards info-card-become-interactive; +} + +@keyframes info-card-become-interactive { + to { + pointer-events: auto; + } +} + +.info-card-art-container { + float: right; + + width: 40%; + margin: 5px; + font-size: 0.8em; + + /* Dynamically shown. */ + display: none; +} + +.info-card-art-container .image-container { + padding: 2px; +} + +.info-card-art { + display: block; + width: 100%; + height: 100%; +} + +.info-card-name { + font-size: 1em; + border-bottom: 1px dotted; + margin: 0; +} + +.info-card p { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +.info-card p:last-child { + margin-bottom: 0; +} + +@media (max-width: 900px) { + .sidebar-column:not(.no-hide) { + display: none; + } + + .layout-columns.vertical-when-thin { + flex-direction: column; + } + + .layout-columns.vertical-when-thin > *:not(:last-child) { + margin-bottom: 10px; + } + + .sidebar-column.no-hide { + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + } + + .sidebar .news-entry:not(.first-news-entry) { + display: none; + } +} + +@media (max-width: 600px) { + .content-columns { + columns: 1; + } +} diff --git a/src/strings-default.json b/src/strings-default.json new file mode 100644 index 00000000..7a948d64 --- /dev/null +++ b/src/strings-default.json @@ -0,0 +1,305 @@ +{ + "meta.languageCode": "en", + "count.tracks": "{TRACKS}", + "count.tracks.withUnit.zero": "", + "count.tracks.withUnit.one": "{TRACKS} track", + "count.tracks.withUnit.two": "", + "count.tracks.withUnit.few": "", + "count.tracks.withUnit.many": "", + "count.tracks.withUnit.other": "{TRACKS} tracks", + "count.albums": "{ALBUMS}", + "count.albums.withUnit.zero": "", + "count.albums.withUnit.one": "{ALBUMS} album", + "count.albums.withUnit.two": "", + "count.albums.withUnit.two": "", + "count.albums.withUnit.few": "", + "count.albums.withUnit.many": "", + "count.albums.withUnit.other": "{ALBUMS} albums", + "count.commentaryEntries": "{ENTRIES}", + "count.commentaryEntries.withUnit.zero": "", + "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", + "count.commentaryEntries.withUnit.two": "", + "count.commentaryEntries.withUnit.few": "", + "count.commentaryEntries.withUnit.many": "", + "count.commentaryEntries.withUnit.other": "{ENTRIES} entries", + "count.contributions": "{CONTRIBUTIONS}", + "count.contributions.withUnit.zero": "", + "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution", + "count.contributions.withUnit.two": "", + "count.contributions.withUnit.few": "", + "count.contributions.withUnit.many": "", + "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions", + "count.coverArts": "{COVER_ARTS}", + "count.coverArts.withUnit.zero": "", + "count.coverArts.withUnit.one": "{COVER_ARTS} cover art", + "count.coverArts.withUnit.two": "", + "count.coverArts.withUnit.few": "", + "count.coverArts.withUnit.many": "", + "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", + "count.timesReferenced": "{TIMES_REFERENCED}", + "count.timesReferenced.withUnit.zero": "", + "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", + "count.timesReferenced.withUnit.two": "", + "count.timesReferenced.withUnit.few": "", + "count.timesReferenced.withUnit.many": "", + "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced", + "count.words": "{WORDS}", + "count.words.thousand": "{WORDS}k", + "count.words.withUnit.zero": "", + "count.words.withUnit.one": "{WORDS} word", + "count.words.withUnit.two": "", + "count.words.withUnit.few": "", + "count.words.withUnit.many": "", + "count.words.withUnit.other": "{WORDS} words", + "count.timesUsed": "{TIMES_USED}", + "count.timesUsed.withUnit.zero": "", + "count.timesUsed.withUnit.one": "used {TIMES_USED} time", + "count.timesUsed.withUnit.two": "", + "count.timesUsed.withUnit.few": "", + "count.timesUsed.withUnit.many": "", + "count.timesUsed.withUnit.other": "used {TIMES_USED} times", + "count.index.zero": "", + "count.index.one": "{INDEX}st", + "count.index.two": "{INDEX}nd", + "count.index.few": "{INDEX}rd", + "count.index.many": "", + "count.index.other": "{INDEX}th", + "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": "_:__", + "releaseInfo.by": "By {ARTISTS}.", + "releaseInfo.from": "From {ALBUM}.", + "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", + "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", + "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.", + "releaseInfo.released": "Released {DATE}.", + "releaseInfo.artReleased": "Art released {DATE}.", + "releaseInfo.addedToWiki": "Added to wiki {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.albumBanner": "album banner", + "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.coverArt": "{INDEX} cover art by {ARTIST}", + "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}", + "misc.chronology.heading.track": "{INDEX} track by {ARTIST}", + "misc.external.domain": "External ({DOMAIN})", + "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.bannerArt": "(banner 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.musicGroupsLine": "Contributed music to groups: {GROUPS}", + "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", + "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})", + "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.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", + "listingPage.listAlbums.byDateAdded.date": "{DATE}", + "listingPage.listAlbums.byDateAdded.album": "{ALBUM}", + "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.referenceList.fandom": "Fandom:", + "trackPage.referenceList.official": "Official:", + "trackPage.nav.track": "{TRACK}", + "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", + "trackPage.nav.random": "Random" +} diff --git a/src/upd8.js b/src/upd8.js new file mode 100755 index 00000000..c0bea083 --- /dev/null +++ b/src/upd8.js @@ -0,0 +1,6395 @@ +#!/usr/bin/env node + +// HEY N8RDS! +// +// This is one of the 8ACKEND FILES. It's not used anywhere on the actual site +// you are pro8a8ly using right now. +// +// Specifically, this one does all the actual work of the music wiki. The +// process looks something like this: +// +// 1. Crawl the music directories. Well, not so much "crawl" as "look inside +// the folders for each al8um, and read the metadata file descri8ing that +// al8um and the tracks within." +// +// 2. Read that metadata. I'm writing this 8efore actually doing any of the +// code, and I've gotta admit I have no idea what file format they're +// going to 8e in. May8e JSON, 8ut more likely some weird custom format +// which will 8e a lot easier to edit. +// +// 3. Generate the page files! They're just static index.html files, and are +// what gh-pages (or wherever this is hosted) will show to clients. +// Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference +// CSS (and maaaaaaaay8e JS) files, hard-coded somewhere near the root. +// +// 4. Print an awesome message which says the process is done. This is the +// most important step. +// +// Oh yeah, like. Just run this through some relatively recent version of +// node.js and you'll 8e fine. ...Within the project root. O8viously. + +// HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are, +// like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link +// from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um +// listing page (a list of all the al8ums)! Make sure to sort these 8y date - +// we'll need a new field for al8ums. + +// ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom +// wiki (I found half those images anywayz). + +// TRACK ART CREDITS. This is a must. + +// 2020-08-23 +// ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE +// ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T +// MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE. +// We're gonna start defining STRUCTURES to make things suck less!!!!!!!! +// No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance +// or whatever -- just some standard structures that should 8e followed +// wherever reasona8le. Only one I need today is the contri8 one 8ut let's put +// any new general-purpose structures here too, ok? +// +// Contri8ution: {who, what, date, thing}. D8 and thing are the new fields. +// +// Use these wisely, which is to say all the time and instead of whatever +// terri8le new pseudo structure you're trying to invent!!!!!!!! +// +// Upd8 2021-01-03: Soooooooo we didn't actually really end up using these, +// lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor +// of all the o8ject structures today. It's not *especially* relevant 8ut feels +// worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much! +// Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the +// spirit of this "make things more consistent" attitude I 8rought up 8ack in +// August, stuff's lookin' 8etter than ever now. W00t! + +import * as path from 'path'; +import { promisify } from 'util'; +import { fileURLToPath } from 'url'; + +// I made this dependency myself! A long, long time ago. It is pro8a8ly my +// most useful li8rary ever. I'm not sure 8esides me actually uses it, though. +import fixWS from 'fix-whitespace'; +// Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast- +// crunch. THAT is my 8est li8rary. + +// It stands for "HTML Entities", apparently. Cursed. +import he from 'he'; + +import { + // 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 + // convention! Would it have 8een so hard to just name the function + // something like fs.readDirectory???????? No, it wouldn't have 8een. + readdir, + // ~~ 8ut okay, like, look at me. DOING THE SAME THING. See, *I* could have + // named my promisified function differently, and yet I did not. I literally + // cannot explain why. We are all used to following in the 8ad decisions of + // our ancestors, and never never never never never never never consider + // that hey, may8e we don't need to make the exact same decisions they did. + // Even when we're perfectly aware th8t's exactly what we're doing! ~~ + // + // 2021 ADDENDUM: Ok, a year and a half later the a8ove is still true, + // except for the part a8out promisifying, since fs/promises + // already does that for us. 8ut I could STILL import it + // using my own name (`readdir as readDirectory`), and yet + // here I am, defin8tely not doing that. + // SOME THINGS NEVER CHANGE. + // + // Programmers, including me, are all pretty stupid. + + // 8ut I mean, come on. Look. Node decided to use readFile, instead of like, + // what, cat? Why couldn't they rename readdir too???????? As Johannes + // Kepler once so elegantly put it: "Shrug." + readFile, + writeFile, + access, + mkdir, + symlink, + unlink +} from 'fs/promises'; + +import genThumbs from './gen-thumbs.js'; +import * as html from './util/html.js'; +import link from './util/link.js'; + +import { + decorateTime, + logWarn, + logInfo, + logError, + parseOptions, + progressPromiseAll +} from './util/cli.js'; + +import { + getLinkThemeString, + getThemeString +} from './util/colors.js'; + +import { + chunkByConditions, + chunkByProperties, + getAllTracks, + getArtistCommentary, + getArtistNumContributions, + getKebabCase, + sortByArtDate, + sortByDate, + sortByName +} from './util/wiki-data.js'; + +import { + call, + filterEmptyLines, + mapInPlace, + queue, + splitArray, + unique, + withEntries +} from './util/sugar.js'; + +import { + generateURLs, + thumb +} from './util/urls.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const CACHEBUST = 5; + +const WIKI_INFO_FILE = 'wiki-info.txt'; +const HOMEPAGE_INFO_FILE = 'homepage.txt'; +const ARTIST_DATA_FILE = 'artists.txt'; +const FLASH_DATA_FILE = 'flashes.txt'; +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 UNRELEASED_TRACKS_DIRECTORY = 'unreleased-tracks'; +const OFFICIAL_GROUP_DIRECTORY = 'official'; +const FANDOM_GROUP_DIRECTORY = 'fandom'; + +// Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted +// site code should 8e put here. Which, uh, ~~only really means this one +// file~~ is now a variety of useful utilities! +// +// Rather than hard code it, anything in this directory can 8e shared across +// 8oth ends of the code8ase. +// (This gets symlinked into the --data directory.) +const UTILITY_DIRECTORY = 'util'; + +// Code that's used only in the static site! CSS, cilent JS, etc. +// (This gets symlinked into the --data directory.) +const STATIC_DIRECTORY = 'static'; + +// Su8directory under provided --data directory for al8um files, which are +// read from and processed to compose the majority of album and track data. +const DATA_ALBUM_DIRECTORY = 'album'; + +// Shared varia8les! These are more efficient to access than a shared varia8le +// (or at least I h8pe so), and are easier to pass across functions than a +// 8unch of specific arguments. +// +// Upd8: Okay yeah these aren't actually any different. Still cleaner than +// passing around a data object containing all this, though. +let dataPath; +let mediaPath; +let langPath; +let outputPath; + +let wikiInfo; +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 toAnythingMan! +let justEverythingSortedByArtDateMan; +let contributionData; + +let queueSize; + +let languages; + +const urlSpec = { + data: { + prefix: 'data/', + + paths: { + root: '', + path: '<>', + + album: 'album/<>', + artist: 'artist/<>', + track: 'track/<>' + } + }, + + localized: { + // TODO: Implement this. + // prefix: '_languageCode', + + paths: { + root: '', + path: '<>', + + home: '', + + 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: { + paths: { + root: '', + path: '<>', + + utilityRoot: 'util', + staticRoot: 'static', + + utilityFile: 'util/<>', + staticFile: 'static/<>' + } + }, + + media: { + prefix: 'media/', + + paths: { + root: '', + path: '<>', + + albumCover: 'album-art/<>/cover.jpg', + albumWallpaper: 'album-art/<>/bg.jpg', + albumBanner: 'album-art/<>/banner.jpg', + trackCover: 'album-art/<>/<>.jpg', + artistAvatar: 'artist-avatar/<>.jpg', + flashArt: 'flash-art/<>.jpg' + } + } +}; + +// This gets automatically switched in place when working from a baseDirectory, +// so it should never be referenced manually. +urlSpec.localizedWithBaseDirectory = { + paths: withEntries( + urlSpec.localized.paths, + entries => entries.map(([key, path]) => [key, '<>/' + path]) + ) +}; + +const urls = generateURLs(urlSpec); + +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] = he.encode(stringsJSON[key], {useNamedReferences: true}); + } + + // 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; + + // Store Intl o8jects that can 8e reused for value formatting. + strings.intl = { + date: new Intl.DateTimeFormat(code, {full: true}), + number: new Intl.NumberFormat(code), + list: { + conjunction: new Intl.ListFormat(code, {type: 'conjunction'}), + disjunction: new Intl.ListFormat(code, {type: 'disjunction'}), + unit: new Intl.ListFormat(code, {type: 'unit'}) + }, + plural: { + cardinal: new Intl.PluralRules(code, {type: 'cardinal'}), + ordinal: new Intl.PluralRules(code, {type: 'ordinal'}) + } + }; + + 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.` + strings.intl.plural.cardinal.select(value) + : `count.${stringKey}`), + {[argName]: strings.intl.number.format(value)}); + +const count = { + date: (date, {strings}) => { + return strings.intl.date.format(date); + }, + + dateRange: ([startDate, endDate], {strings}) => { + return strings.intl.date.formatRange(startDate, endDate); + }, + + 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}) => { + return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value}); + }, + + number: value => strings.intl.number.format(value), + + words: (value, {strings, unit = false}) => { + const num = strings.intl.number.format(value > 1000 + ? Math.floor(value / 100) / 10 + : value); + + const words = (value > 1000 + ? strings('count.words.thousand', {words: num}) + : strings('count.words', {words: num})); + + return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words}); + }, + + albums: countHelper('albums'), + commentaryEntries: countHelper('commentaryEntries', 'entries'), + contributions: countHelper('contributions'), + coverArts: countHelper('coverArts'), + timesReferenced: countHelper('timesReferenced'), + timesUsed: countHelper('timesUsed'), + tracks: countHelper('tracks') +}; + +const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list); + +const list = { + unit: listHelper('unit'), + or: listHelper('disjunction'), + and: listHelper('conjunction') +}; + +// 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 +// have to worry a8out linking track files to al8um files (which would contain +// 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 findFiles(dataPath, filter = f => true) { + return (await readdir(dataPath)) + .map(file => path.join(dataPath, file)) + .filter(file => filter(file)); +} + +function* getSections(lines) { + // ::::) + const isSeparatorLine = line => /^-{8,}$/.test(line); + yield* splitArray(lines, isSeparatorLine); +} + +function getBasicField(lines, name) { + const line = lines.find(line => line.startsWith(name + ':')); + return line && line.slice(name.length + 1).trim(); +} + +function getBooleanField(lines, name) { + // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to + // specify a default! + const value = getBasicField(lines, name); + switch (value) { + case 'yes': + case 'true': + return true; + case 'no': + case 'false': + return false; + default: + return null; + } +} + +function getListField(lines, name) { + let startIndex = lines.findIndex(line => line.startsWith(name + ':')); + // If callers want to default to an empty array, they should stick + // "|| []" after the call. + if (startIndex === -1) { + return null; + } + // We increment startIndex 8ecause we don't want to include the + // "heading" line (e.g. "URLs:") in the actual data. + startIndex++; + let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- ')); + if (endIndex === -1) { + endIndex = lines.length; + } + if (endIndex === startIndex) { + // If there is no list that comes after the heading line, treat the + // heading line itself as the comma-separ8ted array value, using + // the 8asic field function to do that. (It's l8 and my 8rain is + // sleepy. Please excuse any unhelpful comments I may write, or may + // have already written, in this st8. Thanks!) + const value = getBasicField(lines, name); + return value && value.split(',').map(val => val.trim()); + } + const listLines = lines.slice(startIndex, endIndex); + return listLines.map(line => line.slice(2)); +}; + +function getContributionField(section, name) { + let contributors = getListField(section, name); + + if (!contributors) { + return null; + } + + if (contributors.length === 1 && contributors[0].startsWith('<i>')) { + const arr = []; + arr.textContent = contributors[0]; + return arr; + } + + contributors = contributors.map(contrib => { + // 8asically, the format is "Who (What)", or just "Who". 8e sure to + // keep in mind that "what" doesn't necessarily have a value! + const match = contrib.match(/^(.*?)( \((.*)\))?$/); + if (!match) { + return contrib; + } + const who = match[1]; + const what = match[3] || null; + return {who, what}; + }); + + const badContributor = contributors.find(val => typeof val === 'string'); + if (badContributor) { + return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`}; + } + + if (contributors.length === 1 && contributors[0].who === 'none') { + return null; + } + + return contributors; +}; + +function getMultilineField(lines, name) { + // All this code is 8asically the same as the getListText - just with a + // different line prefix (four spaces instead of a dash and a space). + let startIndex = lines.findIndex(line => line.startsWith(name + ':')); + if (startIndex === -1) { + return null; + } + startIndex++; + let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith(' ')); + if (endIndex === -1) { + endIndex = lines.length; + } + // If there aren't any content lines, don't return anything! + if (endIndex === startIndex) { + return null; + } + // We also join the lines instead of returning an array. + const listLines = lines.slice(startIndex, endIndex); + return listLines.map(line => line.slice(4)).join('\n'); +}; + +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' + }, + 'date': { + search: null, + value: ref => new Date(ref), + html: (date, {strings}) => `<time datetime="${date.toString()}">${strings.count.date(date)}</time>` + }, + '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 { + 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, value, html}] of Object.entries(replacerSpec)) { + if (!html && !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, + value: valueFn, + html: htmlFn, + transformName + } = replacerSpec[category]; + + const value = ( + valueFn ? valueFn(ref) : + searchKey ? search[searchKey](ref) : + { + directory: ref.replace(category + ':', ''), + name: null + }); + + if (!value) { + logWarn`The link ${match} does not match anything!`; + return match; + } + + const label = (enteredName + || transformName && transformName(value.name, match, offset, text) + || value.name); + + if (!valueFn && !label) { + logWarn`The link ${match} requires a label be entered!`; + return match; + } + + const fn = (htmlFn + ? htmlFn + : strings.link[linkKey]); + + try { + return fn(value, {text: label, hash, strings, to}); + } catch (error) { + logError`The link ${match} failed to be processed: ${error}`; + return match; + } + }).replaceAll(String.raw`\[[`, '[['); +} + +function parseAttributes(string, {to}) { + const attributes = Object.create(null); + const skipWhitespace = i => { + const ws = /\s/; + if (ws.test(string[i])) { + const match = string.slice(i).match(/[^\s]/); + if (match) { + return i + match.index; + } else { + return string.length; + } + } else { + return i; + } + }; + + for (let i = 0; i < string.length;) { + i = skipWhitespace(i); + const aStart = i; + const aEnd = i + string.slice(i).match(/[\s=]|$/).index; + const attribute = string.slice(aStart, aEnd); + i = skipWhitespace(aEnd); + if (string[i] === '=') { + i = skipWhitespace(i + 1); + let end, endOffset; + if (string[i] === '"' || string[i] === "'") { + end = string[i]; + endOffset = 1; + i++; + } else { + end = '\\s'; + endOffset = 0; + } + const vStart = i; + const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; + const value = string.slice(vStart, vEnd); + i = vEnd + endOffset; + if (attribute === 'src' && value.startsWith('media/')) { + attributes[attribute] = to('media.path', value.slice('media/'.length)); + } else { + attributes[attribute] = value; + } + } else { + attributes[attribute] = attribute; + } + } + return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [ + key, + val === 'true' ? true : + val === 'false' ? false : + val === key ? true : + val + ])); +} + +function transformMultiline(text, {strings, to}) { + // Heck yes, HTML magics. + + text = transformInline(text.trim(), {strings, to}); + + const outLines = []; + + const indentString = ' '.repeat(4); + + let levelIndents = []; + const openLevel = indent => { + // opening a sublist is a pain: to be semantically *and* visually + // correct, we have to append the <ul> at the end of the existing + // previous <li> + const previousLine = outLines[outLines.length - 1]; + if (previousLine?.endsWith('</li>')) { + // we will re-close the <li> later + outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>'; + } else { + // if the previous line isn't a list item, this is the opening of + // the first list level, so no need for indent + outLines.push('<ul>'); + } + levelIndents.push(indent); + }; + const closeLevel = () => { + levelIndents.pop(); + if (levelIndents.length) { + // closing a sublist, so close the list item containing it too + outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>'); + } else { + // closing the final list level! no need for indent here + outLines.push('</ul>'); + } + }; + + // okay yes we should support nested formatting, more than one blockquote + // layer, etc, but hear me out here: making all that work would basically + // be the same as implementing an entire markdown converter, which im not + // interested in doing lol. sorry!!! + let inBlockquote = false; + + for (let line of text.split(/\r|\n|\r\n/)) { + const imageLine = line.startsWith('<img'); + line = line.replace(/<img (.*?)>/g, (match, attributes) => img({ + lazy: true, + link: true, + thumb: 'medium', + ...parseAttributes(attributes, {to}) + })); + + let indentThisLine = 0; + let lineContent = line; + let lineTag = 'p'; + + const listMatch = line.match(/^( *)- *(.*)$/); + if (listMatch) { + // is a list item! + if (!levelIndents.length) { + // first level is always indent = 0, regardless of actual line + // content (this is to avoid going to a lesser indent than the + // initial level) + openLevel(0); + } else { + // find level corresponding to indent + const indent = listMatch[1].length; + let i; + for (i = levelIndents.length - 1; i >= 0; i--) { + if (levelIndents[i] <= indent) break; + } + // note: i cannot equal -1 because the first indentation level + // is always 0, and the minimum indentation is also 0 + if (levelIndents[i] === indent) { + // same indent! return to that level + while (levelIndents.length - 1 > i) closeLevel(); + // (if this is already the current level, the above loop + // will do nothing) + } else if (levelIndents[i] < indent) { + // lesser indent! branch based on index + if (i === levelIndents.length - 1) { + // top level is lesser: add a new level + openLevel(indent); + } else { + // lower level is lesser: return to that level + while (levelIndents.length - 1 > i) closeLevel(); + } + } + } + // finally, set variables for appending content line + indentThisLine = levelIndents.length; + lineContent = listMatch[2]; + lineTag = 'li'; + } else { + // not a list item! close any existing list levels + while (levelIndents.length) closeLevel(); + + // like i said, no nested shenanigans - quotes only appear outside + // of lists. sorry! + const quoteMatch = line.match(/^> *(.*)$/); + if (quoteMatch) { + // is a quote! open a blockquote tag if it doesnt already exist + if (!inBlockquote) { + inBlockquote = true; + outLines.push('<blockquote>'); + } + indentThisLine = 1; + lineContent = quoteMatch[1]; + } else if (inBlockquote) { + // not a quote! close a blockquote tag if it exists + inBlockquote = false; + outLines.push('</blockquote>'); + } + } + + if (lineTag === 'p') { + // certain inline element tags should still be postioned within a + // paragraph; other elements (e.g. headings) should be added as-is + const elementMatch = line.match(/^<(.*?)[ >]/); + if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) { + lineTag = ''; + } + } + + let pushString = indentString.repeat(indentThisLine); + if (lineTag) { + pushString += `<${lineTag}>${lineContent}</${lineTag}>`; + } else { + pushString += lineContent; + } + outLines.push(pushString); + } + + // after processing all lines... + + // if still in a list, close all levels + while (levelIndents.length) closeLevel(); + + // if still in a blockquote, close its tag + if (inBlockquote) { + inBlockquote = false; + outLines.push('</blockquote>'); + } + + return outLines.join('\n'); +} + +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). + + // If it looks like old data, then like, oh god. + // Use the normal transformMultiline tool. + if (text.includes('<br')) { + return transformMultiline(text, {strings, to}); + } + + text = transformInline(text.trim(), {strings, to}); + + let buildLine = ''; + const addLine = () => outLines.push(`<p>${buildLine}</p>`); + const outLines = []; + for (const line of text.split('\n')) { + if (line.length) { + if (buildLine.length) { + buildLine += '<br>'; + } + buildLine += line; + } else if (buildLine.length) { + addLine(); + buildLine = ''; + } + } + if (buildLine.length) { + addLine(); + } + return outLines.join('\n'); +} + +function getCommentaryField(lines) { + const text = getMultilineField(lines, 'Commentary'); + if (text) { + const lines = text.split('\n'); + if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { + return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`}; + } + return text; + } else { + return null; + } +}; + +async function processAlbumDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + // This function can return "error o8jects," which are really just + // ordinary o8jects with an error message attached. I'm not 8othering + // with error codes here or anywhere in this function; while this would + // normally 8e 8ad coding practice, it doesn't really matter here, + // 8ecause this isn't an API getting consumed 8y other services (e.g. + // translaction functions). If we return an error, the caller will just + // print the attached message in the output summary. + return {error: `Could not read ${file} (${error.code}).`}; + } + + // We're pro8a8ly supposed to, like, search for a header somewhere in the + // al8um contents, to make sure it's trying to 8e the intended structure + // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever. + // We'll just return more specific errors if it's missing necessary data + // fields. + + const contentLines = contents.split('\n'); + + // In this line of code I defeat the purpose of using a generator in the + // first place. Sorry!!!!!!!! + const sections = Array.from(getSections(contentLines)); + + const albumSection = sections[0]; + const album = {}; + + album.name = getBasicField(albumSection, 'Album'); + album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist'); + album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art'); + album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style'); + album.bannerArtists = getContributionField(albumSection, 'Banner Art'); + album.bannerStyle = getMultilineField(albumSection, 'Banner Style'); + album.date = getBasicField(albumSection, 'Date'); + album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date; + album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date; + album.dateAdded = getBasicField(albumSection, 'Date Added'); + album.coverArtists = getContributionField(albumSection, 'Cover Art'); + album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true; + album.trackCoverArtists = getContributionField(albumSection, 'Track Art'); + album.artTags = getListField(albumSection, 'Art Tags') || []; + album.commentary = getCommentaryField(albumSection); + album.urls = getListField(albumSection, 'URLs') || []; + album.groups = getListField(albumSection, 'Groups') || []; + album.directory = getBasicField(albumSection, 'Directory'); + album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false; + + if (album.artists && album.artists.error) { + return {error: `${album.artists.error} (in ${album.name})`}; + } + + if (album.coverArtists && album.coverArtists.error) { + return {error: `${album.coverArtists.error} (in ${album.name})`}; + } + + if (album.commentary && album.commentary.error) { + return {error: `${album.commentary.error} (in ${album.name})`}; + } + + if (album.trackCoverArtists && album.trackCoverArtists.error) { + return {error: `${album.trackCoverArtists.error} (in ${album.name})`}; + } + + if (!album.coverArtists) { + return {error: `The album "${album.name}" is missing the "Cover Art" field.`}; + } + + album.color = ( + getBasicField(albumSection, 'Color') || + getBasicField(albumSection, 'FG') + ); + + if (!album.name) { + return {error: `Expected "Album" (name) field!`}; + } + + if (!album.date) { + return {error: `Expected "Date" field! (in ${album.name})`}; + } + + if (!album.dateAdded) { + return {error: `Expected "Date Added" field! (in ${album.name})`}; + } + + if (isNaN(Date.parse(album.date))) { + return {error: `Invalid Date field: "${album.date}" (in ${album.name})`}; + } + + if (isNaN(Date.parse(album.trackArtDate))) { + return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`}; + } + + if (isNaN(Date.parse(album.coverArtDate))) { + return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`}; + } + + if (isNaN(Date.parse(album.dateAdded))) { + return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`}; + } + + album.date = new Date(album.date); + album.trackArtDate = new Date(album.trackArtDate); + album.coverArtDate = new Date(album.coverArtDate); + album.dateAdded = new Date(album.dateAdded); + + if (!album.directory) { + album.directory = getKebabCase(album.name); + } + + album.tracks = []; + + // will be overwritten if a group section is found! + album.trackGroups = null; + + let group = null; + let trackIndex = 0; + + for (const section of sections.slice(1)) { + // Just skip empty sections. Sometimes I paste a 8unch of dividers, + // and this lets the empty sections doing that creates (temporarily) + // exist without raising an error. + if (!section.filter(Boolean).length) { + continue; + } + + const groupName = getBasicField(section, 'Group'); + if (groupName) { + 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'); + track.commentary = getCommentaryField(section); + track.lyrics = getMultilineField(section, 'Lyrics'); + track.originalDate = getBasicField(section, 'Original Date'); + track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate; + track.references = getListField(section, 'References') || []; + track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist'); + track.coverArtists = getContributionField(section, 'Track Art'); + track.artTags = getListField(section, 'Art Tags') || []; + track.contributors = getContributionField(section, 'Contributors') || []; + track.directory = getBasicField(section, 'Directory'); + track.aka = getBasicField(section, 'AKA'); + + if (!track.name) { + return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`}; + } + + let durationString = getBasicField(section, 'Duration') || '0:00'; + track.duration = getDurationInSeconds(durationString); + + if (track.contributors.error) { + return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`}; + } + + if (track.commentary && track.commentary.error) { + return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`}; + } + + if (!track.artists) { + // If an al8um has an artist specified (usually 8ecause it's a solo + // al8um), let tracks inherit that artist. We won't display the + // "8y <artist>" string on the al8um listing. + if (album.artists) { + track.artists = album.artists; + } else { + return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`}; + } + } + + if (!track.coverArtists) { + if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) { + if (album.trackCoverArtists) { + track.coverArtists = album.trackCoverArtists; + } else { + return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`}; + } + } + } + + if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') { + track.coverArtists = null; + } + + if (!track.directory) { + track.directory = getKebabCase(track.name); + } + + if (track.originalDate) { + if (isNaN(Date.parse(track.originalDate))) { + return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`}; + } + track.date = new Date(track.originalDate); + } else { + track.date = album.date; + } + + track.coverArtDate = new Date(track.coverArtDate); + + const hasURLs = getBooleanField(section, 'Has URLs') ?? true; + + track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean); + + if (hasURLs && !track.urls.length) { + return {error: `The track "${track.name}" should have at least one URL specified.`}; + } + + // 8ack-reference the al8um o8ject! This is very useful for when + // we're outputting the track pages. + track.album = album; + + if (group) { + track.color = group.color; + group.tracks.push(track); + } else { + track.color = album.color; + } + + album.tracks.push(track); + } + + return album; +} + +async function processArtistDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + return sections.filter(s => s.filter(Boolean).length).map(section => { + const name = getBasicField(section, 'Artist'); + const urls = (getListField(section, 'URLs') || []).filter(Boolean); + const alias = getBasicField(section, 'Alias'); + const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false; + const note = getMultilineField(section, 'Note'); + let directory = getBasicField(section, 'Directory'); + + if (!name) { + return {error: 'Expected "Artist" (name) field!'}; + } + + if (!directory) { + directory = getKebabCase(name); + } + + if (alias) { + return {name, directory, alias}; + } else { + return {name, directory, urls, note, hasAvatar}; + } + }); +} + +async function processFlashDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + let act, color; + return sections.map(section => { + if (getBasicField(section, 'ACT')) { + act = getBasicField(section, 'ACT'); + color = ( + getBasicField(section, 'Color') || + getBasicField(section, 'FG') + ); + const anchor = getBasicField(section, 'Anchor'); + const jump = getBasicField(section, 'Jump'); + const jumpColor = getBasicField(section, 'Jump Color') || color; + return {act8r8k: true, name: act, color, anchor, jump, jumpColor}; + } + + const name = getBasicField(section, 'Flash'); + let page = getBasicField(section, 'Page'); + let directory = getBasicField(section, 'Directory'); + let date = getBasicField(section, 'Date'); + const jiff = getBasicField(section, 'Jiff'); + const tracks = getListField(section, 'Tracks') || []; + const contributors = getContributionField(section, 'Contributors') || []; + const urls = (getListField(section, 'URLs') || []).filter(Boolean); + + if (!name) { + return {error: 'Expected "Flash" (name) field!'}; + } + + if (!page && !directory) { + return {error: 'Expected "Page" or "Directory" field!'}; + } + + if (!directory) { + directory = page; + } + + if (!date) { + return {error: 'Expected "Date" field!'}; + } + + if (isNaN(Date.parse(date))) { + return {error: `Invalid Date field: "${date}"`}; + } + + date = new Date(date); + + return {name, page, directory, date, contributors, tracks, urls, act, color, jiff}; + }); +} + +async function processNewsDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + return sections.map(section => { + const name = getBasicField(section, 'Name'); + if (!name) { + return {error: 'Expected "Name" field!'}; + } + + const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID'); + if (!directory) { + return {error: 'Expected "Directory" field!'}; + } + + let body = getMultilineField(section, 'Body'); + if (!body) { + return {error: 'Expected "Body" field!'}; + } + + let date = getBasicField(section, 'Date'); + if (!date) { + return {error: 'Expected "Date" field!'}; + } + + if (isNaN(Date.parse(date))) { + return {error: `Invalid date field: "${date}"`}; + } + + date = new Date(date); + + let bodyShort = body.split('<hr class="split">')[0]; + + return { + name, + directory, + body, + bodyShort, + date + }; + }); +} + +async function processTagDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } else { + return {error: `Could not read ${file} (${error.code}).`}; + } + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + return sections.map(section => { + let isCW = false; + + let name = getBasicField(section, 'Tag'); + if (!name) { + name = getBasicField(section, 'CW'); + isCW = true; + if (!name) { + return {error: 'Expected "Tag" or "CW" field!'}; + } + } + + let color; + if (!isCW) { + color = getBasicField(section, 'Color'); + if (!color) { + return {error: 'Expected "Color" field!'}; + } + } + + const directory = getKebabCase(name); + + return { + name, + directory, + isCW, + color + }; + }); +} + +async function processGroupDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } else { + return {error: `Could not read ${file} (${error.code}).`}; + } + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + let category, color; + return sections.map(section => { + if (getBasicField(section, 'Category')) { + category = getBasicField(section, 'Category'); + color = getBasicField(section, 'Color'); + return {isCategory: true, name: category, color}; + } + + const name = getBasicField(section, 'Group'); + if (!name) { + return {error: 'Expected "Group" field!'}; + } + + let directory = getBasicField(section, 'Directory'); + if (!directory) { + directory = getKebabCase(name); + } + + let description = getMultilineField(section, 'Description'); + if (!description) { + return {error: 'Expected "Description" field!'}; + } + + let descriptionShort = description.split('<hr class="split">')[0]; + + const urls = (getListField(section, 'URLs') || []).filter(Boolean); + + return { + isGroup: true, + name, + directory, + description, + descriptionShort, + urls, + category, + color + }; + }); +} + +async function processStaticPageDataFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } else { + return {error: `Could not read ${file} (${error.code}).`}; + } + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + return sections.map(section => { + const name = getBasicField(section, 'Name'); + if (!name) { + return {error: 'Expected "Name" field!'}; + } + + const shortName = getBasicField(section, 'Short Name') || name; + + let directory = getBasicField(section, 'Directory'); + if (!directory) { + return {error: 'Expected "Directory" field!'}; + } + + let content = getMultilineField(section, 'Content'); + if (!content) { + return {error: 'Expected "Content" field!'}; + } + + let stylesheet = getMultilineField(section, 'Style') || ''; + + let listed = getBooleanField(section, 'Listed') ?? true; + + return { + name, + shortName, + directory, + content, + stylesheet, + listed + }; + }); +} + +async function processWikiInfoFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; + } + + // Unlike other data files, the site info data file isn't 8roken up into + // more than one entry. So we operate on the plain old contentLines array, + // rather than dividing into sections like we usually do! + const contentLines = contents.split('\n'); + + const name = getBasicField(contentLines, 'Name'); + if (!name) { + return {error: 'Expected "Name" field!'}; + } + + const shortName = getBasicField(contentLines, 'Short Name') || name; + + const color = getBasicField(contentLines, 'Color') || '#0088ff'; + + // This is optional! Without it, <meta rel="canonical"> tags won't 8e + // 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'); + + const footer = getMultilineField(contentLines, 'Footer') || ''; + + // We've had a comment lying around for ages, just reading: + // "Might ena8le this later... we'll see! Eventually. May8e." + // We still haven't! 8ut hey, the option's here. + const enableArtistAvatars = getBooleanField(contentLines, 'Enable Artist Avatars') ?? false; + + const enableFlashesAndGames = getBooleanField(contentLines, 'Enable Flashes & Games') ?? false; + const enableListings = getBooleanField(contentLines, 'Enable Listings') ?? false; + const enableNews = getBooleanField(contentLines, 'Enable News') ?? false; + const enableArtTagUI = getBooleanField(contentLines, 'Enable Art Tag UI') ?? false; + const enableGroupUI = getBooleanField(contentLines, 'Enable Group UI') ?? false; + + return { + name, + shortName, + color, + canonicalBase, + defaultLanguage, + description, + footer, + features: { + artistAvatars: enableArtistAvatars, + flashesAndGames: enableFlashesAndGames, + listings: enableListings, + news: enableNews, + artTagUI: enableArtTagUI, + groupUI: enableGroupUI + } + }; +} + +async function processHomepageInfoFile(file) { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (error) { + return {error: `Could not read ${file} (${error.code}).`}; + } + + const contentLines = contents.split('\n'); + const sections = Array.from(getSections(contentLines)); + + const [ firstSection, ...rowSections ] = sections; + + const sidebar = getMultilineField(firstSection, 'Sidebar'); + + const validRowTypes = ['albums']; + + const rows = rowSections.map(section => { + const name = getBasicField(section, 'Row'); + if (!name) { + return {error: 'Expected "Row" (name) field!'}; + } + + const color = getBasicField(section, 'Color'); + + const type = getBasicField(section, 'Type'); + if (!type) { + return {error: 'Expected "Type" field!'}; + } + + if (!validRowTypes.includes(type)) { + return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`}; + } + + const row = {name, color, type}; + + switch (type) { + case 'albums': { + const group = getBasicField(section, 'Group') || null; + const albums = getListField(section, 'Albums') || []; + + if (!group && !albums) { + return {error: 'Expected "Group" and/or "Albums" field!'}; + } + + let groupCount = getBasicField(section, 'Count'); + if (group && !groupCount) { + return {error: 'Expected "Count" field!'}; + } + + if (groupCount) { + if (isNaN(parseInt(groupCount))) { + return {error: `Invalid Count field: "${groupCount}"`}; + } + + groupCount = parseInt(groupCount); + } + + const actions = getListField(section, 'Actions') || []; + + return {...row, group, groupCount, albums, actions}; + } + } + }); + + return {sidebar, rows}; +} + +function getDurationInSeconds(string) { + const parts = string.split(':').map(n => parseInt(n)) + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2] + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1] + } else { + return 0 + } +} + +function getTotalDuration(tracks) { + return tracks.reduce((duration, track) => duration + track.duration, 0); +} + +const stringifyIndent = 0; + +const toRefs = (label, objectOrArray) => { + if (Array.isArray(objectOrArray)) { + return objectOrArray.filter(Boolean).map(x => `${label}:${x.directory}`); + } else if (objectOrArray.directory) { + throw new Error('toRefs should not be passed a single object with directory'); + } else if (typeof objectOrArray === 'object') { + return Object.fromEntries(Object.entries(objectOrArray) + .map(([ key, value ]) => [key, toRefs(key, value)])); + } else { + throw new Error('toRefs should be passed an array or object of arrays'); + } +}; + +function stringifyRefs(key, value) { + switch (key) { + case 'tracks': + case 'references': + case 'referencedBy': + return toRefs('track', value); + case 'artists': + case 'contributors': + case 'coverArtists': + case 'trackCoverArtists': + return value && value.map(({ who, what }) => ({who: `artist:${who.directory}`, what})); + case 'albums': return toRefs('album', value); + case 'flashes': return toRefs('flash', value); + case 'groups': return toRefs('group', value); + case 'artTags': return toRefs('tag', value); + case 'aka': return value && `track:${value.directory}`; + default: + return value; + } +} + +function stringifyAlbumData() { + return JSON.stringify(albumData, (key, value) => { + switch (key) { + case 'commentary': + return ''; + default: + return stringifyRefs(key, value); + } + }, stringifyIndent); +} + +function stringifyTrackData() { + return JSON.stringify(trackData, (key, value) => { + switch (key) { + case 'album': + case 'commentary': + case 'otherReleases': + return undefined; + default: + return stringifyRefs(key, value); + } + }, stringifyIndent); +} + +function stringifyFlashData() { + return JSON.stringify(flashData, (key, value) => { + switch (key) { + case 'act': + case 'commentary': + return undefined; + default: + return stringifyRefs(key, value); + } + }, stringifyIndent); +} + +function stringifyArtistData() { + return JSON.stringify(artistData, (key, value) => { + switch (key) { + case 'asAny': + return; + case 'asArtist': + case 'asContributor': + case 'asCoverArtist': + return toRefs('track', value); + default: + return stringifyRefs(key, value); + } + }, stringifyIndent); +} + +function img({ + src = '', + alt = '', + thumb: thumbKey = '', + reveal = '', + id = '', + class: className = '', + width = '', + height = '', + link = false, + lazy = false, + square = false +}) { + const willSquare = square; + const willLink = typeof link === 'string' || link; + + const originalSrc = src; + const thumbSrc = thumbKey ? thumb[thumbKey](src) : src; + + const imgAttributes = html.attributes({ + id: link ? '' : id, + class: className, + alt, + width, + height + }); + + const nonlazyHTML = wrap(`<img src="${thumbSrc}" ${imgAttributes}>`); + const lazyHTML = lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true); + + if (lazy) { + return fixWS` + <noscript>${nonlazyHTML}</noscript> + ${lazyHTML} + `; + } else { + return nonlazyHTML; + } + + function wrap(input, hide = false) { + let wrapped = input; + + wrapped = `<div class="image-inner-area">${wrapped}</div>`; + wrapped = `<div class="image-container">${wrapped}</div>`; + + if (reveal) { + wrapped = fixWS` + <div class="reveal"> + ${wrapped} + <span class="reveal-text">${reveal}</span> + </div> + `; + } + + if (willSquare) { + wrapped = html.tag('div', {class: 'square-content'}, wrapped); + wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped); + } + + if (willLink) { + wrapped = html.tag('a', { + id, + class: ['box', hide && 'js-hide'], + href: typeof link === 'string' ? link : originalSrc + }, wrapped); + } + + return wrapped; + } +} + +function serializeImagePaths(original) { + return { + original, + medium: thumb.medium(original), + small: thumb.small(original) + }; +} + +function serializeLink(thing) { + const ret = {}; + ret.name = thing.name; + ret.directory = thing.directory; + if (thing.color) ret.color = thing.color; + return ret; +} + +function serializeContribs(contribs) { + return contribs.map(({ who, what }) => { + const ret = {}; + ret.artist = serializeLink(who); + if (what) ret.contribution = what; + return ret; + }); +} + +function serializeCover(thing, pathFunction) { + const coverPath = pathFunction(thing, { + to: urls.from('media.root').to + }); + + const { artTags } = thing; + + const cwTags = artTags.filter(tag => tag.isCW); + const linkTags = artTags.filter(tag => !tag.isCW); + + return { + paths: serializeImagePaths(coverPath), + tags: linkTags.map(serializeLink), + warnings: cwTags.map(tag => tag.name) + }; +} + +function serializeGroupsForAlbum(album) { + return album.groups.map(group => { + const index = group.albums.indexOf(album); + const next = group.albums[index + 1] || null; + const previous = group.albums[index - 1] || null; + return {group, index, next, previous}; + }).map(({group, index, next, previous}) => ({ + link: serializeLink(group), + descriptionShort: group.descriptionShort, + albumIndex: index, + nextAlbum: next && serializeLink(next), + previousAlbum: previous && serializeLink(previous), + urls: group.urls + })); +} + +function serializeGroupsForTrack(track) { + return track.album.groups.map(group => ({ + link: serializeLink(group), + urls: group.urls, + })); +} + +function validateWritePath(path, urlGroup) { + if (!Array.isArray(path)) { + return {error: `Expected array, got ${path}`}; + } + + const { paths } = urlGroup; + + const definedKeys = Object.keys(paths); + const specifiedKey = path[0]; + + if (!definedKeys.includes(specifiedKey)) { + return {error: `Specified key ${specifiedKey} isn't defined`}; + } + + const expectedArgs = paths[specifiedKey].match(/<>/g).length; + const specifiedArgs = path.length - 1; + + if (specifiedArgs !== expectedArgs) { + return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`}; + } + + return {success: true}; +} + +function validateWriteObject(obj) { + if (typeof obj !== 'object') { + return {error: `Expected object, got ${typeof obj}`}; + } + + if (typeof obj.type !== 'string') { + return {error: `Expected type to be string, got ${obj.type}`}; + } + + switch (obj.type) { + case 'legacy': { + if (typeof obj.write !== 'function') { + return {error: `Expected write to be string, got ${obj.write}`}; + } + + break; + } + + case 'page': { + const path = validateWritePath(obj.path, urlSpec.localized); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } + + if (typeof obj.page !== 'function') { + return {error: `Expected page to be function, got ${obj.content}`}; + } + + break; + } + + case 'data': { + const path = validateWritePath(obj.path, urlSpec.data); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } + + if (typeof obj.data !== 'function') { + return {error: `Expected data to be function, got ${obj.data}`}; + } + + break; + } + + default: { + return {error: `Unknown type: ${obj.type}`}; + } + } + + return {success: true}; +} + +async function writeData(subKey, directory, data) { + const paths = writePage.paths('', 'data.' + subKey, directory, {file: 'data.json'}); + await writePage.write(JSON.stringify(data), {paths}); +} + +async function writePage(strings, baseDirectory, pageSubKey, 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, 'localized.' + pageSubKey, directory); + + const to = (targetFullKey, ...args) => { + const [ groupKey, subKey ] = targetFullKey.split('.')[0]; + let path = paths.subdirectoryPrefix + // When linking to *outside* the localized area of the site, we need to + // make sure the result is correctly relative to the 8ase directory. + if (groupKey !== 'localized' && baseDirectory) { + path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args); + } else { + // If we're linking inside the localized area (or there just is no + // 8ase directory), the 8ase directory doesn't matter. + path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args); + } + // console.log(pageSubKey, '->', targetFullKey, '=', path); + return path; + }; + + 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 = {}, + banner = {}, + main = {}, + sidebarLeft = {}, + sidebarRight = {}, + nav = {}, + footer = {} + } = pageFn({to}); + + body.style ??= ''; + + theme = theme || getThemeString(wikiInfo.color); + + banner ||= {}; + banner.classes ??= []; + banner.src ??= ''; + banner.position ??= ''; + + main.classes ??= []; + main.content ??= ''; + + sidebarLeft ??= {}; + sidebarRight ??= {}; + + for (const sidebar of [sidebarLeft, sidebarRight]) { + sidebar.classes ??= []; + sidebar.content ??= ''; + sidebar.collapse ??= true; + } + + nav.classes ??= []; + nav.content ??= ''; + nav.links ??= []; + + footer.classes ??= []; + footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer, {strings, to}) : ''); + + const canonical = (wikiInfo.canonicalBase + ? wikiInfo.canonicalBase + paths.pathname + : ''); + + const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false); + + const mainHTML = main.content && fixWS` + <main id="content" ${classes(...main.classes || [])}> + ${main.content} + </main> + `; + + const footerHTML = footer.content && fixWS` + <footer id="footer" ${classes(...footer.classes || [])}> + ${footer.content} + </footer> + `; + + const generateSidebarHTML = (id, { + content, + multiple, + classes: sidebarClasses = [], + collapse = true, + wide = false + }) => (content ? fixWS` + <div id="${id}" ${classes( + 'sidebar-column', + 'sidebar', + wide && 'wide', + !collapse && 'no-hide', + ...sidebarClasses + )}> + ${content} + </div> + ` : multiple ? fixWS` + <div id="${id}" ${classes( + 'sidebar-column', + 'sidebar-multiple', + wide && 'wide', + !collapse && 'no-hide' + )}> + ${multiple.map(content => fixWS` + <div ${classes( + 'sidebar', + ...sidebarClasses + )}> + ${content} + </div> + `).join('\n')} + </div> + ` : ''); + + const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft); + const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight); + + if (nav.simple) { + nav.links = [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: '', + title + } + ]; + } + + const links = (nav.links || []).filter(Boolean); + + const navLinkParts = []; + for (let i = 0; i < links.length; i++) { + const link = links[i]; + const prev = links[i - 1]; + const next = links[i + 1]; + 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 navHTML = html.tag('nav', { + [html.onlyIfContent]: true, + id: 'header', + class: nav.classes + }, [ + links.length && html.tag('h2', {class: 'highlight-last-link'}, navLinkParts), + nav.content + ]); + + const bannerHTML = banner.position && banner.src && html.tag('div', + { + id: 'banner', + class: banner.classes + }, + html.tag('img', { + src: banner.src, + alt: banner.alt, + width: 1100, + height: 200 + }) + ); + + const layoutHTML = [ + navHTML, + banner.position === 'top' && bannerHTML, + (sidebarLeftHTML || sidebarRightHTML) ? fixWS` + <div ${classes('layout-columns', !collapseSidebars && 'vertical-when-thin')}> + ${sidebarLeftHTML} + ${mainHTML} + ${sidebarRightHTML} + </div> + ` : mainHTML, + banner.position === 'bottom' && bannerHTML, + footerHTML + ].filter(Boolean).join('\n'); + + const infoCardHTML = fixWS` + <div id="info-card-container"> + <div class="info-card-decor"> + <div class="info-card"> + <div class="info-card-art-container no-reveal"> + ${img({ + class: 'info-card-art', + src: '', + link: true, + square: true + })} + </div> + <div class="info-card-art-container reveal"> + ${img({ + class: 'info-card-art', + src: '', + link: true, + square: true, + reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {strings}) + })} + </div> + <h1 class="info-card-name"><a></a></h1> + <p class="info-card-album">${strings('releaseInfo.from', {album: '<a></a>'})}</p> + <p class="info-card-artists">${strings('releaseInfo.by', {artists: '<span></span>'})}</p> + <p class="info-card-cover-artists">${strings('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p> + </div> + </div> + </div> + `; + + return filterEmptyLines(fixWS` + <!DOCTYPE html> + <html ${html.attributes({ + lang: strings.code, + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.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}="${html.escapeAttributeValue(value)}">`).join('\n')} + ${canonical && `<link rel="canonical" href="${canonical}">`} + <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}"> + ${(theme || stylesheet) && fixWS` + <style> + ${theme} + ${stylesheet} + </style> + `} + <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script> + </head> + <body ${html.attributes({style: body.style || ''})}> + <div id="page-container"> + ${mainHTML && fixWS` + <div id="skippers"> + ${[ + ['#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> + ${infoCardHTML} + <script type="module" src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script> + </body> + </html> + `); +}; + +writePage.write = async (content, {paths}) => { + await mkdir(paths.outputDirectory, {recursive: true}); + await writeFile(paths.outputFile, content); +}; + +// TODO: This only supports one <>-style argument. +writePage.paths = (baseDirectory, fullKey, directory, { + file = 'index.html' +} = {}) => { + const [ groupKey, subKey ] = fullKey.split('.'); + + const pathname = (groupKey === 'localized' && baseDirectory + ? urls.from('shared.root').to('localizedWithBaseDirectory.' + subKey, baseDirectory, directory) + : urls.from('shared.root').to(fullKey, directory)); + + // Needed for the rare directory which itself contains a slash, e.g. for + // listings, with directories like 'albums/by-name'. + const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1); + + const outputDirectory = path.join(outputPath, pathname); + const outputFile = path.join(outputDirectory, file); + + return { + pathname, + subdirectoryPrefix, + outputDirectory, outputFile + }; +}; + +function getGridHTML({ + strings, + entries, + srcFn, + hrefFn, + altFn = () => '', + detailsFn = null, + lazy = true +}) { + return entries.map(({ large, item }, i) => fixWS` + <a ${classes('grid-item', 'box', large && 'large-grid-item')} href="${hrefFn(item)}" style="${getLinkThemeString(item.color)}"> + ${img({ + src: srcFn(item), + alt: altFn(item), + thumb: 'small', + lazy: (typeof lazy === 'number' ? i >= lazy : lazy), + square: true, + reveal: getRevealStringFromTags(item.artTags, {strings}) + })} + <span>${item.name}</span> + ${detailsFn && `<span>${detailsFn(item)}</span>`} + </a> + `).join('\n'); +} + +function getAlbumGridHTML({strings, to, details = false, ...props}) { + return getGridHTML({ + strings, + srcFn: album => getAlbumCover(album, {to}), + hrefFn: album => to('localized.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 getFlashGridHTML({strings, to, ...props}) { + return getGridHTML({ + strings, + srcFn: flash => to('media.flashArt', flash.directory), + hrefFn: flash => to('localized.flash', flash.directory), + ...props + }); +} + +function getNewReleases(numReleases) { + const latestFirst = albumData.slice().reverse(); + const majorReleases = latestFirst.filter(album => album.isMajorRelease); + majorReleases.splice(1); + + const otherReleases = latestFirst + .filter(album => !majorReleases.includes(album)) + .slice(0, numReleases - majorReleases.length); + + return [ + ...majorReleases.map(album => ({large: true, item: album})), + ...otherReleases.map(album => ({large: false, item: album})) + ]; +} + +function getNewAdditions(numAlbums) { + // Sort al8ums, in descending order of priority, 8y... + // + // * D8te of addition to the wiki (descending). + // * Major releases first. + // * D8te of release (descending). + // + // Major releases go first to 8etter ensure they show up in the list (and + // are usually at the start of the final output for a given d8 of release + // too). + const sortedAlbums = albumData.slice().sort((a, b) => { + if (a.dateAdded > b.dateAdded) return -1; + if (a.dateAdded < b.dateAdded) return 1; + if (a.isMajorRelease && !b.isMajorRelease) return -1; + if (!a.isMajorRelease && b.isMajorRelease) return 1; + if (a.date > b.date) return -1; + if (a.date < b.date) return 1; + }); + + // When multiple al8ums are added to the wiki at a time, we want to show + // all of them 8efore pulling al8ums from the next (earlier) date. We also + // want to show a diverse selection of al8ums - with limited space, we'd + // rather not show only the latest al8ums, if those happen to all 8e + // closely rel8ted! + // + // Specifically, we're concerned with avoiding too much overlap amongst + // the primary (first/top-most) group. We do this 8y collecting every + // primary group present amongst the al8ums for a given d8 into one + // (ordered) array, initially sorted (inherently) 8y latest al8um from + // the group. Then we cycle over the array, adding one al8um from each + // group until all the al8ums from that release d8 have 8een added (or + // we've met the total target num8er of al8ums). Once we've added all the + // al8ums for a given group, it's struck from the array (so the groups + // with the most additions on one d8 will have their oldest releases + // collected more towards the end of the list). + + const albums = []; + + let i = 0; + outerLoop: while (i < sortedAlbums.length) { + // 8uild up a list of groups and their al8ums 8y order of decending + // release, iter8ting until we're on a different d8. (We use a map for + // indexing so we don't have to iter8te through the entire array each + // time we access one of its entries. This is 8asically unnecessary + // since this will never 8e an expensive enough task for that to + // matter.... 8ut it's nicer code. BBBB) ) + const currentDate = sortedAlbums[i].dateAdded; + const groupMap = new Map(); + const groupArray = []; + for (let album; (album = sortedAlbums[i]) && +album.dateAdded === +currentDate; i++) { + const primaryGroup = album.groups[0]; + if (groupMap.has(primaryGroup)) { + groupMap.get(primaryGroup).push(album); + } else { + const entry = [album] + groupMap.set(primaryGroup, entry); + groupArray.push(entry); + } + } + + // Then cycle over that sorted array, adding one al8um from each to + // the main array until we've run out or have met the target num8er + // of al8ums. + while (groupArray.length) { + let j = 0; + while (j < groupArray.length) { + const entry = groupArray[j]; + const album = entry.shift(); + albums.push(album); + + + // This is the only time we ever add anything to the main al8um + // list, so it's also the only place we need to check if we've + // met the target length. + if (albums.length === numAlbums) { + // If we've met it, 8r8k out of the outer loop - we're done + // here! + break outerLoop; + } + + if (entry.length) { + j++; + } else { + groupArray.splice(j, 1); + } + } + } + } + + // Finally, do some quick mapping shenanigans to 8etter display the result + // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut + // whatevs.) + return albums.map(album => ({large: album.isMajorRelease, item: album})); +} + +function writeSymlinks() { + return progressPromiseAll('Writing site symlinks.', [ + link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'), + link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'), + link(mediaPath, 'media.root') + ]); + + async function link(directory, urlKey) { + const pathname = urls.from('shared.root').to(urlKey); + const file = path.join(outputPath, pathname); + try { + await unlink(file); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + await symlink(path.resolve(directory), file); + } +} + +function writeSharedFilesAndPages({strings}) { + const redirect = async (title, from, urlKey, directory) => { + const target = path.relative(from, urls.from('shared.root').to(urlKey, directory)); + const content = generateRedirectPage(title, target, {strings}); + await mkdir(path.join(outputPath, from), {recursive: true}); + await writeFile(path.join(outputPath, from, 'index.html'), content); + }; + + return progressPromiseAll(`Writing files & pages shared across languages.`, [ + groupData?.some(group => group.directory === 'fandom') && + redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'), + + groupData?.some(group => group.directory === 'official') && + redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'), + + wikiInfo.features.listings && + redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''), + + writeFile(path.join(outputPath, 'data.json'), fixWS` + { + "albumData": ${stringifyAlbumData()}, + ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData()},`} + "artistData": ${stringifyArtistData()} + } + `) + ].filter(Boolean)); +} + +function writeHomepage() { + return ({strings, writePage}) => writePage('home', '', ({to}) => ({ + title: wikiInfo.name, + + meta: { + description: wikiInfo.description + }, + + main: { + classes: ['top-index'], + content: fixWS` + <h1>${wikiInfo.name}</h1> + ${homepageInfo.rows.map((row, i) => fixWS` + <section class="row" style="${getLinkThemeString(row.color)}"> + <h2>${row.name}</h2> + ${row.type === 'albums' && fixWS` + <div class="grid-listing"> + ${getAlbumGridHTML({ + strings, to, + entries: ( + row.group === 'new-releases' ? getNewReleases(row.groupCount) : + row.group === 'new-additions' ? getNewAdditions(row.groupCount) : + ((search.group(row.group)?.albums || []) + .slice() + .reverse() + .slice(0, row.groupCount) + .map(album => ({item: album}))) + ).concat(row.albums + .map(search.album) + .map(album => ({item: album})) + ), + lazy: i > 0 + })} + ${row.actions.length && fixWS` + <div class="grid-actions"> + ${row.actions.map(action => transformInline(action, {strings, to}) + .replace('<a', '<a class="box grid-item"')).join('\n')} + </div> + `} + </div> + `} + </section> + `).join('\n')} + ` + }, + + sidebarLeft: homepageInfo.sidebar && { + wide: true, + collapse: false, + // This is a pretty filthy hack! 8ut otherwise, the [[news]] part + // gets treated like it's a reference to the track named "news", + // which o8viously isn't what we're going for. Gotta catch that + // 8efore we pass it to transformMultiline, 'cuz otherwise it'll + // get repl8ced with just the word "news" (or anything else that + // transformMultiline does with references it can't match) -- and + // we can't match that for replacing it with the news column! + // + // 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__'), {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>${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>`) + }, + + nav: { + content: fixWS` + <h2 class="dot-between-spans"> + ${[ + strings.link.home('', {text: wikiInfo.shortName, class: 'current', to}), + wikiInfo.features.listings && + strings.link.listingIndex('', {text: strings('listingIndex.title'), to}), + wikiInfo.features.news && + strings.link.newsIndex('', {text: strings('newsIndex.title'), to}), + wikiInfo.features.flashesAndGames && + strings.link.flashIndex('', {text: strings('flashIndex.title'), to}), + ...staticPageData.filter(page => page.listed).map(page => strings.link.staticPage(page, {to})) + ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')} + </h2> + ` + } + })); +} + +function writeMiscellaneousPages() { + return [ + writeHomepage() + ]; +} + +function writeNewsPages() { + if (!wikiInfo.features.news) { + return; + } + + return [ + writeNewsIndex(), + ...newsData.map(writeNewsEntryPage) + ]; +} + +function writeNewsIndex() { + return ({strings, writePage}) => writePage('newsIndex', '', ({to}) => ({ + title: strings('newsIndex.title'), + + main: { + content: fixWS` + <div class="long-content news-index"> + <h1>${strings('newsIndex.title')}</h1> + ${newsData.map(entry => fixWS` + <article id="${entry.directory}"> + <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: {simple: true} + })); +} + +function writeNewsEntryPage(entry) { + return ({strings, writePage}) => writePage('newsEntry', entry.directory, ({to}) => ({ + title: strings('newsEntryPage.title', {entry: entry.name}), + + main: { + content: fixWS` + <div class="long-content"> + <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: generateNewsEntryNav(entry, {strings, to}) + })); +} + +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('localized.newsEntry', entry, newsData.slice().reverse(), {strings, to}); + + return { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: to('localized.newsIndex'), + title: strings('newsEntryPage.nav.news') + }, + { + html: strings('newsEntryPage.nav.entry', { + date: strings.count.date(entry.date), + entry: strings.link.newsEntry(entry, {class: 'current', to}) + }) + }, + previousNextLinks && + { + divider: false, + html: `(${previousNextLinks})` + } + ] + }; +} + +function writeStaticPages() { + return staticPageData.map(writeStaticPage); +} + +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, {strings, to})} + </div> + ` + }, + + nav: {simple: true} + })); +} + + +function getRevealStringFromWarnings(warnings, {strings}) { + return strings('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${strings('misc.contentWarnings.reveal')}</span>` +} + +function getRevealStringFromTags(tags, {strings}) { + return tags && tags.some(tag => tag.isCW) && ( + getRevealStringFromWarnings(strings.list.unit(tags.filter(tag => tag.isCW).map(tag => tag.name)), {strings})); +} + +function generateCoverLink({ + strings, to, + src, + alt, + tags = [] +}) { + return fixWS` + <div id="cover-art-container"> + ${img({ + src, + alt, + thumb: 'medium', + id: 'cover-art', + link: true, + square: true, + reveal: getRevealStringFromTags(tags, {strings}) + })} + ${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> + `; +} + +// This function title is my gr8test work of art. +// (The 8ehavior... well, um. Don't tell anyone, 8ut it's even 8etter.) +/* // RIP, 2k20-2k20. +function writeIndexAndTrackPagesForAlbum(album) { + return [ + () => writeAlbumPage(album), + ...album.tracks.map(track => () => writeTrackPage(track)) + ]; +} +*/ + +function writeAlbumPages() { + return albumData.map(writeAlbumPage); +} + +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.color)}">${ + (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 albumDuration = getTotalDuration(album.tracks); + + const listTag = getAlbumListTag(album); + + const data = { + type: 'data', + path: ['album', album.directory], + data: () => ({ + name: album.name, + directory: album.directory, + dates: { + released: album.date, + trackArtAdded: album.trackArtDate, + coverArtAdded: album.coverArtDate, + addedToWiki: album.dateAdded + }, + duration: albumDuration, + color: album.color, + cover: serializeCover(album, getAlbumCover), + artists: serializeContribs(album.artists || []), + coverArtists: serializeContribs(album.coverArtists || []), + wallpaperArtists: serializeContribs(album.wallpaperArtists || []), + bannerArtists: serializeContribs(album.bannerArtists || []), + groups: serializeGroupsForAlbum(album), + trackGroups: album.trackGroups?.map(trackGroup => ({ + name: trackGroup.name, + color: trackGroup.color, + tracks: trackGroup.tracks.map(track => track.directory) + })), + tracks: album.tracks.map(track => ({ + link: serializeLink(track), + duration: track.duration + })) + }) + }; + + const page = {type: 'page', path: ['album', album.directory], page: ({strings, to}) => ({ + title: strings('albumPage.title', {album: album.name}), + stylesheet: getAlbumStylesheet(album, {to}), + theme: getThemeString(album.color, [ + `--album-directory: ${album.directory}` + ]), + + banner: album.bannerArtists && { + src: to('media.albumBanner', album.directory), + alt: strings('misc.alt.albumBanner'), + position: 'top' + }, + + main: { + content: fixWS` + ${generateCoverLink({ + strings, to, + src: to('media.albumCover', album.directory), + alt: strings('misc.alt.albumCover'), + tags: album.artTags + })} + <h1>${strings('albumPage.title', {album: album.name})}</h1> + <p> + ${[ + 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 + }) + }), + album.bannerArtists && strings('releaseInfo.bannerArtBy', { + artists: getArtistString(album.bannerArtists, { + 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(albumDuration, {approximate: album.tracks.length > 1}) + }) + ].filter(Boolean).join('<br>\n')} + </p> + ${commentaryEntries && `<p>${ + strings('releaseInfo.viewCommentary', { + link: `<a href="${to('localized.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.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(t => trackToListItem(t, {strings, to})).join('\n')} + </${listTag}> + `} + <p> + ${[ + strings('releaseInfo.addedToWiki', { + date: strings.count.date(album.dateAdded) + }) + ].filter(Boolean).join('<br>\n')} + </p> + ${album.commentary && fixWS` + <p>${strings('releaseInfo.artistCommentary')}</p> + <blockquote> + ${transformMultiline(album.commentary, {strings, to})} + </blockquote> + `} + ` + }, + + sidebarLeft: generateSidebarForAlbum(album, null, {strings, to}), + + nav: { + links: [ + { + href: to('localized.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, null, {strings, to})} + </div> + ` + } + })}; + + return [page, data]; +} + +function getAlbumStylesheet(album, {to}) { + return [ + album.wallpaperArtists && fixWS` + body::before { + background-image: url("${to('media.albumWallpaper', album.directory)}"); + ${album.wallpaperStyle} + } + `, + album.bannerStyle && fixWS` + #banner img { + ${album.bannerStyle} + } + ` + ].filter(Boolean).join('\n'); +} + +function writeTrackPages() { + return trackData.map(writeTrackPage); +} + +function writeTrackPage(track) { + const { album } = track; + + const tracksThatReference = track.referencedBy; + const useDividedReferences = groupData.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY); + const ttrFanon = (useDividedReferences && + tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY))); + const ttrOfficial = (useDividedReferences && + tracksThatReference.filter(t => t.album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY))); + + const tracksReferenced = track.references; + const otherReleases = track.otherReleases; + const listTag = getAlbumListTag(album); + + let flashesThatFeature; + if (wikiInfo.features.flashesAndGames) { + flashesThatFeature = sortByDate([track, ...otherReleases] + .flatMap(track => track.flashes.map(flash => ({flash, as: track})))); + } + + const generateTrackList = (tracks, {strings, to}) => html.tag('ul', + tracks.map(track => { + const 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>` + }); + return (track.aka + ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>` + : `<li>${line}</li>`); + }) + ); + + 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}); + + const data = { + type: 'data', + path: ['track', track.directory], + data: () => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artists: serializeContribs(track.artists), + contributors: serializeContribs(track.contributors), + coverArtists: serializeContribs(track.coverArtists || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map(track => ({ + track: serializeLink(track), + album: serializeLink(track.album) + })) + }) + }; + + const page = {type: 'page', path: ['track', track.directory], page: ({strings, to}) => ({ + title: strings('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + theme: getThemeString(track.color, [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}` + ]), + + // disabled for now! shifting banner position per height of page is disorienting + /* + banner: album.bannerArtists && { + classes: ['dim'], + src: to('media.albumBanner', album.directory), + alt: strings('misc.alt.albumBanner'), + position: 'bottom' + }, + */ + + main: { + content: fixWS` + ${generateCoverLink({ + strings, to, + src: getTrackCover(track, {to}), + alt: strings('misc.alt.trackCover'), + tags: track.artTags + })} + <h1>${strings('trackPage.title', {track: track.name})}</h1> + <p> + ${[ + 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 !== 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> + <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>${strings('releaseInfo.alsoReleasedAs')}</p> + <ul> + ${otherReleases.map(track => fixWS` + <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> + ${strings('releaseInfo.contributors')} + <br> + ${transformInline(track.contributors.textContent, {strings, to})} + </p> + `} + ${track.contributors.length && fixWS` + <p>${strings('releaseInfo.contributors')}</p> + <ul> + ${(track.contributors + .map(contrib => `<li>${getArtistString([contrib], { + strings, to, + showContrib: true, + showIcons: true + })}</li>`) + .join('\n'))} + </ul> + `} + ${tracksReferenced.length && fixWS` + <p>${strings('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p> + ${generateTrackList(tracksReferenced, {strings, to})} + `} + ${tracksThatReference.length && fixWS` + <p>${strings('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p> + ${useDividedReferences && fixWS` + <dl> + ${ttrOfficial.length && fixWS` + <dt>${strings('trackPage.referenceList.official')}</dt> + <dd>${generateTrackList(ttrOfficial, {strings, to})}</dd> + `} + ${ttrFanon.length && fixWS` + <dt>${strings('trackPage.referenceList.fandom')}</dt> + <dd>${generateTrackList(ttrFanon, {strings, to})}</dd> + `} + </dl> + `} + ${!useDividedReferences && generateTrackList(tracksThatReference, {strings, to})} + `} + ${wikiInfo.features.flashesAndGames && flashesThatFeature.length && fixWS` + <p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p> + <ul> + ${flashesThatFeature.map(({ flash, as }) => fixWS` + <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>${strings('releaseInfo.lyrics')}</p> + <blockquote> + ${transformLyrics(track.lyrics, {strings, to})} + </blockquote> + `} + ${hasCommentary && fixWS` + <p>${strings('releaseInfo.artistCommentary')}</p> + <blockquote> + ${generateCommentary({strings, to})} + </blockquote> + `} + ` + }, + + sidebarLeft: generateSidebarForAlbum(album, track, {strings, to}), + + nav: { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: to('localized.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> + ` + } + })}; + + return [data, page]; +} + +function writeArtistPages() { + return [ + ...artistData.map(writeArtistPage), + ...artistAliasData.map(writeArtistAliasPage) + ]; +} + +function writeArtistPage(artist) { + const { + name, + urls = [], + note = '' + } = artist; + + const artThingsAll = sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist])); + const artThingsGallery = sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]); + const commentaryThings = sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]); + + const hasGallery = artThingsGallery.length > 0; + + const getArtistsAndContrib = (thing, key) => ({ + artists: thing[key]?.filter(({ who }) => who !== artist), + contrib: thing[key]?.find(({ who }) => who === artist), + thing, + key + }); + + const artListChunks = chunkByProperties(artThingsAll.flatMap(thing => + (['coverArtists', 'wallpaperArtists', 'bannerArtists'] + .map(key => getArtistsAndContrib(thing, key)) + .filter(({ contrib }) => contrib) + .map(props => ({ + album: thing.album || thing, + track: thing.album ? thing : null, + date: +(thing.coverArtDate || thing.date), + ...props + }))) + ), ['date', 'album']); + + const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({ + album: thing.album || thing, + track: thing.album ? thing : null + })), ['album']); + + const allTracks = sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor])); + const unreleasedTracks = allTracks.filter(track => track.album.directory === UNRELEASED_TRACKS_DIRECTORY); + const releasedTracks = allTracks.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY); + + const chunkTracks = tracks => ( + chunkByProperties(tracks.map(track => ({ + track, + date: +track.date, + 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(', ') + } + })), ['date', 'album']) + .map(({date, album, chunk}) => ({ + date, album, chunk, + duration: getTotalDuration(chunk), + }))); + + const unreleasedTrackListChunks = chunkTracks(unreleasedTracks); + const releasedTrackListChunks = chunkTracks(releasedTracks); + + const totalReleasedDuration = getTotalDuration(releasedTracks); + + const countGroups = things => { + const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []); + return groupData + .map(group => ({ + group, + contributions: usedGroups.filter(g => g === group).length + })) + .filter(({ contributions }) => contributions > 0) + .sort((a, b) => b.contributions - a.contributions); + }; + + const musicGroups = countGroups(releasedTracks); + const artGroups = countGroups(artThingsAll); + + let flashes, flashListChunks; + if (wikiInfo.features.flashesAndGames) { + flashes = 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.what + }) + : strings('artistPage.creditList.entry.withArtists', { + entry, + artists: getArtistString(artists, {strings, to}) + })) + : (contrib.what + ? strings('artistPage.creditList.entry.withContribution', { + entry, + contribution: contrib.what + }) + : entry))); + + const generateTrackList = (chunks, {strings, to}) => fixWS` + <dl> + ${chunks.map(({date, album, chunk, duration}) => fixWS` + <dt>${strings('artistPage.creditList.album.withDate.withDuration', { + album: strings.link.album(album, {to}), + date: strings.count.date(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> + `; + + const serializeArtistsAndContrib = key => thing => { + const { artists, contrib } = getArtistsAndContrib(thing, key); + const ret = {}; + ret.link = serializeLink(thing); + if (contrib.what) ret.contribution = contrib.what; + if (artists.length) ret.otherArtists = serializeContribs(artists); + return ret; + }; + + const serializeTrackListChunks = chunks => + chunks.map(({date, album, chunk, duration}) => ({ + album: serializeLink(album), + date, + duration, + tracks: chunk.map(({ track }) => ({ + link: serializeLink(track), + duration: track.duration + })) + })); + + const data = { + type: 'data', + path: ['artist', artist.directory], + data: () => ({ + albums: { + asCoverArtist: artist.albums.asCoverArtist.map(serializeArtistsAndContrib('coverArtists')), + asWallpaperArtist: artist.albums.asWallpaperArtist.map(serializeArtistsAndContrib('wallpaperArtists')), + asBannerArtist: artist.albums.asBannerArtist.map(serializeArtistsAndContrib('bannerArtists')) + }, + flashes: wikiInfo.features.flashesAndGames ? { + asContributor: artist.flashes.asContributor + .map(flash => getArtistsAndContrib(flash, 'contributors')) + .map(({ contrib, thing: flash }) => ({ + link: serializeLink(flash), + contribution: contrib.what + })) + } : null, + tracks: { + asArtist: artist.tracks.asArtist.map(serializeArtistsAndContrib('artists')), + asContributor: artist.tracks.asContributor.map(serializeArtistsAndContrib('contributors')), + chunked: { + released: serializeTrackListChunks(releasedTrackListChunks), + unreleased: serializeTrackListChunks(unreleasedTrackListChunks) + } + } + }) + }; + + const infoPage = { + type: 'page', + path: ['artist', artist.directory], + page: ({strings, to}) => ({ + title: strings('artistPage.title', {artist: name}), + + main: { + content: fixWS` + ${artist.hasAvatar && generateCoverLink({ + strings, to, + src: to('localized.artistAvatar', artist.directory), + 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.unit([ + [ + [...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> + <p>${strings('artistPage.musicGroupsLine', { + groups: strings.list.unit(musicGroups + .map(({ group, contributions }) => strings('artistPage.groupsLine.item', { + group: strings.link.groupInfo(group, {to}), + contributions: strings.count.contributions(contributions) + }))) + })}</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>`} + <p>${strings('artistPage.artGroupsLine', { + groups: strings.list.unit(artGroups + .map(({ group, contributions }) => strings('artistPage.groupsLine.item', { + group: strings.link.groupInfo(group, {to}), + contributions: strings.count.contributions(contributions) + }))) + })}</p> + <dl> + ${artListChunks.map(({date, album, chunk}) => fixWS` + <dt>${strings('artistPage.creditList.album.withDate', { + album: strings.link.album(album, {to}), + date: strings.count.date(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', + bannerArtists: 'bannerArt', + 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: generateNavForArtist(artist, {strings, to, isGallery: false, hasGallery}) + }) + }; + + const galleryPage = hasGallery && { + type: 'page', + path: ['artistGallery', artist.directory], + page: ({strings, 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('localized.track', thing.directory) + : to('localized.album', thing.directory)) + })} + </div> + ` + }, + + nav: generateNavForArtist(artist, {strings, to, isGallery: true, hasGallery}) + }) + }; + + return [data, infoPage, galleryPage].filter(Boolean); +} + +function generateNavForArtist(artist, {strings, to, isGallery, hasGallery}) { + const infoGalleryLinks = (hasGallery && + generateInfoGalleryLinks('artist', 'artistGallery', artist, isGallery, {strings, to})) + + return { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to('localized.listingIndex'), + title: strings('listingIndex.title') + }, + { + html: strings('artistPage.nav.artist', { + artist: strings.link.artist(artist, {class: 'current', to}) + }) + }, + hasGallery && + { + divider: false, + html: `(${infoGalleryLinks})` + } + ] + }; +} + +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, {strings}) { + return fixWS` + <!DOCTYPE html> + <html> + <head> + <title>${strings('redirectPage.title', {title})}</title> + <meta charset="utf-8"> + <meta http-equiv="refresh" content="0;url=${target}"> + <link rel="canonical" href="${target}"> + <link rel="stylesheet" href="static/site-basic.css"> + </head> + <body> + <main> + <h1>${strings('redirectPage.title', {title})}</h1> + <p>${strings('redirectPage.infoLine', { + target: `<a href="${target}">${target}</a>` + })}</p> + </main> + </body> + </html> + `; +} + +function writeFlashPages() { + if (!wikiInfo.features.flashesAndGames) { + return; + } + + return [ + writeFlashIndex(), + ...flashData.map(writeFlashPage) + ]; +} + +function writeFlashIndex() { + return ({strings, writePage}) => writePage('flashIndex', '', ({to}) => ({ + title: strings('flashIndex.title'), + + 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(jumpColor)}">${jump}</a></li> + `).join('\n')} + </ul> + </div> + ${flashActData.map((act, i) => fixWS` + <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}"><a href="${to('localized.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')} + ` + }, + + nav: {simple: true} + })); +} + +function writeFlashPage(flash) { + return ({strings, writePage}) => writePage('flash', flash.directory, ({to}) => ({ + title: strings('flashPage.title', {flash: flash.name}), + theme: getThemeString(flash.color, [ + `--flash-directory: ${flash.directory}` + ]), + + main: { + content: fixWS` + <h1>${strings('flashPage.title', {flash: flash.name})}</h1> + ${generateCoverLink({ + strings, to, + src: to('media.flashArt', flash.directory), + alt: strings('misc.alt.flashArt') + })} + <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, {strings}))) + })}</p>`} + ${flash.tracks.length && fixWS` + <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p> + <ul> + ${(flash.tracks + .map(track => strings('trackList.item.withArtists', { + track: strings.link.track(track, {strings, to}), + by: `<span class="by">${ + strings('trackList.item.withArtists.by', { + artists: getArtistString(track.artists, {strings, to}) + }) + }</span>` + })) + .map(row => `<li>${row}</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>${strings('releaseInfo.contributors')}</p> + <ul> + ${flash.contributors + .map(contrib => `<li>${getArtistString([contrib], { + strings, to, + showContrib: true, + showIcons: true + })}</li>`) + .join('\n')} + </ul> + `} + ` + }, + + sidebarLeft: generateSidebarForFlash(flash, {strings, to}), + nav: generateNavForFlash(flash, {strings, to}) + })); +} + +function generateNavForFlash(flash, {strings, to}) { + const previousNextLinks = generatePreviousNextLinks('localized.flash', flash, flashData, {strings, to}); + + return { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: to('localized.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, + headingString: 'misc.chronology.heading.flash', + contribKey: 'contributors', + getThings: artist => artist.flashes.asContributor + })} + </div> + ` + }; +} + +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 = ( + (actIndex < 0) ? 0 : + (actIndex < act6) ? 1 : + (actIndex <= outsideCanon) ? 2 : + 3 + ); + const currentAct = flash && flash.act; + + 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('localized.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('localized.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('localized.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('localized.flash', act.flashes[0].directory)}" style="${getLinkThemeString(act.color)}">${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> + ` + }; +} + +const listingSpec = [ + { + directory: 'albums/by-name', + title: ({strings}) => strings('listingPage.listAlbums.byName.title'), + + data() { + return albumData.slice() + .sort(sortByName); + }, + + 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}) + }); + } + }, + + { + directory: 'albums/by-tracks', + title: ({strings}) => strings('listingPage.listAlbums.byTracks.title'), + + 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 + .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 sortByDate(albumData + .filter(album => album.directory !== 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: 'albusm/by-date-added', + title: ({strings}) => strings('listingPage.listAlbums.byDateAdded.title'), + + data() { + return chunkByProperties(albumData.slice().sort((a, b) => { + if (a.dateAdded < b.dateAdded) return -1; + if (a.dateAdded > b.dateAdded) return 1; + }), ['dateAdded']); + }, + + html(chunks, {strings, to}) { + return fixWS` + <dl> + ${chunks.map(({dateAdded, chunk: albums}) => fixWS` + <dt>${strings('listingPage.listAlbums.byDateAdded.date', { + date: strings.count.date(dateAdded) + })}</dt> + <dd><ul> + ${(albums + .map(album => strings('listingPage.listAlbums.byDateAdded.album', { + album: strings.link.album(album, {to}) + })) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul></dd> + `).join('\n')} + </dl> + `; + } + }, + + { + directory: 'artists/by-name', + title: ({strings}) => strings('listingPage.listArtists.byName.title'), + + data() { + return artistData.slice() + .sort(sortByName) + .map(artist => ({artist, contributions: 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 + + artist.albums.asBannerArtist.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> + `; + } + }, + + { + 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 !== 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: sortByDate(artistData + .filter(artist => !artist.alias) + .map(artist => ({ + artist, + date: reversedTracks.find(({ album, artists, contributors }) => ( + album.directory !== 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: sortByDate(artistData + .filter(artist => !artist.alias) + .map(artist => { + const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => ( + album?.directory !== 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> + `; + } + }, + + { + 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 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>${strings('listingPage.listTracks.byAlbum.album', { + album: strings.link.album(album, {to}) + })}</dt> + <dd><ol> + ${(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> + `; + } + }, + + { + directory: 'tracks/by-date', + title: ({strings}) => strings('listingPage.listTracks.byDate.title'), + + data() { + return chunkByProperties( + sortByDate(trackData.filter(track => track.album.directory !== 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> + ${(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> + `; + } + }, + + { + directory: 'tracks/by-duration', + title: ({strings}) => strings('listingPage.listTracks.byDuration.title'), + + data() { + return trackData + .filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY) + .map(track => ({track, duration: track.duration})) + .filter(({ duration }) => duration > 0) + .sort((a, b) => b.duration - a.duration); + }, + + row({track, duration}, {strings, to}) { + return strings('listingPage.listTracks.byDuration.item', { + track: strings.link.track(track, {to}), + duration: strings.count.duration(duration) + }); + } + }, + + { + 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) + })); + }, + + 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> + `; + } + }, + + { + 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); + }, + + row({track, timesReferenced}, {strings, to}) { + return strings('listingPage.listTracks.byTimesReferenced.item', { + track: strings.link.track(track, {to}), + timesReferenced: strings.count.timesReferenced(timesReferenced, {unit: true}) + }); + } + }, + + { + directory: 'tracks/in-flashes/by-album', + title: ({strings}) => strings('listingPage.listTracks.inFlashes.byAlbum.title'), + condition: () => wikiInfo.features.flashesAndGames, + + data() { + return chunkByProperties(trackData.filter(t => t.flashes.length > 0), ['album']) + .filter(({ album }) => album.directory !== 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> + ${(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> + ${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> + `).join('\n')} + </dl> + `; + } + }, + + { + directory: 'tracks/with-lyrics', + title: ({strings}) => strings('listingPage.listTracks.withLyrics.title'), + + data() { + return chunkByProperties(trackData.filter(t => t.lyrics), ['album']); + }, + + 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-uses', + 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.color)}; --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 !== UNRELEASED_TRACKS_DIRECTORY); + const releasedAlbums = albumData.filter(album => album.directory !== 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: {simple: true} + })) +} + +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>${listing.title({strings})}</h1> + ${listing.html && (listing.data + ? listing.html(data, {strings, to}) + : listing.html({strings, to}))} + ${listing.row && fixWS` + <ul> + ${(data + .map(item => listing.row(item, {strings, to})) + .map(row => `<li>${row}</li>`) + .join('\n'))} + </ul> + `} + ` + }, + + sidebarLeft: { + content: generateSidebarForListings(listing, {strings, to}) + }, + + nav: { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: to('localized.listingIndex'), + title: strings('listingIndex.title') + }, + { + href: '', + title: listing.title({strings}) + } + ] + } + })); +} + +function generateSidebarForListings(currentListing, {strings, to}) { + return fixWS` + <h1>${strings.link.listingIndex('', {text: strings('listingIndex.title'), to})}</h1> + ${generateLinkIndexForListings(currentListing, {strings, to})} + `; +} + +function generateLinkIndexForListings(currentListing, {strings, to}) { + return fixWS` + <ul> + ${(listingSpec + .filter(({ condition }) => !condition || condition()) + .map(listing => fixWS` + <li ${classes(listing === currentListing && 'current')}> + <a href="${to('localized.listing', listing.directory)}">${listing.title({strings})}</a> + </li> + `) + .join('\n'))} + </ul> + `; +} + +function filterAlbumsByCommentary() { + return albumData.filter(album => [album, ...album.tracks].some(x => x.commentary)); +} + +function writeCommentaryPages() { + if (!filterAlbumsByCommentary().length) { + return; + } + + return [ + writeCommentaryIndex(), + ...filterAlbumsByCommentary().map(writeAlbumCommentaryPage) + ]; +} + +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>${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> + ${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')} + </ul> + </div> + ` + }, + + nav: {simple: true} + })); +} + +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.color), + + main: { + content: fixWS` + <div class="long-content"> + <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>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3> + <blockquote> + ${transformMultiline(album.commentary, {strings, to})} + </blockquote> + `} + ${album.tracks.filter(t => t.commentary).map(track => fixWS` + <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', { + track: strings.link.track(track, {to}) + })}</h3> + <blockquote style="${getLinkThemeString(track.color)}"> + ${transformMultiline(track.commentary, {strings, to})} + </blockquote> + `).join('\n')} + </div> + ` + }, + + nav: { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + { + href: to('localized.commentaryIndex'), + title: strings('commentaryIndex.title') + }, + { + html: strings('albumCommentaryPage.nav.album', { + album: strings.link.albumCommentary(album, {class: 'current', to}) + }) + } + ] + } + })); +} + +function writeTagPages() { + if (!wikiInfo.features.artTagUI) { + return; + } + + return tagData.filter(tag => !tag.isCW).map(writeTagPage); +} + +function writeTagPage(tag) { + const { things } = tag; + + return ({strings, writePage}) => writePage('tag', tag.directory, ({to}) => ({ + title: strings('tagPage.title', {tag: tag.name}), + theme: getThemeString(tag.color), + + main: { + classes: ['top-index'], + content: fixWS` + <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, {to}) + : getAlbumCover(thing, {to})), + hrefFn: thing => (thing.album + ? to('localized.track', thing.directory) + : to('localized.album', thing.directory)) + })} + </div> + ` + }, + + nav: { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to('localized.listingIndex'), + title: strings('listingIndex.title') + }, + { + html: strings('tagPage.nav.tag', { + tag: strings.link.tag(tag, {class: 'current', to}) + }) + } + ] + } + })); +} + +function getArtistString(artists, {strings, to, showIcons = false, showContrib = false}) { + return strings.list.and(artists.map(({ who, what }) => { + const { urls, directory, name } = who; + return [ + strings.link.artist(who, {to}), + showContrib && what && `(${what})`, + showIcons && urls.length && `<span class="icons">(${ + strings.list.unit(urls.map(url => iconifyURL(url, {strings, to}))) + })</span>` + ].filter(Boolean).join(' '); + })); +} + +function getFlashDirectory(flash) { + // const kebab = getKebabCase(flash.name.replace('[S] ', '')); + // return flash.page + (kebab ? '-' + kebab : ''); + // return '' + flash.page; + return '' + flash.directory; +} + +function getTagDirectory({name}) { + return getKebabCase(name); +} + +function getAlbumListTag(album) { + if (album.directory === UNRELEASED_TRACKS_DIRECTORY) { + return 'ul'; + } else { + return 'ol'; + } +} + +function fancifyURL(url, {strings, album = false} = {}) { + const domain = new URL(url).hostname; + return fixWS`<a href="${url}" class="nowrap">${ + 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, {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, {strings, to}) { + const domain = new URL(url).hostname; + const [ id, msg ] = ( + domain.includes('bandcamp.com') ? ['bandcamp', strings('misc.external.bandcamp')] : + ( + domain.includes('music.solatrus.com') + ) ? ['bandcamp', strings('misc.external.bandcamp.domain', {domain})] : + ( + domain.includes('types.pl') + ) ? ['mastodon', strings('misc.external.mastodon.domain', {domain})] : + domain.includes('youtu') ? ['youtube', strings('misc.external.youtube')] : + domain.includes('soundcloud') ? ['soundcloud', strings('misc.external.soundcloud')] : + domain.includes('tumblr.com') ? ['tumblr', strings('misc.external.tumblr')] : + domain.includes('twitter.com') ? ['twitter', strings('misc.external.twitter')] : + domain.includes('deviantart.com') ? ['deviantart', strings('misc.external.deviantart')] : + domain.includes('instagram.com') ? ['instagram', strings('misc.external.bandcamp')] : + ['globe', strings('misc.external.domain', {domain})] + ); + return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`; +} + +function chronologyLinks(currentThing, { + strings, to, + headingString, + contribKey, + getThings +}) { + const contributions = currentThing[contribKey]; + if (!contributions) { + return ''; + } + + if (contributions.length > 8) { + return `<div class="chronology">${strings('misc.chronology.seeArtistPages')}</div>`; + } + + return contributions.map(({ who: artist }) => { + const things = sortByDate(unique(getThings(artist))); + const releasedThings = things.filter(thing => { + const album = albumData.includes(thing) ? thing : thing.album; + return !(album && album.directory === UNRELEASED_TRACKS_DIRECTORY); + }); + 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="${toAnythingMan(previous, to)}" title="${previous.name}">Previous</a>`, + next && `<a href="${toAnythingMan(next, to)}" title="${next.name}">Next</a>` + ].filter(Boolean); + + const stringOpts = { + index: strings.count.index(index + 1, {strings}), + artist: strings.link.artist(artist, {to}) + }; + + return fixWS` + <div class="chronology"> + <span class="heading">${strings(headingString, stringOpts)}</span> + ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`} + </div> + `; + }).filter(Boolean).join('\n'); +} + +function generateAlbumNavLinks(album, currentTrack, {strings, to}) { + if (album.tracks.length <= 1) { + return ''; + } + + const previousNextLinks = currentTrack && generatePreviousNextLinks('localized.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>`; + + return (previousNextLinks + ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)` + : `<span class="js-hide-until-data">(${randomLink})</span>`); +} + +function generateAlbumChronologyLinks(album, currentTrack, {strings, to}) { + return [ + currentTrack && chronologyLinks(currentTrack, { + strings, to, + headingString: 'misc.chronology.heading.track', + contribKey: 'artists', + getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor] + }), + chronologyLinks(currentTrack || album, { + strings, to, + headingString: 'misc.chronology.heading.coverArt', + contribKey: 'coverArtists', + getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist] + }) + ].filter(Boolean).join('\n'); +} + +function generateSidebarForAlbum(album, currentTrack, {strings, to}) { + const listTag = getAlbumListTag(album); + + const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}>${ + strings('albumSidebar.trackList.item', { + track: strings.link.track(track, {to}) + }) + }</li>`; + + const trackListPart = fixWS` + <h1><a href="${to('localized.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: strings.link.track(tracks[0], {to, text: name}), + range: `${startIndex + 1}–${startIndex + tracks.length}` + }) + : strings('albumSidebar.trackList.group', { + group: strings.link.track(tracks[0], {to, text: name}) + })) + }</dt> + ${(!currentTrack || tracks.includes(currentTrack)) && fixWS` + <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}> + ${tracks.map(trackToListItem).join('\n')} + </${listTag}></dd> + `} + `).join('\n')} + </dl> + ` : fixWS` + <${listTag}> + ${album.tracks.map(trackToListItem).join('\n')} + </${listTag}> + `} + `; + + const { groups } = album; + + const groupParts = groups.map(group => { + const index = group.albums.indexOf(album); + const next = group.albums[index + 1]; + const previous = group.albums[index - 1]; + return {group, next, previous}; + }).map(({group, next, previous}) => fixWS` + <h1>${ + strings('albumSidebar.groupBox.title', { + group: `<a href="${to('localized.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">${ + strings('albumSidebar.groupBox.next', { + album: `<a href="${to('localized.album', next.directory)}" style="${getLinkThemeString(next.color)}">${next.name}</a>` + }) + }</p>`} + ${previous && `<p class="group-chronology-link">${ + strings('albumSidebar.groupBox.previous', { + album: `<a href="${to('localized.album', previous.directory)}" style="${getLinkThemeString(previous.color)}">${previous.name}</a>` + }) + }</p>`} + `} + `); + + if (groupParts.length) { + if (currentTrack) { + const combinedGroupPart = groupParts.join('\n<hr>\n'); + return { + multiple: [ + trackListPart, + combinedGroupPart + ] + }; + } else { + return { + multiple: [ + ...groupParts, + trackListPart + ] + }; + } + } else { + return { + content: trackListPart + }; + } +} + +function generateSidebarForGroup(currentGroup, {strings, to, isGallery}) { + if (!wikiInfo.features.groupUI) { + return null; + } + + const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo'; + + return { + content: fixWS` + <h1>${strings('groupSidebar.title')}</h1> + <dl> + ${groupCategoryData.map(category => [ + fixWS` + <dt ${classes(category === currentGroup.category && 'current')}>${ + strings('groupSidebar.groupList.category', { + category: `<a href="${to(urlKey, category.groups[0].directory)}" style="${getLinkThemeString(category.color)}">${category.name}</a>` + }) + }</dt> + <dd><ul> + ${category.groups.map(group => fixWS` + <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group.color)}">${ + strings('groupSidebar.groupList.item', { + group: `<a href="${to(urlKey, group.directory)}">${group.name}</a>` + }) + }</li> + `).join('\n')} + </ul></dd> + ` + ]).join('\n')} + </dl> + ` + }; +} + +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 index = thingData.indexOf(currentThing); + const previous = thingData[index - 1]; + const next = thingData[index + 1]; + + return [ + previous && `<a href="${to(urlKey, previous.directory)}" id="previous-button" title="${previous.name}">${strings('misc.nav.previous')}</a>`, + next && `<a href="${to(urlKey, 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 ? 'localized.groupGallery' : 'localized.groupInfo'; + const linkKey = isGallery ? 'groupGallery' : 'groupInfo'; + + const infoGalleryLinks = generateInfoGalleryLinks('groupInfo', 'groupGallery', currentGroup, isGallery, {strings, to}); + const previousNextLinks = generatePreviousNextLinks(urlKey, currentGroup, groupData, {strings, to}) + + return { + links: [ + { + href: to('localized.home'), + title: wikiInfo.shortName + }, + wikiInfo.features.listings && + { + href: to('localized.listingIndex'), + title: strings('listingIndex.title') + }, + { + html: strings('groupPage.nav.group', { + group: strings.link[linkKey](currentGroup, {class: 'current', to}) + }) + }, + { + divider: false, + html: (previousNextLinks + ? `(${infoGalleryLinks}; ${previousNextLinks})` + : `(${previousNextLinks})`) + } + ] + }; +} + +function writeGroupPages() { + return groupData.map(writeGroupPage); +} + +function writeGroupPage(group) { + const releasedAlbums = group.albums.filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY); + const releasedTracks = releasedAlbums.flatMap(album => album.tracks); + const totalDuration = getTotalDuration(releasedTracks); + + return async ({strings, writePage}) => { + await writePage('groupInfo', group.directory, ({to}) => ({ + title: strings('groupInfoPage.title', {group: group.name}), + theme: getThemeString(group.color), + + 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('localized.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('localized.album', album.directory)}" style="${getLinkThemeString(album.color)}">${album.name}</a>` + }) + }</li> + `).join('\n')} + </ul> + ` + }, + + sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: false}), + nav: generateNavForGroup(group, {strings, to, isGallery: false}) + })); + + await writePage('groupGallery', group.directory, ({to}) => ({ + title: strings('groupGalleryPage.title', {group: group.name}), + theme: getThemeString(group.color), + + 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('localized.listing', 'groups/by-category')}">Choose another group to filter by!</a>)</p>`} + <div class="grid-listing"> + ${getAlbumGridHTML({ + strings, to, + entries: 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 toAnythingMan(anythingMan, to) { + return ( + albumData.includes(anythingMan) ? to('localized.album', anythingMan.directory) : + trackData.includes(anythingMan) ? to('localized.track', anythingMan.directory) : + flashData?.includes(anythingMan) ? to('localized.flash', anythingMan.directory) : + 'idk-bud' + ) +} + +function getAlbumCover(album, {to}) { + return to('media.albumCover', album.directory); +} + +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, {to}); + } else { + return to('media.trackCover', track.album.directory, track.directory); + } +} + +function getFlashLink(flash) { + return `https://homestuck.com/story/${flash.page}`; +} + +function classes(...args) { + const values = args.filter(Boolean); + return `class="${values.join(' ')}"`; +} + +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}).`}; + } + + let json; + try { + json = JSON.parse(contents); + } catch (error) { + return {error: `Could not parse JSON from ${file} (${error}).`}; + } + + return genStrings(json, defaultStrings); +} + +// 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, writeOneLanguage = null) { + const k = writeOneLanguage + const languagesToRun = (k + ? {[k]: languages[k]} + : languages) + + const entries = Object.entries(languagesToRun) + .filter(([ key ]) => key !== 'default'); + + for (let i = 0; i < entries.length; i++) { + const [ key, strings ] = entries[i]; + + 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 + }, i, entries); + } +} + +async function main() { + const miscOptions = await parseOptions(process.argv.slice(2), { + // Data files for the site, including flash, artist, and al8um data, + // and like a jillion other things too. Pretty much everything which + // makes an individual wiki what it is goes here! + 'data-path': { + type: 'value' + }, + + // Static media will 8e referenced in the site here! The contents are + // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants + // near the top of this file (upd8.js). + 'media-path': { + 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-path': { + 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 + // site. Just keep in mind that the gener8ted result will contain a + // couple symlinked directories, so if you're uploading, you're pro8a8ly + // gonna want to resolve those yourself. + 'out-path': { + type: 'value' + }, + + // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e + // kinda a pain to run every time, since it does necessit8te reading + // every media file at run time. Pass this to skip it. + 'skip-thumbs': { + type: 'flag' + }, + + // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can + // pass this flag! It exits 8efore 8uilding the rest of the site. + 'thumbs-only': { + type: 'flag' + }, + + // Only want 8uild one language during testing? This can chop down + // 8uild times a pretty 8ig chunk! Just pass a single language code. + 'lang': { + type: 'value' + }, + + 'queue-size': { + type: 'value', + validate(size) { + if (parseInt(size) !== parseFloat(size)) return 'an integer'; + if (parseInt(size) < 0) return 'a counting number or zero'; + return true; + } + }, + queue: {alias: 'queue-size'}, + + [parseOptions.handleUnknown]: () => {} + }); + + dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; + mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; + langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! + outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT; + + const writeOneLanguage = miscOptions['lang']; + + { + let errored = false; + const error = (cond, msg) => { + if (cond) { + console.error(`\x1b[31;1m${msg}\x1b[0m`); + errored = true; + } + }; + error(!dataPath, `Expected --data option or HSMUSIC_DATA to be set`); + error(!mediaPath, `Expected --media option or HSMUSIC_MEDIA to be set`); + error(!outputPath, `Expected --out option or HSMUSIC_OUT to be set`); + if (errored) { + return; + } + } + + const skipThumbs = miscOptions['skip-thumbs'] ?? false; + const thumbsOnly = miscOptions['thumbs-only'] ?? false; + + if (skipThumbs && thumbsOnly) { + logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`; + return; + } + + if (skipThumbs) { + logInfo`Skipping thumbnail generation.`; + } else { + logInfo`Begin thumbnail generation... -----+`; + const result = await genThumbs(mediaPath, {queueSize, quiet: true}); + logInfo`Done thumbnail generation! --------+`; + if (!result) return; + if (thumbsOnly) return; + } + + 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, f => path.extname(f) === '.json'); + 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; + } + + logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`; + + if (writeOneLanguage && !(writeOneLanguage in languages)) { + logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`; + return; + } else if (writeOneLanguage) { + logInfo`Writing only language ${writeOneLanguage} this run.`; + } else { + logInfo`Writing all languages.`; + } + + 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) { + console.log(`\x1b[31;1m${homepageInfo.error}\x1b[0m`); + return; + } + + { + const errors = homepageInfo.rows.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + // 8ut wait, you might say, how do we know which al8um these data files + // correspond to???????? You wouldn't dare suggest we parse the actual + // paths returned 8y this function, which ought to 8e of effectively + // unknown format except for their purpose as reada8le data files!? + // To that, I would say, yeah, you're right. Thanks a 8unch, my projection + // of "you". We're going to read these files later, and contained within + // will 8e the actual directory names that the data correspond to. Yes, + // that's redundant in some ways - we COULD just return the directory name + // in addition to the data path, and duplicating that name within the file + // itself suggests we 8e careful to avoid mismatching it - 8ut doing it + // this way lets the data files themselves 8e more porta8le (meaning we + // could store them all in one folder, if we wanted, and this program would + // still output to the correct al8um directories), and also does make the + // function's signature simpler (an array of strings, rather than some kind + // of structure containing 8oth data file paths and output directories). + // This is o8jectively a good thing, 8ecause it means the function can stay + // truer to its name, and have a narrower purpose: it doesn't need to + // concern itself with where we *output* files, or whatever other reasons + // we might (hypothetically) have for knowing the containing directory. + // And, in the strange case where we DO really need to know that info, we + // callers CAN use path.dirname to find out that data. 8ut we'll 8e + // 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 findFiles(path.join(dataPath, 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 + // with it. + albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile)); + + { + const errors = albumData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + sortByDate(albumData); + + artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE)); + if (artistData.error) { + console.log(`\x1b[31;1m${artistData.error}\x1b[0m`); + return; + } + + { + const errors = artistData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + artistAliasData = artistData.filter(x => x.alias); + artistData = artistData.filter(x => !x.alias); + + trackData = getAllTracks(albumData); + + if (wikiInfo.features.flashesAndGames) { + flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE)); + if (flashData.error) { + console.log(`\x1b[31;1m${flashData.error}\x1b[0m`); + return; + } + + const errors = flashData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + 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), + ...[ + ...albumData.flatMap(album => [ + ...album.artists || [], + ...album.coverArtists || [], + ...album.wallpaperArtists || [], + ...album.tracks.flatMap(track => [ + ...track.artists, + ...track.coverArtists || [], + ...track.contributors || [] + ]) + ]), + ...(flashData?.flatMap(flash => [ + ...flash.contributors || [] + ]) || []) + ].map(contribution => contribution.who) + ])); + + tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE)); + if (tagData.error) { + console.log(`\x1b[31;1m${tagData.error}\x1b[0m`); + return; + } + + { + const errors = tagData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE)); + if (groupData.error) { + console.log(`\x1b[31;1m${groupData.error}\x1b[0m`); + return; + } + + { + const errors = groupData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + 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`); + return; + } + + { + const errors = staticPageData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + } + + if (wikiInfo.features.news) { + newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE)); + if (newsData.error) { + console.log(`\x1b[31;1m${newsData.error}\x1b[0m`); + return; + } + + const errors = newsData.filter(obj => obj.error); + if (errors.length) { + for (const error of errors) { + console.log(`\x1b[31;1m${error.error}\x1b[0m`); + } + return; + } + + sortByDate(newsData); + newsData.reverse(); + } + + { + const tagNames = new Set([...trackData, ...albumData].flatMap(thing => thing.artTags)); + + for (let { name, isCW } of tagData) { + if (isCW) { + name = 'cw: ' + name; + } + tagNames.delete(name); + } + + if (tagNames.size) { + for (const name of Array.from(tagNames).sort()) { + console.log(`\x1b[33;1m- Missing tag: "${name}"\x1b[0m`); + } + return; + } + } + + artistNames.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0); + + justEverythingMan = sortByDate([...albumData, ...trackData, ...(flashData || [])]); + justEverythingSortedByArtDateMan = sortByArtDate(justEverythingMan.slice()); + // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2)); + + { + let buffer = []; + const clearBuffer = function() { + if (buffer.length) { + for (const entry of buffer.slice(0, -1)) { + console.log(`\x1b[2m... ${entry.name} ...\x1b[0m`); + } + const lastEntry = buffer[buffer.length - 1]; + console.log(`\x1b[2m... \x1b[0m${lastEntry.name}\x1b[0;2m ...\x1b[0m`); + buffer = []; + } + }; + const showWhere = (name, color) => { + const where = justEverythingMan.filter(thing => [ + ...thing.coverArtists || [], + ...thing.contributors || [], + ...thing.artists || [] + ].some(({ who }) => who === name)); + for (const thing of where) { + console.log(`\x1b[${color}m- ` + (thing.album ? `(\x1b[1m${thing.album.name}\x1b[0;${color}m)` : '') + ` \x1b[1m${thing.name}\x1b[0m`); + } + }; + let CR4SH = false; + for (let name of artistNames) { + 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`); + showWhere(name, 31); + CR4SH = true; + } else if (entry.alias) { + console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.alias}\x1b[0;33m"\x1b[0m`); + showWhere(name, 33); + CR4SH = true; + } else if (entry.name !== name) { + console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.name}\x1b[0;33m"\x1b[0m`); + showWhere(name, 33); + CR4SH = true; + } else { + buffer.push(entry); + if (buffer.length > 3) { + buffer.shift(); + } + } + } + if (CR4SH) { + return; + } + } + + { + const directories = []; + for (const { directory, name } of albumData) { + if (directories.includes(directory)) { + console.log(`\x1b[31;1mDuplicate album directory "${directory}" (${name})\x1b[0m`); + return; + } + directories.push(directory); + } + } + + { + const directories = []; + const where = {}; + for (const { directory, album } of trackData) { + if (directories.includes(directory)) { + console.log(`\x1b[31;1mDuplicate track directory "${directory}"\x1b[0m`); + console.log(`Shows up in:`); + console.log(`- ${album.name}`); + console.log(`- ${where[directory].name}`); + return; + } + directories.push(directory); + where[directory] = album; + } + } + + { + const artists = []; + const artistsLC = []; + for (const name of artistNames) { + if (!artists.includes(name) && artistsLC.includes(name.toLowerCase())) { + const other = artists.find(oth => oth.toLowerCase() === name.toLowerCase()); + console.log(`\x1b[31;1mMiscapitalized artist name: ${name}, ${other}\x1b[0m`); + return; + } + artists.push(name); + artistsLC.push(name.toLowerCase()); + } + } + + { + for (const { references, name, album } of trackData) { + for (const ref of references) { + if (!search.track(ref)) { + logWarn`Track not found "${ref}" in ${name} (${album.name})`; + } + } + } + } + + contributionData = Array.from(new Set([ + ...trackData.flatMap(track => [...track.artists || [], ...track.contributors || [], ...track.coverArtists || []]), + ...albumData.flatMap(album => [...album.artists || [], ...album.coverArtists || [], ...album.wallpaperArtists || [], ...album.bannerArtists || []]), + ...(flashData?.flatMap(flash => [...flash.contributors || []]) || []) + ])); + + // Now that we have all the data, resolve references all 8efore actually + // gener8ting any of the pages, 8ecause page gener8tion is going to involve + // accessing these references a lot, and there's no reason to resolve them + // more than once. (We 8uild a few additional links that can't 8e cre8ted + // at initial data processing time here too.) + + const filterNullArray = (parent, key) => { + for (const obj of parent) { + const array = obj[key]; + for (let i = 0; i < array.length; i++) { + if (!array[i]) { + const prev = array[i - 1] && array[i - 1].name; + const next = array[i + 1] && array[i + 1].name; + logWarn`Unexpected null in ${obj.name} (${obj.what}) (array key ${key} - prev: ${prev}, next: ${next})`; + } + } + array.splice(0, array.length, ...array.filter(Boolean)); + } + }; + + const filterNullValue = (parent, key) => { + parent.splice(0, parent.length, ...parent.filter(obj => { + if (!obj[key]) { + logWarn`Unexpected null in ${obj.name} (value key ${key})`; + } + })); + }; + + 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 = 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 || (track.aka && aka === track.aka)), + ].filter(x => x && x !== track)); + + if (wikiInfo.features.flashesAndGames) { + 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)); + + filterNullArray(flashData, 'tracks'); + + trackData.forEach(track => track.flashes = flashData.filter(flash => flash.tracks.includes(track))); + } + + artistData.forEach(artist => { + const filterProp = (array, prop) => array.filter(thing => thing[prop]?.some(({ who }) => who === artist)); + const filterCommentary = array => array.filter(thing => thing.commentary && thing.commentary.replace(/<\/?b>/g, '').includes('<i>' + artist.name + ':</i>')); + artist.tracks = { + asArtist: filterProp(trackData, 'artists'), + asCommentator: filterCommentary(trackData), + asContributor: filterProp(trackData, 'contributors'), + asCoverArtist: filterProp(trackData, 'coverArtists'), + asAny: trackData.filter(track => ( + [...track.artists, ...track.contributors, ...track.coverArtists || []].some(({ who }) => who === artist) + )) + }; + artist.albums = { + asArtist: filterProp(albumData, 'artists'), + asCommentator: filterCommentary(albumData), + asCoverArtist: filterProp(albumData, 'coverArtists'), + asWallpaperArtist: filterProp(albumData, 'wallpaperArtists'), + asBannerArtist: filterProp(albumData, 'bannerArtists') + }; + if (wikiInfo.features.flashesAndGames) { + artist.flashes = { + asContributor: filterProp(flashData, 'contributors') + }; + } + }); + + officialAlbumData = albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY)); + fandomAlbumData = albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY)); + + // Makes writing a little nicer on CPU theoretically, 8ut also costs in + // performance right now 'cuz it'll w8 for file writes to 8e completed + // 8efore moving on to more data processing. So, defaults to zero, which + // disa8les the queue feature altogether. + queueSize = +(miscOptions['queue-size'] ?? 0); + + // NOT for ena8ling or disa8ling specific features of the site! + // This is only in charge of what general groups of files to 8uild. + // They're here to make development quicker when you're only working + // on some particular area(s) of the site rather than making changes + // across all of them. + const writeFlags = await parseOptions(process.argv.slice(2), { + all: {type: 'flag'}, // Defaults to true if none 8elow specified. + + album: {type: 'flag'}, + artist: {type: 'flag'}, + commentary: {type: 'flag'}, + flash: {type: 'flag'}, + group: {type: 'flag'}, + list: {type: 'flag'}, + misc: {type: 'flag'}, + news: {type: 'flag'}, + static: {type: 'flag'}, + tag: {type: 'flag'}, + track: {type: 'flag'}, + + [parseOptions.handleUnknown]: () => {} + }); + + const writeAll = !Object.keys(writeFlags).length || writeFlags.all; + + logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`; + + await writeSymlinks(); + 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 = (writeAll + ? Object.values(buildDictionary) + : (Object.entries(buildDictionary) + .filter(([ flag ]) => writeFlags[flag]) + .map(([ flag, fn ]) => fn))); + + // *NB: While what's 8elow is 8asically still true in principle, the + // format is QUITE DIFFERENT than what's descri8ed here! There + // will 8e actual document8tion on like, what the return format + // looks like soon, once we implement a 8unch of other pages and + // are certain what they actually, uh, will look like, in the end.* + // + // 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 writes; + { + let error = false; + + writes = 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.every(entry => Array.isArray(entry))) { + if (!( + fns.every(entry => entry.every(obj => typeof obj === 'object')) && + fns.every(entry => entry.every(obj => { + const result = validateWriteObject(obj); + if (result.error) { + logError`Validating write object failed: ${result.error}`; + return false; + } else { + return true; + } + })) + )) { + logError`${fn.name} uses updated format, but entries are invalid!`; + error = true; + } + + return fns.flatMap(writes => writes); + } else if (fns.some(fn => typeof fn !== 'function')) { + logError`${fn.name} didn't return all functions or all arrays!`; + error = true; + } + + return fns; + }); + + if (error) { + return; + } + + // The modern(TM) return format for each writeThingPages function is an + // array of arrays, each of which's items are 8ig Complicated Objects + // that 8asically look like {type, path, content}. 8ut surprise, these + // aren't actually implemented in most places yet! So, we transform + // stuff in the old format here. 'Scept keep in mind, the OLD FORMAT + // doesn't really give us most of the info we want for Cool And Modern + // Reasons, so they're going into a fancy {type: 'legacy'} sort of + // o8ject, with a plain {write} property for, uh, the writing stuff, + // same as usual. + // + // I promise this document8tion will get 8etter when we make progress + // actually moving old pages over. Also it'll 8e hecks of less work + // than previous restructures, don't worry. + writes = writes.map(entry => + typeof entry === 'object' ? entry : + typeof entry === 'function' ? {type: 'legacy', write: entry} : + {type: 'wut', entry}); + + const wut = writes.filter(({ type }) => type === 'wut'); + if (wut.length) { + // Oh g*d oh h*ck. + logError`Uhhhhh writes contains something 8esides o8jects and functions?`; + logError`Definitely a 8ug!`; + console.log(wut); + return; + } + } + + const localizedWrites = writes.filter(({ type }) => type === 'page' || type === 'legacy'); + const dataWrites = writes.filter(({ type }) => type === 'data'); + + await progressPromiseAll(`Writing data files shared across languages.`, queue( + // TODO: This only supports one <>-style argument. + dataWrites.map(({path, data}) => () => writeData(path[0], path[1], data())), + queueSize + )); + + await wrapLanguages(async ({strings, ...opts}, i, entries) => { + console.log(`\x1b[34;1m${ + (`[${i + 1}/${entries.length}] ${strings.code} (-> /${opts.baseDirectory}) ` + .padEnd(60, '-')) + }\x1b[0m`); + await progressPromiseAll(`Writing ${strings.code}`, queue( + localizedWrites.map(({type, ...props}) => () => { + switch (type) { + case 'legacy': { + const { write } = props; + return write({strings, ...opts}); + } + case 'page': { + const { path, page } = props; + // TODO: This only supports one <>-style argument. + return opts.writePage(path[0], path[1], ({to}) => page({strings, to})); + } + } + }), + queueSize + )); + }, writeOneLanguage); + + decorateTime.displayTime(); + + // The single most important step. + logInfo`Written!`; +} + +main().catch(error => console.error(error)); diff --git a/src/util/cli.js b/src/util/cli.js new file mode 100644 index 00000000..77711566 --- /dev/null +++ b/src/util/cli.js @@ -0,0 +1,210 @@ +// Utility functions for CLI- and de8ugging-rel8ted stuff. +// +// A 8unch of these depend on process.stdout 8eing availa8le, so they won't +// work within the 8rowser. + +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`); +}; + +export const logInfo = logColor(2); +export const logWarn = logColor(33); +export const logError = logColor(31); + +// Stolen as #@CK from mtui! +export async function parseOptions(options, optionDescriptorMap) { + // This function is sorely lacking in comments, but the basic usage is + // as such: + // + // options is the array of options you want to process; + // optionDescriptorMap is a mapping of option names to objects that describe + // the expected value for their corresponding options. + // Returned is a mapping of any specified option names to their values, or + // a process.exit(1) and error message if there were any issues. + // + // Here are examples of optionDescriptorMap to cover all the things you can + // do with it: + // + // optionDescriptorMap: { + // 'telnet-server': {type: 'flag'}, + // 't': {alias: 'telnet-server'} + // } + // + // options: ['t'] -> result: {'telnet-server': true} + // + // optionDescriptorMap: { + // 'directory': { + // type: 'value', + // validate(name) { + // // const whitelistedDirectories = ['apple', 'banana'] + // if (whitelistedDirectories.includes(name)) { + // return true + // } else { + // return 'a whitelisted directory' + // } + // } + // }, + // 'files': {type: 'series'} + // } + // + // ['--directory', 'apple'] -> {'directory': 'apple'} + // ['--directory', 'artichoke'] -> (error) + // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']} + // + // TODO: Be able to validate the values in a series option. + + const handleDashless = optionDescriptorMap[parseOptions.handleDashless]; + const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown]; + const result = Object.create(null); + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.startsWith('--')) { + // --x can be a flag or expect a value or series of values + let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x'] + let descriptor = optionDescriptorMap[name]; + if (!descriptor) { + if (handleUnknown) { + handleUnknown(option); + } else { + console.error(`Unknown option name: ${name}`); + process.exit(1); + } + continue; + } + if (descriptor.alias) { + name = descriptor.alias; + descriptor = optionDescriptorMap[name]; + } + if (descriptor.type === 'flag') { + result[name] = true; + } else if (descriptor.type === 'value') { + let value = option.slice(2).split('=')[1]; + if (!value) { + value = options[++i]; + if (!value || value.startsWith('-')) { + value = null; + } + } + if (!value) { + console.error(`Expected a value for --${name}`); + process.exit(1); + } + result[name] = value; + } else if (descriptor.type === 'series') { + if (!options.slice(i).includes(';')) { + console.error(`Expected a series of values concluding with ; (\\;) for --${name}`); + process.exit(1); + } + const endIndex = i + options.slice(i).indexOf(';'); + result[name] = options.slice(i + 1, endIndex); + i = endIndex; + } + if (descriptor.validate) { + const validation = await descriptor.validate(result[name]); + if (validation !== true) { + console.error(`Expected ${validation} for --${name}`); + process.exit(1); + } + } + } else if (option.startsWith('-')) { + // mtui doesn't use any -x=y or -x y format optionuments + // -x will always just be a flag + let name = option.slice(1); + let descriptor = optionDescriptorMap[name]; + if (!descriptor) { + if (handleUnknown) { + handleUnknown(option); + } else { + console.error(`Unknown option name: ${name}`); + process.exit(1); + } + continue; + } + if (descriptor.alias) { + name = descriptor.alias; + descriptor = optionDescriptorMap[name]; + } + if (descriptor.type === 'flag') { + result[name] = true; + } else { + console.error(`Use --${name} (value) to specify ${name}`); + process.exit(1); + } + } else if (handleDashless) { + handleDashless(option); + } + } + return result; +} + +export const handleDashless = Symbol(); +export const handleUnknown = Symbol(); + +export function decorateTime(functionToBeWrapped) { + const fn = function(...args) { + const start = Date.now(); + const ret = functionToBeWrapped(...args); + const end = Date.now(); + fn.timeSpent += end - start; + fn.timesCalled++; + return ret; + }; + + fn.wrappedName = functionToBeWrapped.name; + fn.timeSpent = 0; + fn.timesCalled = 0; + fn.displayTime = function() { + const averageTime = fn.timeSpent / fn.timesCalled; + console.log(`\x1b[1m${fn.wrappedName}(...):\x1b[0m ${fn.timeSpent} ms / ${fn.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`); + }; + + decorateTime.decoratedFunctions.push(fn); + + return fn; +} + +decorateTime.decoratedFunctions = []; +decorateTime.displayTime = function() { + if (decorateTime.decoratedFunctions.length) { + console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); + for (const fn of decorateTime.decoratedFunctions) { + fn.displayTime(); + } + } +}; + +export function progressPromiseAll(msgOrMsgFn, array) { + if (!array.length) { + return Promise.resolve([]); + } + + const msgFn = (typeof msgOrMsgFn === 'function' + ? msgOrMsgFn + : () => msgOrMsgFn); + + let done = 0, total = array.length; + process.stdout.write(`\r${msgFn()} [0/${total}]`); + const start = Date.now(); + return Promise.all(array.map(promise => promise.then(val => { + done++; + // const pc = `${done}/${total}`; + const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); + if (done === total) { + const time = Date.now() - start; + process.stdout.write(`\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`) + } else { + process.stdout.write(`\r${msgFn()} [${pc}] `); + } + return val; + }))); +} diff --git a/src/util/colors.js b/src/util/colors.js new file mode 100644 index 00000000..1df591bf --- /dev/null +++ b/src/util/colors.js @@ -0,0 +1,47 @@ +// Color and theming utility functions! Handy. + +// Graciously stolen from https://stackoverflow.com/a/54071699! ::::) +// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1] +export function rgb2hsl(r, g, b) { + let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1)); + let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n)); + return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2]; +} + +export function getColors(primary) { + const [ r, g, b ] = primary.slice(1) + .match(/[0-9a-fA-F]{2,2}/g) + .slice(0, 3) + .map(val => parseInt(val, 16) / 255); + const [ h, s, l ] = rgb2hsl(r, g, b); + const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`; + + return {primary, dim}; +} + +export function getLinkThemeString(color) { + if (!color) return ''; + + const { primary, dim } = getColors(color); + return `--primary-color: ${primary}; --dim-color: ${dim}`; +} + +export function getThemeString(color, additionalVariables = []) { + if (!color) return ''; + + const { primary, dim } = getColors(color); + + const variables = [ + `--primary-color: ${primary}`, + `--dim-color: ${dim}`, + ...additionalVariables + ].filter(Boolean); + + if (!variables.length) return ''; + + return ( + `:root {\n` + + variables.map(line => ` ` + line + ';\n').join('') + + `}` + ); +} diff --git a/src/util/html.js b/src/util/html.js new file mode 100644 index 00000000..4895301b --- /dev/null +++ b/src/util/html.js @@ -0,0 +1,92 @@ +// Some really simple functions for formatting HTML content. + +// Non-comprehensive. ::::P +export const selfClosingTags = ['br', 'img']; + +// Pass to tag() as an attri8utes key to make tag() return a 8lank string +// if the provided content is empty. Useful for when you'll only 8e showing +// an element according to the presence of content that would 8elong there. +export const onlyIfContent = Symbol(); + +export function tag(tagName, ...args) { + const selfClosing = selfClosingTags.includes(tagName); + + let openTag; + let content; + let attrs; + + if (typeof args[0] === 'object' && !Array.isArray(args[0])) { + attrs = args[0]; + content = args[1]; + } else { + content = args[0]; + } + + if (selfClosing && content) { + throw new Error(`Tag <${tagName}> is self-closing but got content!`); + } + + if (attrs?.[onlyIfContent] && !content) { + return ''; + } + + if (attrs) { + const attrString = attributes(args[0]); + if (attrString) { + openTag = `${tagName} ${attrString}`; + } + } + + if (!openTag) { + openTag = tagName; + } + + if (Array.isArray(content)) { + content = content.filter(Boolean).join('\n'); + } + + if (content) { + if (content.includes('\n')) { + return ( + `<${openTag}>\n` + + content.split('\n').map(line => ' ' + line + '\n').join('') + + `</${tagName}>` + ); + } else { + return `<${openTag}>${content}</${tagName}>`; + } + } else { + if (selfClosing) { + return `<${openTag}>`; + } else { + return `<${openTag}></${tagName}>`; + } + } +} + +export function escapeAttributeValue(value) { + return value + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function attributes(attribs) { + return Object.entries(attribs) + .map(([ key, val ]) => { + if (!val) + return [key, val]; + else if (typeof val === 'string' || typeof val === 'boolean') + return [key, val]; + else if (typeof val === 'number') + return [key, val.toString()]; + else if (Array.isArray(val)) + return [key, val.join(' ')]; + else + throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); + }) + .filter(([ key, val ]) => val) + .map(([ key, val ]) => (typeof val === 'boolean' + ? `${key}` + : `${key}="${escapeAttributeValue(val)}"`)) + .join(' '); +} diff --git a/src/util/link.js b/src/util/link.js new file mode 100644 index 00000000..e5c3c596 --- /dev/null +++ b/src/util/link.js @@ -0,0 +1,67 @@ +// This file is essentially one level of a8straction a8ove urls.js (and the +// urlSpec it gets its paths from). It's a 8unch of utility functions which +// take certain types of wiki data o8jects (colloquially known as "things") +// and return actual <a href> HTML link tags. +// +// The functions we're cre8ting here (all factory-style) take a "to" argument, +// which is roughly a function which takes a urlSpec key and spits out a path +// to 8e stuck in an href or src or suchever. There are also a few other +// options availa8le in all the functions, making a common interface for +// gener8ting just a8out any link on the site. + +import * as html from './html.js' +import { getLinkThemeString } from './colors.js' + +const linkHelper = (hrefFn, {color = true, attr = null} = {}) => + (thing, { + strings, to, + text = '', + class: className = '', + hash = '' + }) => ( + html.tag('a', { + ...attr ? attr(thing) : {}, + href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''), + style: color ? getLinkThemeString(thing.color) : '', + class: className + }, text || thing.name) + ); + +const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => + linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { + attr: thing => ({ + ...attr ? attr(thing) : {}, + ...expose ? {[expose]: thing.directory} : {} + }), + ...conf + }); + +const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); +const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + 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', {expose: 'data-track'}), + + media: linkPathname('media.path', {color: false}), + root: linkPathname('shared.path', {color: false}), + data: linkPathname('data.path', {color: false}), + site: linkPathname('localized.path', {color: false}) +}; + +export default link; diff --git a/src/util/node-utils.js b/src/util/node-utils.js new file mode 100644 index 00000000..d660612e --- /dev/null +++ b/src/util/node-utils.js @@ -0,0 +1,27 @@ +// Utility functions which are only relevant to particular Node.js constructs. + +// Very cool function origin8ting in... http-music pro8a8ly! +// Sorry if we happen to 8e violating past-us's copyright, lmao. +export function promisifyProcess(proc, showLogging = true) { + // Takes a process (from the child_process module) and returns a promise + // that resolves when the process exits (or rejects, if the exit code is + // non-zero). + // + // Ayy look, no alpha8etical second letter! Couldn't tell this was written + // like three years ago 8efore I was me. 8888) + + return new Promise((resolve, reject) => { + if (showLogging) { + proc.stdout.pipe(process.stdout); + proc.stderr.pipe(process.stderr); + } + + proc.on('exit', code => { + if (code === 0) { + resolve(); + } else { + reject(code); + } + }) + }) +} diff --git a/src/util/sugar.js b/src/util/sugar.js new file mode 100644 index 00000000..9970bad1 --- /dev/null +++ b/src/util/sugar.js @@ -0,0 +1,70 @@ +// Syntactic sugar! (Mostly.) +// Generic functions - these are useful just a8out everywhere. +// +// Friendly(!) disclaimer: these utility functions haven't 8een tested all that +// much. Do not assume it will do exactly what you want it to do in all cases. +// It will likely only do exactly what I want it to, and only in the cases I +// decided were relevant enough to 8other handling. + +// Apparently JavaScript doesn't come with a function to split an array into +// chunks! Weird. Anyway, this is an awesome place to use a generator, even +// though we don't really make use of the 8enefits of generators any time we +// actually use this. 8ut it's still awesome, 8ecause I say so. +export function* splitArray(array, fn) { + let lastIndex = 0; + while (lastIndex < array.length) { + let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item)); + if (nextIndex === -1) { + nextIndex = array.length; + } + yield array.slice(lastIndex, nextIndex); + // Plus one because we don't want to include the dividing line in the + // next array we yield. + lastIndex = nextIndex + 1; + } +}; + +export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); + +export const filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n'); + +export const unique = arr => Array.from(new Set(arr)); + +// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. +export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); + +// Nothin' more to it than what it says. Runs a function in-place. Provides an +// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to +// open a scope and run some statements while inside an existing expression. +export const call = fn => fn(); + +export function queue(array, max = 50) { + if (max === 0) { + return array.map(fn => fn()); + } + + const begin = []; + let current = 0; + const ret = array.map(fn => new Promise((resolve, reject) => { + begin.push(() => { + current++; + Promise.resolve(fn()).then(value => { + current--; + if (current < max && begin.length) { + begin.shift()(); + } + resolve(value); + }, reject); + }); + })); + + for (let i = 0; i < max && begin.length; i++) { + begin.shift()(); + } + + return ret; +} + +export function delay(ms) { + return new Promise(res => setTimeout(res, ms)); +} diff --git a/src/util/urls.js b/src/util/urls.js new file mode 100644 index 00000000..f0f9cdb1 --- /dev/null +++ b/src/util/urls.js @@ -0,0 +1,102 @@ +// Code that deals with URLs (really the pathnames that get referenced all +// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which +// is in charge of pre-gener8ting a complete network of template strings +// which can really quickly take su8stitute parameters to link from any one +// place to another; 8ut there are also a few other utilities, too. +// +// Nota8ly, everything here is string-8ased, for gener8ting and transforming +// actual path strings. More a8stract operations using wiki data o8jects is +// the domain of link.js. + +import * as path from 'path'; +import { withEntries } from './sugar.js'; + +export function generateURLs(urlSpec) { + const getValueForFullKey = (obj, fullKey, prop = null) => { + const [ groupKey, subKey ] = fullKey.split('.'); + if (!groupKey || !subKey) { + throw new Error(`Expected group key and subkey (got ${fullKey})`); + } + + if (!obj.hasOwnProperty(groupKey)) { + throw new Error(`Expected valid group key (got ${groupKey})`); + } + + const group = obj[groupKey]; + + if (!group.hasOwnProperty(subKey)) { + throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); + } + + return { + value: group[subKey], + group + }; + }; + + const generateTo = (fromPath, fromGroup) => { + const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + + const pathHelper = (toPath, toGroup) => { + let target = toPath; + + let argIndex = 0; + target = target.replaceAll('<>', () => `<${argIndex++}>`); + + if (toGroup.prefix !== fromGroup.prefix) { + // TODO: Handle differing domains in prefixes. + target = rebasePrefix + (toGroup.prefix || '') + target; + } + + return (path.relative(fromPath, target) + + (toPath.endsWith('/') ? '/' : '')); + }; + + const groupSymbol = Symbol(); + + const groupHelper = urlGroup => ({ + [groupSymbol]: urlGroup, + ...withEntries(urlGroup.paths, entries => entries + .map(([key, path]) => [key, pathHelper(path, urlGroup)])) + }); + + const relative = withEntries(urlSpec, entries => entries + .map(([key, urlGroup]) => [key, groupHelper(urlGroup)])); + + const to = (key, ...args) => { + const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key) + let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]); + + // Kinda hacky lol, 8ut it works. + const missing = result.match(/<([0-9]+)>/g); + if (missing) { + throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`); + } + + return result; + }; + + return {to, relative}; + }; + + const generateFrom = () => { + const map = withEntries(urlSpec, entries => entries + .map(([key, group]) => [key, withEntries(group.paths, entries => entries + .map(([key, path]) => [key, generateTo(path, group)]) + )])); + + const from = key => getValueForFullKey(map, key).value; + + return {from, map}; + }; + + return generateFrom(); +} + +const thumbnailHelper = name => file => + file.replace(/\.(jpg|png)$/, name + '.jpg'); + +export const thumb = { + medium: thumbnailHelper('.medium'), + small: thumbnailHelper('.small') +}; diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js new file mode 100644 index 00000000..e76722bd --- /dev/null +++ b/src/util/wiki-data.js @@ -0,0 +1,126 @@ +// Utility functions for interacting with wiki data. + +// Generic value operations + +export function getKebabCase(name) { + return name + .split(' ') + .join('-') + .replace(/&/g, 'and') + .replace(/[^a-zA-Z0-9\-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +export function chunkByConditions(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; +} + +export function chunkByProperties(array, properties) { + return chunkByConditions(array, properties.map(p => (a, b) => { + if (a[p] instanceof Date && b[p] instanceof Date) + return +a[p] !== +b[p]; + + if (a[p] !== b[p]) return true; + + // Not sure if this line is still necessary with the specific check for + // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? + if (a[p] != b[p]) return true; + + return false; + })) + .map(chunk => ({ + ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])), + chunk + })); +} + +// Sorting functions + +export function 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; +} + +// This function was originally made to sort just al8um data, 8ut its exact +// code works fine for sorting tracks too, so I made the varia8les and names +// more general. +export function sortByDate(data) { + // Just to 8e clear: sort is a mutating function! I only return the array + // 8ecause then you don't have to define it as a separate varia8le 8efore + // passing it into this function. + return data.sort((a, b) => a.date - b.date); +} + +// Same details as the sortByDate, 8ut for covers~ +export function sortByArtDate(data) { + return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date)); +} + +// Specific data utilities + +// This gets all the track o8jects defined in every al8um, and sorts them 8y +// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore +// you pass it to this function, 8ut individual tracks can have their own +// original release d8, distinct from the al8um's d8. I allowed that 8ecause +// in Homestuck, the first four Vol.'s were com8ined into one al8um really +// early in the history of the 8andcamp, and I still want to use that as the +// al8um listing (not the original four al8um listings), 8ut if I only did +// that, all the tracks would 8e sorted as though they were released at the +// same time as the compilation al8um - i.e, after some other al8ums (including +// Vol.'s 5 and 6!) were released. That would mess with chronological listings +// including tracks from multiple al8ums, like artist pages. So, to fix that, +// I gave tracks an Original Date field, defaulting to the release date of the +// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can +// 8e used for other projects too, like if you wanted to have an al8um listing +// compiling a 8unch of songs with radically different & interspersed release +// d8s, 8ut still keep the al8um listing in a specific order, since that isn't +// sorted 8y date. +export function getAllTracks(albumData) { + return sortByDate(albumData.flatMap(album => album.tracks)); +} + +export function getArtistNumContributions(artist) { + return ( + artist.tracks.asAny.length + + artist.albums.asCoverArtist.length + + (artist.flashes ? artist.flashes.asContributor.length : 0) + ); +} + +export function getArtistCommentary(artist, {justEverythingMan}) { + return justEverythingMan.filter(thing => + (thing?.commentary + .replace(/<\/?b>/g, '') + .includes('<i>' + artist.name + ':</i>'))); +} |