« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/upd8.js
diff options
context:
space:
mode:
Diffstat (limited to 'upd8.js')
-rw-r--r--upd8.js765
1 files changed, 765 insertions, 0 deletions
diff --git a/upd8.js b/upd8.js
new file mode 100644
index 0000000..8e43e7e
--- /dev/null
+++ b/upd8.js
@@ -0,0 +1,765 @@
+// HEY N8RDS!
+//
+// This is one of the 8ACKEND FILES. It's not used anywhere on the actual site
+// you are might 8e 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.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const util = require('util');
+
+// 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.
+const fixWS = require('fix-whitespace');
+// Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
+// crunch. THAT is my 8est li8rary.
+
+// The require function just returns whatever the module exports, so there's
+// no reason you can't wrap it in some decorator right out of the 8ox. Which is
+// exactly what we do here.
+const mkdirp = util.promisify(require('mkdirp'));
+
+// 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.
+const readdir = util.promisify(fs.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! 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."
+const readFile = util.promisify(fs.readFile);
+const writeFile = util.promisify(fs.writeFile);
+const access = util.promisify(fs.access);
+
+const {
+    joinNoOxford,
+    splitArray
+} = require('./upd8-util');
+
+const SITE_TITLE = 'Homestuck';
+
+// The folder you stick your random downloads in is called "Downloads", yeah?
+// (Unless you sort all your downloads into manual, organized locations. Good
+// for you.) It might just 8e me, 8ut I've always said "the downloads folder."
+// And yet here I say "the al8um directory!" It's like we've gotten "Downloads"
+// as a name so ingrained into our heads that we use it like an adjective too,
+// even though it doesn't make any grammatical sense to do so. Anyway, also for
+// contrast, note that this folder is called "album" and not "albums". To 8e
+// clear, that IS against how I normally name folders - 8ut here, I'm doing it
+// to match 8andcamp's URL schema: "/album/genesis-frog" instead of "/albums
+// /genesis-frog." That seems to kind of 8e a standard for a lot of sites?
+// 8ut only KIND OF. Twitter has the weird schema of "/<user>/status/<id>"
+// (not "statuses")... 8ut it also has "/<user>/likes", so I really have no
+// idea how people decide to make their URL schemas consistent. Luckily I don't
+// have to worry a8out any of that, 8ecause I'm just stealing 8andcamp.
+const ALBUM_DIRECTORY = 'album';
+const TRACK_DIRECTORY = 'track';
+const ARTIST_DIRECTORY = 'artist';
+const ARTIST_AVATAR_DIRECTORY = 'artist-avatar';
+const GRID_DIRECTORY = 'grid';
+
+// Might ena8le this later... we'll see! Eventually. May8e.
+const ENABLE_ARTIST_AVATARS = false;
+
+const ALBUM_DATA_FILE = 'album.txt';
+
+const CSS_FILE = 'site.css';
+const GRID_CSS_FILE = 'grid-site.css';
+
+// 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 findAlbumDataFiles() {
+    // Promises suck. This could pro8a8ly 8e written with async/await and an
+    // ordinary for loop, 8ut I'm using promises 8ecause they let all the
+    // folders get read simultaneously.
+    // ...Actually screw it, let's use async/await AND promises.
+    /*
+    return readdir(ALBUM_DIRECTORY)
+        .then(albums => Promise.all(albums
+            .map(album => readdir(path.join(ALBUM_DIRECTORY, album))
+                .then(files => files.includes(ALBUM_DATA_FILE) ? path.join(ALBUM_DIRECTORY, album, ALBUM_DATA_FILE) : null))))
+        .then(paths => paths.filter(Boolean));
+    */
+
+    const albums = await readdir(ALBUM_DIRECTORY);
+
+    const paths = await Promise.all(albums.map(async album => {
+        // Argua8ly terri8le/am8iguous varia8le naming. Too 8ad!
+        const albumDirectory = path.join(ALBUM_DIRECTORY, album);
+        const files = await readdir(albumDirectory);
+        if (files.includes(ALBUM_DATA_FILE)) {
+            return path.join(albumDirectory, ALBUM_DATA_FILE);
+        }
+        // The old code returns null if the data file isn't present, 8ut that's
+        // not actually necessary. We just need some falsey value, and the
+        // implied undefined when you don't explicitly return anything works.
+    }));
+
+    return paths.filter(Boolean);
+}
+
+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 by 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 probably supposed to, like, search for a header somewhere in the
+    // album contents, to make sure it's trying to be 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 isSeparatorLine = line => /^-{8,}$/.test(line);
+
+    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(splitArray(contentLines, isSeparatorLine));
+
+    const initialLines = contentLines.slice(0, contentLines.findIndex(isSeparatorLine));
+
+    const getBasicField = (lines, name) => {
+        const line = lines.find(line => line.startsWith(name + ':'));
+        return line && line.slice(name.length + 1).trim();
+    };
+
+    const 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));
+    };
+
+    const albumSection = sections[0];
+    const albumName = getBasicField(albumSection, 'Album');
+    const albumArtists = getListField(albumSection, 'Artists') || getListField(albumSection, 'Artist');
+    const albumDate = getBasicField(albumSection, 'Date');
+    let albumDirectory = getBasicField(albumSection, 'Directory');
+
+    // I don't like these varia8le names. I'm sorry. -- I only really use the
+    // FG theme in the Homestuck wiki site (at least as of this writing), since
+    // without any styles consistent across the site, it kinda ends up losing
+    // any coherence of a single we8site and is a 8it distracting to navig8.
+    // 8ut these are implemented if you ever want to mess with them in the
+    // future or whatever.
+    const albumColorFG = getBasicField(albumSection, 'FG') || '#0088ff';
+    const albumColorBG = getBasicField(albumSection, 'BG') || '#222222';
+    const albumTheme = getBasicField(albumSection, 'Theme') || 0;
+
+    if (!albumName) {
+        return {error: 'Expected "Album" (name) field!'};
+    }
+
+    if (!albumDate) {
+        return {error: 'Expected "Date" field!'};
+    }
+
+    if (isNaN(Date.parse(albumDate))) {
+        return {error: `Invalid Date field: "${albumDate}"`};
+    }
+
+    const dateValue = new Date(albumDate);
+
+    if (!albumDirectory) {
+        albumDirectory = getKebabCase(albumName);
+    }
+
+    // We need to declare this varia8le 8efore the al8um varia8le, 8ecause
+    // that varia8le references this one. 8ut we won't actually fill in the
+    // contents of the tracks varia8le until after creating the al8um one,
+    // 8ecause each track o8ject will (8ack-)reference the al8um o8ject.
+    const tracks = [];
+
+    const albumData = {
+        name: albumName,
+        date: dateValue,
+        artists: albumArtists,
+        directory: albumDirectory,
+        theme: {
+            fg: albumColorFG,
+            bg: albumColorBG,
+            theme: albumTheme
+        },
+        tracks
+    };
+
+    for (const section of sections.slice(1)) {
+        // Just skip empty sections. Sometimes I paste a bunch of dividers,
+        // and this lets the empty sections doing that creates (temporarily)
+        // exist without raising an error.
+        if (!section.filter(Boolean).length) {
+            continue;
+        }
+
+        const trackName = getBasicField(section, 'Track');
+        const originalDate = getBasicField(section, 'Original Date');
+        let trackArtists = getListField(section, 'Artists') || getListField(section, 'Artist');
+        let trackContributors = getListField(section, 'Contributors') || [];
+        let trackDirectory = getBasicField(section, 'Directory');
+
+        if (!trackName) {
+            return {error: 'A track section is missing the "Track" (name) field.'};
+        }
+
+        if (!trackArtists) {
+            // 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 (albumArtists) {
+                trackArtists = albumArtists;
+            } else {
+                return {error: `The track "${trackName}" is missing the "Artist" field.`};
+            }
+        }
+
+        if (!trackDirectory) {
+            trackDirectory = getKebabCase(trackName);
+        }
+
+        trackContributors = trackContributors.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;
+            if (!what) {
+                console.log(trackName, '-\t', albumName, '-\t', who);
+            }
+            return {who, what};
+        });
+
+        const badContributor = trackContributors.find(val => typeof val === 'string');
+        if (badContributor) {
+            return {error: `The track "${trackName}" has an incorrectly formatted contributor, "${badContributor}".`};
+        }
+
+        let date;
+        if (originalDate) {
+            if (isNaN(Date.parse(originalDate))) {
+                return {error: `The track "${trackName}"'s has an invalid "Original Date" field: "${originalDate}"`};
+            }
+            date = new Date(originalDate);
+        } else {
+            date = dateValue;
+        }
+
+        const trackURLs = (getListField(section, 'URLs') || []).filter(Boolean);
+
+        if (!trackURLs.length) {
+            return {error: `The track "${trackName}" should have at least one URL specified.`};
+        }
+
+        tracks.push({
+            name: trackName,
+            artists: trackArtists,
+            contributors: trackContributors,
+            date,
+            directory: trackDirectory,
+            urls: trackURLs,
+            // 8ack-reference the al8um o8ject! This is very useful for when
+            // we're outputting the track pages.
+            album: albumData
+        });
+    }
+
+    return albumData;
+}
+
+// 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 be 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.
+function getAllTracks(albumData) {
+    return sortByDate(albumData.reduce((acc, album) => acc.concat(album.tracks), []));
+}
+
+// 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.
+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);
+}
+
+function getDateString({ date }) {
+    return date.toLocaleDateString();
+}
+
+function getArtistNames(albumData) {
+    return Array.from(new Set(albumData.reduce((acc, album) => acc.concat(album.tracks.reduce((acc, track) => acc.concat(track.artists), [])), [])));
+}
+
+async function writeTopIndexPage(albumData) {
+    // This is hard-coded, i.e. we don't do a path.join(ROOT_DIRECTORY).
+    // May8e that's 8ad? Yes, definitely 8ad. 8ut I'm too lazy to fix it...
+    // for now. TM.
+    await writeFile('index.html', fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${SITE_TITLE}</title>
+                <link rel="stylesheet" href="site.css">
+            </head>
+            <body id="top-index">
+                <div id="content">
+                    <h1>${SITE_TITLE}</h1>
+                    <div class="grid-listing">
+                        ${albumData.map(album => fixWS`
+                            <a class="grid-item" href="${ALBUM_DIRECTORY}/${album.directory}/index.html" style="${getThemeString(album.theme)}">
+                                <img src="${getAlbumCover(album)}">
+                                <span>${album.name}</span>
+                            </a>
+                        `).join('\n')}
+                    </div>
+                </div>
+            </body>
+        </html>
+    `);
+}
+
+// This function title is my gr8test work of art.
+async function writeIndexAndTrackPagesForAlbum(album, albumData) {
+    await writeAlbumPage(album);
+    await Promise.all(album.tracks.map(track => writeTrackPage(track, albumData)));
+}
+
+async function writeAlbumPage(album) {
+    const albumDirectory = path.join(ALBUM_DIRECTORY, album.directory);
+    await mkdirp(albumDirectory);
+    await writeFile(path.join(albumDirectory, 'index.html'), fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${album.name}</title>
+                <base href="${path.relative(albumDirectory, '')}">
+                <link rel="stylesheet" href="${CSS_FILE}">
+            </head>
+            <body style="${getThemeString(album.theme)}">
+                <div id="sidebar">
+                    ${generateSidebarForAlbum(album)}
+                </div>
+                <div id="content">
+                    <a id="cover-art" href="${getAlbumCover(album)}"><img src="${getAlbumCover(album)}"></a>
+                    <h1>${album.name}</h1>
+                    <p>
+                        ${album.artist && `By ${getArtistString(album.artists)}.<br>`}
+                        Released ${getDateString(album)}.
+                    </p>
+                    <ol>
+                        ${album.tracks.map(track => fixWS`
+                            <li>
+                                <a href="${TRACK_DIRECTORY}/${track.directory}/index.html">${track.name}</a>
+                                ${track.artists !== album.artists && fixWS`
+                                    <i>by ${getArtistString(track.artists)}</i>
+                                `}
+                            </li>
+                        `).join('\n')}
+                    </ol>
+                </div>
+            </body>
+        </html>
+    `);
+}
+
+async function writeTrackPage(track, albumData) {
+    const artistNames = getArtistNames(albumData);
+    const allTracks = getAllTracks(albumData);
+    const trackDirectory = path.join(TRACK_DIRECTORY, track.directory);
+    await mkdirp(trackDirectory);
+    await writeFile(path.join(trackDirectory, 'index.html'), fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${track.name}</title>
+                <base href="${path.relative(trackDirectory, '')}">
+                <link rel="stylesheet" href="${CSS_FILE}">
+            </head>
+            <body style="${getThemeString(track.album.theme)}">
+                <div id="sidebar">
+                    ${generateSidebarForAlbum(track.album, track)}
+                </div>
+                <div id="content">
+                    <a href="${getTrackCover(track)}" id="cover-art"><img src="${getTrackCover(track)}"></a>
+                    <h1>${track.name}</h1>
+                    <p>
+                        By ${getArtistString(track.artists)}.<br>
+                        Released ${getDateString(track)}.
+                    </p>
+                    ${track.contributors.length && fixWS`
+                        <p>Contributors:</p>
+                        <ul>
+                            ${track.contributors.map(({ who, what }) => fixWS`
+                                <li>${artistNames.includes(who)
+                                    ? `<a href="${ARTIST_DIRECTORY}/${getArtistDirectory(who)}/index.html">${who}</a>`
+                                    : who
+                                } ${what && `(${getContributionString({what}, allTracks)})`}</li>
+                            `).join('\n')}
+                        </ul>
+                    `}
+                    <p>Listen:</p>
+                    <ul>
+                        ${track.urls.map(url => fixWS`
+                            <li><a href="${url}">${
+                                url.includes('bandcamp.com') ? 'Bandcamp' :
+                                url.includes('youtu') ? 'YouTube' :
+                                '(External)'
+                            }</a></li>
+                        `)}
+                    </ul>
+                </div>
+            </body>
+        </html>
+    `);
+}
+
+async function writeArtistPages(albumData) {
+    await Promise.all(getArtistNames(albumData).map(artistName => writeArtistPage(artistName, albumData)));
+}
+
+async function writeArtistPage(artistName, albumData) {
+    const tracks = sortByDate(getAllTracks(albumData).filter(track => track.artists.includes(artistName) || track.contributors.some(({ who }) => who === artistName)));
+
+    // Shish!
+    const kebab = getArtistDirectory(artistName);
+
+    const artistDirectory = path.join(ARTIST_DIRECTORY, kebab);
+    await mkdirp(artistDirectory);
+    await writeFile(path.join(artistDirectory, 'index.html'), fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${artistName}</title>
+                <base href="${path.relative(artistDirectory, '')}">
+                <link rel="stylesheet" href="${CSS_FILE}">
+            </head>
+            <body>
+                <div id="content">
+                    ${ENABLE_ARTIST_AVATARS && await access(path.join(ARTIST_AVATAR_DIRECTORY, kebab + '.jpg')).then(() => true, () => false) && fixWS`
+                        <a id="cover-art" href="${ARTIST_AVATAR_DIRECTORY}/${getArtistDirectory(artistName)}.jpg"><img src="${ARTIST_AVATAR_DIRECTORY}/${getArtistDirectory(artistName)}.jpg"></a>
+                    `}
+                    <h1>${artistName}</h1>
+                    <ol>
+                        ${tracks.map(track => fixWS`
+                            <li class="${!track.artists.includes(artistName) && `contributed ${track.contributors.filter(({ who }) => who === artistName).every(({ what }) => what && what.startsWith('[') && what.endsWith(']')) && 'contributed-only-original'}`}">
+                                <a href="${TRACK_DIRECTORY}/${track.directory}/index.html">${track.name}</a>
+                                ${track.artists.includes(artistName) && track.artists.length > 1 && `<span="contributed">(with ${getArtistString(track.artists.filter(a => a !== artistName))})</span>`}
+                                ${!track.artists.includes(artistName) && `<span class="contributed">(${track.contributors.filter(({ who }) => who === artistName).map(contrib => getContributionString(contrib, tracks)).join(', ') || 'contributed'})</span> `}
+                                <i>from <a href="${ALBUM_DIRECTORY}/${track.album.directory}/index.html" style="${getThemeString(track.album.theme)}">${track.album.name}</a></i>
+                            </li>
+                        `).join('\n')}
+                    </ol>
+                </div>
+            </body>
+        </html>
+    `);
+}
+
+// This function is terri8le. Sorry!
+function getContributionString({ what }, allTracks) {
+    return what
+        ? what.replace(/\[(.*?)\]/g, (match, name) =>
+            allTracks.some(track => track.name === name)
+                ? `<i><a href="${TRACK_DIRECTORY}/${allTracks.find(track => track.name === name).directory}/index.html">${name}</a></i>`
+                : `<i>${name}</i>`)
+        : '';
+}
+
+function getArtistString(artists) {
+    return joinNoOxford(artists.map(artist => fixWS`
+        <a href="${ARTIST_DIRECTORY}/${getArtistDirectory(artist)}/index.html">${artist}</a>
+    `));
+}
+
+function getThemeString({fg, bg, theme}) {
+    return `--fg-color: ${fg}; --bg-color: ${bg}; --theme: ${theme + ''}`;
+}
+
+// Terri8le hack: since artists aren't really o8jects and don't have proper
+// "directories", we just reformat the artist's name.
+function getArtistDirectory(artistName) {
+    return getKebabCase(artistName);
+}
+
+function getKebabCase(name) {
+    return name.split(' ').join('-').replace(/[^a-zA-Z0-9\-]/g, '').replace(/-{2,}/g, '-').toLowerCase();
+}
+
+function generateSidebarForAlbum(album, currentTrack = null) {
+    return fixWS`
+        <h2><a href="index.html">(Home)</a></h2>
+        <hr>
+        <h1><a href="${ALBUM_DIRECTORY}/${album.directory}/index.html">${album.name}</a></h1>
+        <ol>
+            ${album.tracks.map(track => fixWS`
+                <li class="${track === currentTrack ? 'current-track' : ''}"><a href="${TRACK_DIRECTORY}/${track.directory}/index.html">${track.name}</a></li>
+            `).join('\n')}
+        </ol>
+    `
+}
+
+// These two functions are sort of hard-coded ways to quickly gra8 the path to
+// cover arts, for em8edding witin the HTML. They're actually 8ig hacks,
+// 8ecause they assume the track and al8um directories are adjacent to each
+// other. I get to make that assumption on the responsi8ility that I la8el
+// these functions "hard-coded", which 8asically just means my future self and
+// anyone else trying to mess with this code can't 8lame me for my terri8le
+// decisions / laziness in figuring out a 8etter solution. That said, note to
+// future self: these only work from two levels above the root directory.
+// "O8viously," if you look at their implementation, 8ut if you don't... yeah.
+// You won't 8e a8le to call these for use in the lower level files.
+// ACTUALLY this means I really should just use a <base> element, which yes, I
+// have done before (on my 8log). That way all HTML files have the same root
+// for referenced files, and these functions work anywhere. The catch, then, is
+// that you have to have a "8ase directory" constant, and keep that accurate on
+// 8oth your development machine and the server you pu8lish this too. So, it's
+// a trade-off. 8ut it does mean much cleaner, more general-use functions.
+// Which is kind of the goal here, I suppose. --- Actually, hold on, I took a
+// look at the document8tion and apparently relative URLs are totally okay!
+// Com8ine that with path.relative and I think that should work as a way to
+// skip a 8ase directory constant. Neat!
+/*
+function getAlbumCover(album) {
+    return `../../${ALBUM_DIRECTORY}/${album.directory}/cover.png`;
+}
+function getTrackCover(track) {
+    return `../../${ALBUM_DIRECTORY}/${track.album.directory}/${track.directory}.png`;
+}
+*/
+
+function getAlbumCover(album) {
+    return `${ALBUM_DIRECTORY}/${album.directory}/cover.jpg`;
+}
+function getTrackCover(track) {
+    // Some al8ums don't have any track art at all, and in those, every track
+    // just inherits the al8um's own cover art.
+    // TODO: Don't hard-c8de this!
+    if ([
+        'homestuck-vol-5',
+        'squiddles',
+        'medium',
+        'symphony-impossible-to-play'
+    ].includes(track.album.directory)) {
+        return getAlbumCover(track.album);
+    } else {
+        return `${ALBUM_DIRECTORY}/${track.album.directory}/${track.directory}.jpg`;
+    }
+}
+
+// Super fancy, more interactive 8rowsing section of the site. May8e the
+// primary one in time???????? We'll see! For real.
+async function writeGridSite(albumData) {
+    await mkdirp(GRID_DIRECTORY);
+    await writeFile(path.join(GRID_DIRECTORY, 'index.html'), fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${SITE_TITLE}</title>
+                <base href="${path.relative(GRID_DIRECTORY, '')}">
+                <link rel="stylesheet" href="${GRID_CSS_FILE}">
+            </head>
+            <body>
+                <div class="grid-listing">
+                    ${albumData.map(album => fixWS`
+                        <a class="grid-item" href="${GRID_DIRECTORY}/${ALBUM_DIRECTORY}/${album.directory}/index.html">
+                            <img src="${getAlbumCover(album)}">
+                            <span>${album.name}</span>
+                        </a>
+                    `).join('\n')}
+                </div>
+            </body>
+        </html>
+    `);
+    await Promise.all(albumData.map(writeGridAlbumPage));
+}
+
+async function writeGridAlbumPage(album) {
+    const albumDirectory = path.join(GRID_DIRECTORY, ALBUM_DIRECTORY, album.directory);
+    await mkdirp(albumDirectory);
+    await writeFile(path.join(albumDirectory, 'index.html'), fixWS`
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta charset="utf-8">
+                <title>${album.name}</title>
+                <base href="${path.relative(albumDirectory, '')}">
+                <link rel="stylesheet" href="${GRID_CSS_FILE}">
+            </head>
+            <body>
+                <!--
+                <a id="cover-art" href="${getAlbumCover(album)}"><img src="${getAlbumCover(album)}"></a>
+                <h1>${album.name}</h1>
+                <p>
+                    ${album.artist && `By ${getArtistString(album.artists)}.<br>`}
+                    Released ${getDateString(album)}.
+                </p>
+                -->
+                <div class="grid-listing">
+                    ${album.tracks.map(track => fixWS`
+                        <a class="grid-item" href="${TRACK_DIRECTORY}/${track.directory}/index.html">
+                            <img src="${getTrackCover(track)}">
+                            <span>${track.name}<br>by ${joinNoOxford(track.artists)}</span>
+                        </a>
+                    `).join('\n')}
+                </div>
+            </body>
+        </html>
+    `);
+}
+
+async function main() {
+    // 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 findAlbumDataFiles();
+
+    // 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.
+    const albumData = await Promise.all(albumDataFiles.map(processAlbumDataFile));
+
+    sortByDate(albumData);
+
+    const errors = albumData.filter(obj => obj.error);
+    if (errors.length) {
+        for (const error of errors) {
+            console.log(error.error);
+        }
+        return;
+    }
+
+    await writeTopIndexPage(albumData);
+    await Promise.all(albumData.map(album => writeIndexAndTrackPagesForAlbum(album, albumData)));
+    await writeArtistPages(albumData);
+
+    // await writeGridSite(albumData);
+
+    // The single most important step.
+    console.log('Written!');
+}
+
+main().catch(error => console.error(error));