« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/upd8.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/upd8.js')
-rwxr-xr-xsrc/upd8.js500
1 files changed, 362 insertions, 138 deletions
diff --git a/src/upd8.js b/src/upd8.js
index 6f538d18..fd5a21ca 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -17,6 +17,9 @@
 //      going to 8e in. May8e JSON, 8ut more likely some weird custom format
 //      which will 8e a lot easier to edit.
 //
+//      Like three years later oh god: SURPISE! We went with the latter, but
+//      they're YAML now. Probably. Assuming that hasn't changed, yet.
+//
 //   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
@@ -28,40 +31,6 @@
 // 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';
@@ -75,6 +44,8 @@ import fixWS from 'fix-whitespace';
 // It stands for "HTML Entities", apparently. Cursed.
 import he from 'he';
 
+import yaml from 'js-yaml';
+
 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
@@ -118,6 +89,8 @@ import find from './util/find.js';
 import * as html from './util/html.js';
 import unbound_link, {getLinkThemeString} from './util/link.js';
 
+import Album from './thing/album.js';
+
 import {
     fancifyFlashURL,
     fancifyURL,
@@ -129,6 +102,7 @@ import {
     getAlbumStylesheet,
     getArtistString,
     getFlashGridHTML,
+    getFooterLocalizationLinks,
     getGridHTML,
     getRevealStringFromTags,
     getRevealStringFromWarnings,
@@ -137,6 +111,7 @@ import {
 } from './misc-templates.js';
 
 import {
+    color,
     decorateTime,
     logWarn,
     logInfo,
@@ -185,10 +160,15 @@ import {
 import {
     bindOpts,
     call,
+    filterAggregateAsync,
     filterEmptyLines,
+    mapAggregateAsync,
+    openAggregate,
     queue,
+    showAggregate,
     splitArray,
     unique,
+    withAggregate,
     withEntries
 } from './util/sugar.js';
 
@@ -208,6 +188,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 const CACHEBUST = 7;
 
+// MAKE THESE END IN YAML
 const WIKI_INFO_FILE = 'wiki-info.txt';
 const HOMEPAGE_INFO_FILE = 'homepage.txt';
 const ARTIST_DATA_FILE = 'artists.txt';
@@ -274,6 +255,11 @@ function splitLines(text) {
     return text.split(/\r\n|\r|\n/);
 }
 
+// REFERENCE CODE!
+// REFERENCE CODE!
+// REFERENCE CODE!
+// REFERENCE CODE!
+
 function* getSections(lines) {
     // ::::)
     const isSeparatorLine = line => /^-{8,}/.test(line);
@@ -285,9 +271,11 @@ function getBasicField(lines, name) {
     return line && line.slice(name.length + 1).trim();
 }
 
-function getDimensionsField(lines, name) {
-    const string = getBasicField(lines, name);
-    if (!string) return string;
+function parseDimensions(string) {
+    if (!string) {
+        return null;
+    }
+
     const parts = string.split(/[x,* ]+/g);
     if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
     const nums = parts.map(part => Number(part.trim()));
@@ -338,9 +326,7 @@ function getListField(lines, name) {
     return listLines.map(line => line.slice(2));
 };
 
-function getContributionField(section, name) {
-    let contributors = getListField(section, name);
-
+function parseContributors(contributors) {
     if (!contributors) {
         return null;
     }
@@ -737,10 +723,61 @@ function transformLyrics(text, {
     return outLines.join('\n');
 }
 
-function getCommentaryField(lines) {
-    const text = getMultilineField(lines, 'Commentary');
+// Use parseErrorFactory to declare different "types" of errors. By storing the
+// factory itself in an accessible location, the type of error may be detected
+// by comparing against its own factory property.
+function parseErrorFactory(annotation) {
+    return function factory(data = null) {
+        return {
+            error: true,
+            annotation,
+            data,
+            factory
+        };
+    };
+}
+
+function parseField(object, key, steps) {
+    let value = object[key];
+
+    for (const step of steps) {
+        try {
+            value = step(value);
+        } catch (error) {
+            throw parseField.stepError({
+                stepName: step.name,
+                stepError: error
+            });
+        }
+    }
+
+    return value;
+}
+
+parseField.stepError = parseErrorFactory('step failed');
+
+function assertFieldPresent(value) {
+    if (value === undefined || value === null) {
+        throw assertFieldPresent.missingField();
+    } else {
+        return value;
+    }
+}
+
+assertFieldPresent.missingField = parseErrorFactory('missing field');
+
+function assertValidDate(dateString, {optional = false} = {}) {
+    if (dateString && isNaN(Date.parse(dateString))) {
+        throw assertValidDate.invalidDate();
+    }
+    return value;
+}
+
+assertValidDate.invalidDate = parseErrorFactory('invalid date');
+
+function parseCommentary(text) {
     if (text) {
-        const lines = text.split('\n');
+        const lines = String(text).split('\n');
         if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
             return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
         }
@@ -748,8 +785,141 @@ function getCommentaryField(lines) {
     } else {
         return null;
     }
+}
+
+// General function for inputting a single document (usually loaded from YAML)
+// and outputting an instance of a provided Thing subclass.
+//
+// makeParseDocument is a factory function: the returned function will take a
+// document and apply the configuration passed to makeParseDocument in order to
+// construct a Thing subclass.
+function makeParseDocument(thingClass, {
+    // Optional early step for transforming field values before providing them
+    // to the Thing's update() method. This is useful when the input format
+    // (i.e. values in the document) differ from the format the actual Thing
+    // expects.
+    //
+    // Each key and value are a field name (not an update() property) and a
+    // function which takes the value for that field and returns the value which
+    // will be passed on to update().
+    fieldTransformations = {},
+
+    // Mapping of Thing.update() source properties to field names.
+    //
+    // Note this is property -> field, not field -> property. This is a
+    // shorthand convenience because properties are generally typical
+    // camel-cased JS properties, while fields may contain whitespace and be
+    // more easily represented as quoted strings.
+    propertyFieldMapping
+}) {
+    const knownFields = Object.values(propertyFieldMapping);
+
+    // Invert the property-field mapping, since it'll come in handy for
+    // assigning update() source values later.
+    const fieldPropertyMapping = Object.fromEntries(
+        (Object.entries(propertyFieldMapping)
+            .map(([ property, field ]) => [field, property])));
+
+    return function(document, {file = null}) {
+        const unknownFields = Object.keys(document)
+            .filter(field => !knownFields.includes(field));
+
+        if (unknownFields.length) {
+            throw new makeParseDocument.UnknownFieldsError(unknownFields);
+        }
+
+        const fieldValues = {};
+
+        for (const [ field, value ] of Object.entries(document)) {
+            if (Object.hasOwn(fieldTransformations, field)) {
+                fieldValues[field] = fieldTransformations[field](value);
+            } else {
+                fieldValues[field] = value;
+            }
+        }
+
+        const sourceProperties = {};
+
+        for (const [ field, value ] of Object.entries(fieldValues)) {
+            const property = fieldPropertyMapping[field];
+            sourceProperties[property] = value;
+        }
+
+        const thing = Reflect.construct(thingClass, []);
+
+        const C = color;
+        const filePart = file ? `(file: ${C.bright(C.blue(path.relative(dataPath, file)))})` : '';
+        withAggregate({message: `Errors applying ${C.green(thingClass.name)} properties ${filePart}`}, ({ call }) => {
+            for (const [ property, value ] of Object.entries(sourceProperties)) {
+                call(() => {
+                    thing[property] = value;
+                });
+            }
+        });
+
+        return thing;
+    };
+}
+
+makeParseDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
+    constructor(fields) {
+        super(`Unknown fields present: ${fields.join(', ')}`);
+        this.fields = fields;
+    }
 };
 
+processAlbumDataFile.parseDocument = makeParseDocument(Album, {
+    fieldTransformations: {
+        'Artists': parseContributors,
+        'Cover Artists': parseContributors,
+        'Default Track Cover Artists': parseContributors,
+        'Wallpaper Artists': parseContributors,
+        'Banner Artists': parseContributors,
+
+        'Date': value => new Date(value),
+        'Date Added': value => new Date(value),
+        'Cover Art Date': value => new Date(value),
+        'Default Track Cover Art Date': value => new Date(value),
+
+        'Banner Dimensions': parseDimensions,
+    },
+
+    propertyFieldMapping: {
+        name: 'Album',
+
+        color: 'Color',
+        directory: 'Directory',
+        urls: 'URLs',
+
+        artistContribsByRef: 'Artists',
+        coverArtistContribsByRef: 'Cover Artists',
+        trackCoverArtistContribsByRef: 'Default Track Cover Artists',
+
+        wallpaperArtistContribsByRef: 'Wallpaper Artists',
+        wallpaperStyle: 'Wallpaper Style',
+        wallpaperFileExtension: 'Wallpaper File Extension',
+
+        bannerArtistContribsByRef: 'Banner Artists',
+        bannerStyle: 'Banner Style',
+        bannerFileExtension: 'Banner File Extension',
+        bannerDimensions: 'Banner Dimensions',
+
+        date: 'Date',
+        trackArtDate: 'Default Track Cover Art Date',
+        coverArtDate: 'Cover Art Date',
+        dateAddedToWiki: 'Date Added',
+
+        hasTrackArt: 'Has Track Art',
+        isMajorRelease: 'Major Release',
+        isListedOnHomepage: 'Listed on Homepage',
+
+        aka: 'Also Released As',
+        groupsByRef: 'Groups',
+        artTagsByRef: 'Art Tags',
+        commentary: 'Commentary',
+    }
+});
+
 async function processAlbumDataFile(file) {
     let contents;
     try {
@@ -771,43 +941,54 @@ async function processAlbumDataFile(file) {
     // We'll just return more specific errors if it's missing necessary data
     // fields.
 
-    const contentLines = contents.split(/\r\n|\r|\n/);
+    const documents = yaml.loadAll(contents);
 
-    // 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 albumDoc = documents[0];
 
-    const albumSection = sections[0];
-    const album = {};
+    return processAlbumDataFile.parseDocument(albumDoc, {file});
 
-    album.name = getBasicField(albumSection, 'Album');
+    // --------------------------------------------------------------
+
+    // const album = {};
+
+    album.name = parseField(albumDoc, 'Album', [
+        assertFieldPresent
+    ]);
 
     if (!album.name) {
         return {error: `The file "${path.relative(dataPath, file)}" is missing the "Album" field - maybe this is a misplaced file instead of album data?`};
     }
 
-    album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist');
-    album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art');
-    album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style');
-    album.wallpaperFileExtension = getBasicField(albumSection, 'Wallpaper File Extension') || 'jpg';
-    album.bannerArtists = getContributionField(albumSection, 'Banner Art');
-    album.bannerStyle = getMultilineField(albumSection, 'Banner Style');
-    album.bannerFileExtension = getBasicField(albumSection, 'Banner File Extension') || 'jpg';
-    album.bannerDimensions = getDimensionsField(albumSection, 'Banner Dimensions');
-    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;
-    album.isListedOnHomepage = getBooleanField(albumSection, 'Listed on Homepage') ?? true;
+    // album.directory = albumDoc['Directory'];
+    // album.urls = albumDoc['URLs'] || [];
+
+    // album.artists = parseContributors(albumDoc['Artists']);
+
+    // album.date = albumDoc['Date'];
+    // album.trackArtDate = albumDoc['Track Art Date'] || album.date;
+    // album.coverArtDate = albumDoc['Cover Art Date'] || album.date;
+    // album.dateAdded = albumDoc['Date Added'];
+
+    // album.coverArtists = parseContributors(albumDoc['Cover Artists']);
+    // album.trackCoverArtists = parseContributors(albumDoc['Default Track Cover Artists']);
+    // album.hasTrackArt = albumDoc['Has Track Art'] ?? true;
+
+    // album.wallpaperArtists = parseContributors(albumDoc['Wallpaper Artists']);
+    // album.wallpaperStyle = albumDoc['Wallpaper Style'];
+    // album.wallpaperFileExtension = albumDoc['Wallpaper File Extension'] || 'jpg';
+
+    // album.bannerArtists = albumDoc['Banner Artists'];
+    // album.bannerStyle = albumDoc['Banner Style'];
+    // album.bannerFileExtension = albumDoc['Banner File Extension'] || 'jpg';
+    // album.bannerDimensions = parseDimensions(albumDoc['Banner Dimensions']);
+
+    // album.groups = albumDoc['Groups'] || [];
+    // album.artTags = albumDoc['Art Tags'] || [];
+
+    // album.commentary = parseCommentary(albumDoc['Commentary']);
+
+    // album.isMajorRelease = albumDoc['Major Release'] ?? false;
+    // album.isListedOnHomepage = albumDoc['Listed on Homepage'] ?? true;
 
     if (album.artists && album.artists.error) {
         return {error: `${album.artists.error} (in ${album.name})`};
@@ -829,10 +1010,7 @@ async function processAlbumDataFile(file) {
         return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
     }
 
-    album.color = (
-        getBasicField(albumSection, 'Color') ||
-        getBasicField(albumSection, 'FG')
-    );
+    // album.color = albumDoc['Color'];
 
     if (!album.name) {
         return {error: `Expected "Album" (name) field!`};
@@ -879,24 +1057,20 @@ async function processAlbumDataFile(file) {
     let group = null;
     let trackIndex = 0;
 
-    for (const section of sections.slice(1)) {
+    for (const doc of documents.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) {
+        if (!doc) {
             continue;
         }
 
-        const groupName = getBasicField(section, 'Group');
+        const groupName = doc['Group'];
         if (groupName) {
             group = {
                 name: groupName,
-                color: (
-                    getBasicField(section, 'Color') ||
-                    getBasicField(section, 'FG') ||
-                    album.color
-                ),
-                originalDate: getBasicField(section, 'Original Date'),
+                color: doc['Color'] || album.color,
+                originalDate: doc['Original Date'],
                 startIndex: trackIndex,
                 tracks: []
             };
@@ -921,27 +1095,46 @@ async function processAlbumDataFile(file) {
 
         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');
+        track.name = String(doc['Track']);
+
+        track.commentary = parseCommentary(doc['Commentary']);
+        track.lyrics = String(doc['Lyrics']);
+
+        track.originalDate = doc['Date First Released'];
+        track.coverArtDate = doc['Cover Art Date'] || track.originalDate || album.trackArtDate;
+
+        isNaN(Date.parse(track.originalDate))
+
+        if (track.originalDate) {
+            if (isNaN(Date.parse(track.originalDate))) {
+                return {error: `The track "${track.name}"'s has an invalid "Date First Released" field: "${track.originalDate}"`};
+            }
+            track.originalDate = new Date(track.originalDate);
+            track.date = new Date(track.originalDate);
+        } else if (group && group.originalDate) {
+            track.originalDate = group.originalDate;
+            track.date = group.originalDate;
+        } else {
+            track.date = album.date;
+        }
+
+        track.coverArtDate = new Date(track.coverArtDate);
+
+        track.references = doc['References'] || [];
+        track.artists = parseContributors(doc['Artists']);
+        track.coverArtists = parseContributors(doc['Cover Artists']);
+        track.artTags = doc['Art Tags'] || [];
+        track.contributors = parseContributors(doc['Contributors']);
+        track.directory = doc['Directory'];
+        track.aka = doc['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}).`};
+            return {error: `A track document 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);
+        track.duration = getDurationInSeconds(doc['Duration'] || '0:00');
 
-        if (track.contributors.error) {
+        if (track.contributors?.error) {
             return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
         }
 
@@ -960,42 +1153,28 @@ async function processAlbumDataFile(file) {
             }
         }
 
-        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') {
+        if (doc['Has Cover Art'] === false) {
             track.coverArtists = null;
+        } else if (album.hasTrackArt && !track.coverArtists) {
+            if (album.trackCoverArtists) {
+                track.coverArtists = album.trackCoverArtists;
+            } else {
+                return {error: `The track "${track.name}" is missing the "Cover Artists" field (in ${album.name}).`};
+            }
         }
 
         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}"`};
+            try {
+                track.directory = getKebabCase(track.name);
+            } catch (error) {
+                console.log('error:', track.name);
+                process.exit();
             }
-            track.originalDate = new Date(track.originalDate);
-            track.date = new Date(track.originalDate);
-        } else if (group && group.originalDate) {
-            track.originalDate = group.originalDate;
-            track.date = group.originalDate;
-        } else {
-            track.date = album.date;
         }
 
-        track.coverArtDate = new Date(track.coverArtDate);
-
-        const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
+        const hasURLs = doc['Has URLs'] ?? true;
 
-        track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
+        track.urls = hasURLs && doc['URLs'] || [];
 
         if (hasURLs && !track.urls.length) {
             return {error: `The track "${track.name}" should have at least one URL specified.`};
@@ -1726,15 +1905,35 @@ writePage.to = ({
 }) => (targetFullKey, ...args) => {
     const [ groupKey, subKey ] = targetFullKey.split('.');
     let path = paths.subdirectoryPrefix;
+
+    let from;
+    let to;
+
     // 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);
+    if (groupKey !== 'localized' && groupKey !== 'localizedDefaultLanguage' && baseDirectory) {
+        from = 'localizedWithBaseDirectory.' + pageSubKey;
+        to = targetFullKey;
+    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
+        // Special case for specifically linking *from* a page with base
+        // directory *to* a page without! Used for the language switcher and
+        // hopefully nothing else oh god.
+        from = 'localizedWithBaseDirectory.' + pageSubKey;
+        to = 'localized.' + subKey;
+    } else if (groupKey === 'localizedDefaultLanguage') {
+        // Linking to the default, except surprise, we're already IN the default
+        // (no baseDirectory set).
+        from = 'localized.' + pageSubKey;
+        to = 'localized.' + subKey;
     } 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);
+        from = 'localized.' + pageSubKey;
+        to = targetFullKey;
     }
+
+    path += urls.from(from).to(to, ...args);
+
     return path;
 };
 
@@ -1792,8 +1991,12 @@ writePage.html = (pageFn, {
     footer.classes ??= [];
     footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer) : '');
 
+    footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, {
+        languages, paths, strings, to
+    });
+
     const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathanme)
+        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
         : '');
 
     const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
@@ -2035,6 +2238,7 @@ writePage.paths = (baseDirectory, fullKey, directory = '', {
     const outputFile = path.join(outputDirectory, file);
 
     return {
+        toPath: [fullKey, directory],
         pathname,
         subdirectoryPrefix,
         outputDirectory, outputFile
@@ -2419,12 +2623,32 @@ async function main() {
     // avoiding that in our code 8ecause, again, we want to avoid assuming the
     // format of the returned paths here - they're only meant to 8e used for
     // reading as-is.
-    const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY));
+    const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), f => path.extname(f) === '.yaml');
+
+    class LoadDataFileError extends AggregateError {}
+
+    // WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(aggregate.wrapAsync(processAlbumDataFile)));
+
+    const processDataAggregate = openAggregate({message: `Errors processing data files`});
+
+    await processDataAggregate.callAsync(async () => {
+        const { aggregate, result } = await mapAggregateAsync(albumDataFiles, processAlbumDataFile, {
+            message: `Errors processing album files`,
+            promiseAll: array => progressPromiseAll(`Reading & processing album files.`, array)
+        });
+
+        WD.albumData = result;
 
-    // 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.
-    WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile));
+        aggregate.close();
+    });
+
+    try {
+        processDataAggregate.close();
+    } catch (error) {
+        showAggregate(error);
+    }
+
+    process.exit();
 
     {
         const errors = WD.albumData.filter(obj => obj.error);