« get me outta code hell

Merge branch 'preview' into commentary-entries - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-15 10:57:55 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-15 10:57:55 -0400
commitdd5cbf9db64e994d44c922bca2ca8ec37e9f7983 (patch)
tree56ef644f10f1814c8d58ea04a259e7e6fe02f8bf
parentf2a31006efa7c4d9c7c15823adc70cc40c46dedd (diff)
parent52cc83065f41472a4c32c2003b0a715a66d4739a (diff)
Merge branch 'preview' into commentary-entries
-rw-r--r--src/content/dependencies/generateContentHeading.js26
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js18
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js38
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js53
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListRandomPageLinksGroupSection.js81
-rw-r--r--src/content/dependencies/generateListingPage.js180
-rw-r--r--src/content/dependencies/generatePageLayout.js11
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js20
-rw-r--r--src/content/dependencies/linkExternal.js25
-rw-r--r--src/content/dependencies/linkTemplate.js8
-rw-r--r--src/content/dependencies/listArtistsByContributions.js116
-rw-r--r--src/content/dependencies/listArtistsByGroup.js133
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js592
-rw-r--r--src/content/dependencies/listArtistsByName.js45
-rw-r--r--src/content/dependencies/listRandomPageLinks.js213
-rw-r--r--src/content/dependencies/listTracksByDate.js9
-rw-r--r--src/data/language.js208
-rw-r--r--src/data/things/artist.js17
-rw-r--r--src/data/things/wiki-info.js12
-rw-r--r--src/listing-spec.js11
-rw-r--r--src/repl.js7
-rw-r--r--src/static/client2.js49
-rw-r--r--src/static/site5.css14
-rw-r--r--src/strings-default.json513
-rw-r--r--src/strings-default.yaml1691
-rwxr-xr-xsrc/upd8.js326
-rw-r--r--src/util/html.js4
-rw-r--r--test/lib/content-function.js11
29 files changed, 3191 insertions, 1258 deletions
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index ccaf1076..56f68cb3 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -1,19 +1,39 @@
 export default {
   extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleVariables'],
+
+  relations: (relation) => ({
+    colorVariables: relation('generateColorStyleVariables'),
+  }),
 
   slots: {
     title: {type: 'html'},
+    accent: {type: 'html'},
+
+    color: {validate: v => v.isColor},
+
     id: {type: 'string'},
     tag: {type: 'string', default: 'p'},
   },
 
-  generate(slots, {html}) {
+  generate(relations, slots, {html}) {
     return html.tag(slots.tag,
       {
         class: 'content-heading',
         id: slots.id,
         tabindex: '0',
-      },
-      slots.title);
+
+        style:
+          slots.color &&
+            relations.colorVariables
+              .slot('color', slots.color)
+              .content,
+      }, [
+        slots.title,
+
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-accent'},
+          slots.accent),
+      ]);
   }
 }
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index bd6063c9..80072483 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,5 +1,6 @@
 import find from '#find';
 import {stitchArrays} from '#sugar';
+import {filterMultipleArrays} from '#wiki-data';
 
 export default {
   contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
@@ -11,10 +12,12 @@ export default {
 
   query(sprawl, act, flash) {
     const findFlashAct = directory =>
-      find.flashAct(directory, sprawl.flashActData, {mode: 'error'});
+      find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'});
+
+    const homestuckSide1 = findFlashAct('flash-act:a1');
 
     const sideFirstActs = [
-      findFlashAct('flash-act:a1'),
+      sprawl.flashActData[0],
       findFlashAct('flash-act:a6a1'),
       findFlashAct('flash-act:hiveswap'),
       findFlashAct('flash-act:cool-and-new-web-comic'),
@@ -22,7 +25,9 @@ export default {
     ];
 
     const sideNames = [
-      `Side 1 (Acts 1-5)`,
+      (homestuckSide1
+        ? `Side 1 (Acts 1-5)`
+        : `All flashes & games`),
       `Side 2 (Acts 6-7)`,
       `Additional Canon`,
       `Fan Adventures`,
@@ -30,13 +35,18 @@ export default {
     ];
 
     const sideColors = [
-      '#4ac925',
+      (homestuckSide1
+        ? '#4ac925'
+        : null),
       '#3796c6',
       '#f2a400',
       '#c466ff',
       '#32c7fe',
     ];
 
+    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
+      firstAct => firstAct);
+
     const sideFirstActIndexes =
       sideFirstActs
         .map(act => sprawl.flashActData.indexOf(act));
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index ad1dab94..5fc62ab3 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -95,23 +95,25 @@ export default {
 
       mainClasses: ['flash-index'],
       mainContent: [
-        html.tag('p',
-          {class: 'quick-info'},
-          language.$('misc.jumpTo')),
-
-        html.tag('ul',
-          {class: 'quick-info'},
-          stitchArrays({
-            colorVariables: relations.jumpLinkColorVariables,
-            anchor: data.jumpLinkAnchors,
-            color: data.jumpLinkColors,
-            label: data.jumpLinkLabels,
-          }).map(({colorVariables, anchor, color, label}) =>
-              html.tag('li',
-                html.tag('a', {
-                  href: '#' + anchor,
-                  style: colorVariables.slot('color', color).content,
-                }, label)))),
+        !empty(data.jumpLinkLabels) && [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('misc.jumpTo')),
+
+          html.tag('ul',
+            {class: 'quick-info'},
+            stitchArrays({
+              colorVariables: relations.jumpLinkColorVariables,
+              anchor: data.jumpLinkAnchors,
+              color: data.jumpLinkColors,
+              label: data.jumpLinkLabels,
+            }).map(({colorVariables, anchor, color, label}) =>
+                html.tag('li',
+                  html.tag('a', {
+                    href: '#' + anchor,
+                    style: colorVariables.slot('color', color).content,
+                  }, label)))),
+        ],
 
         stitchArrays({
           colorVariables: relations.actColorVariables,
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
index 5df83566..86e6c61a 100644
--- a/src/content/dependencies/generateFooterLocalizationLinks.js
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -1,3 +1,6 @@
+import {stitchArrays} from '#sugar';
+import {sortByName} from '#wiki-data';
+
 export default {
   extraDependencies: [
     'defaultLanguage',
@@ -16,25 +19,37 @@ export default {
     pagePath,
     to,
   }) {
-    const links = Object.entries(languages)
-      .filter(([code, language]) => code !== 'default' && !language.hidden)
-      .map(([code, language]) => language)
-      .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
-      .map((language) =>
-        html.tag('span',
-          html.tag('a',
-            {
-              href:
-                language === defaultLanguage
-                  ? to(
-                      'localizedDefaultLanguage.' + pagePath[0],
-                      ...pagePath.slice(1))
-                  : to(
-                      'localizedWithBaseDirectory.' + pagePath[0],
-                      language.code,
-                      ...pagePath.slice(1)),
-            },
-            language.name)));
+    const switchableLanguages =
+      Object.entries(languages)
+        .filter(([code, language]) => code !== 'default' && !language.hidden)
+        .map(([code, language]) => language);
+
+    if (switchableLanguages.length <= 1) {
+      return html.blank();
+    }
+
+    sortByName(switchableLanguages);
+
+    const [pagePathSubkey, ...pagePathArgs] = pagePath;
+
+    const linkPaths =
+      switchableLanguages.map(language =>
+        (language === defaultLanguage
+          ? (['localizedDefaultLanguage.' + pagePathSubkey,
+              ...pagePathArgs])
+          : (['localizedWithBaseDirectory.' + pagePathSubkey,
+              language.code,
+              ...pagePathArgs])));
+
+    const links =
+      stitchArrays({
+        language: switchableLanguages,
+        linkPath: linkPaths,
+      }).map(({language, linkPath}) =>
+          html.tag('span',
+            html.tag('a',
+              {href: to(...linkPath)},
+              language.name)));
 
     return html.tag('div', {class: 'footer-localization-links'},
       language.$('misc.uiLanguage', {
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 00000000..b3560aca
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
@@ -0,0 +1,18 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+
+  data: (album) =>
+    ({directory: album.directory}),
+
+  relations: (relation, album) =>
+    ({albumLink: relation('linkAlbum', album)}),
+
+  generate: (data, relations) =>
+    relations.albumLink.slots({
+      anchor: true,
+      attributes: {
+        'data-random': 'track-in-album',
+        'style': `--album-directory: ${data.directory}`,
+      },
+    }),
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js
deleted file mode 100644
index 2a684b19..00000000
--- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import {stitchArrays} from '#sugar';
-import {sortChronologically} from '#wiki-data';
-
-export default {
-  contentDependencies: ['generateColorStyleVariables', 'linkGroup'],
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({albumData}) => ({albumData}),
-
-  query: (sprawl, group) => ({
-    albums:
-      sortChronologically(sprawl.albumData.slice())
-        .filter(album => album.groups.includes(group))
-        .filter(album => album.tracks.length > 1),
-  }),
-
-  relations: (relation, query, sprawl, group) => ({
-    groupLink:
-      relation('linkGroup', group),
-
-    albumColorVariables:
-      query.albums
-        .map(() => relation('generateColorStyleVariables')),
-  }),
-
-  data: (query, sprawl, group) => ({
-    groupDirectory:
-      group.directory,
-
-    albumColors:
-      query.albums
-        .map(album => album.color),
-
-    albumDirectories:
-      query.albums
-        .map(album => album.directory),
-
-    albumNames:
-      query.albums
-        .map(album => album.name),
-  }),
-
-  generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('dt',
-        language.$('listingPage.other.randomPages.group', {
-          group: relations.groupLink,
-
-          randomAlbum:
-            html.tag('a',
-              {href: '#', 'data-random': 'album-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomAlbum')),
-
-          randomTrack:
-            html.tag('a',
-              {href: '#', 'data-random': 'track-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomTrack')),
-        })),
-
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            colorVariables: relations.albumColorVariables,
-            color: data.albumColors,
-            directory: data.albumDirectories,
-            name: data.albumNames,
-          }).map(({colorVariables, color, directory, name}) =>
-              html.tag('li',
-                language.$('listingPage.other.randomPages.album', {
-                  album:
-                    html.tag('a', {
-                      href: '#',
-                      'data-random': 'track-in-album',
-                      style:
-                        colorVariables.slot('color', color).content +
-                        '; ' +
-                        `--album-directory: ${directory}`,
-                    }, name),
-                }))))),
-    ]),
-};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 08eb40c6..2050d62d 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {bindOpts, empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -7,6 +7,7 @@ export default {
     'generatePageLayout',
     'linkListing',
     'linkListingIndex',
+    'linkTemplate',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -26,6 +27,9 @@ export default {
     relations.chunkHeading =
       relation('generateContentHeading');
 
+    relations.showSkipToSectionLinkTemplate =
+      relation('linkTemplate');
+
     if (listing.target.listings.length > 1) {
       relations.sameTargetListingLinks =
         listing.target.listings
@@ -58,12 +62,42 @@ export default {
   },
 
   slots: {
-    type: {validate: v => v.is('rows', 'chunks', 'custom')},
+    type: {
+      validate: v => v.is('rows', 'chunks', 'custom'),
+    },
+
+    rows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    rows: {validate: v => v.strictArrayOf(v.isObject)},
+    rowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject))
+    },
+
+    chunkTitles: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    chunkTitles: {validate: v => v.strictArrayOf(v.isObject)},
-    chunkRows: {validate: v => v.strictArrayOf(v.isObject)},
+    chunkTitleAccents: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    chunkRows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkRowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    showSkipToSection: {
+      type: 'boolean',
+      default: false,
+    },
+
+    chunkIDs: {
+      validate: v => v.strictArrayOf(v.optional(v.isString)),
+    },
 
     listStyle: {
       validate: v => v.is('ordered', 'unordered'),
@@ -74,26 +108,59 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const listTag =
-      (slots.listStyle === 'ordered'
-        ? 'ol'
-        : 'ul');
+    function formatListingString({
+      context,
+      provided = {},
+    }) {
+      const parts = ['listingPage', data.stringsKey];
+
+      if (Array.isArray(context)) {
+        parts.push(...context);
+      } else {
+        parts.push(context);
+      }
 
-    const formatListingString = (contextStringsKey, options = {}) => {
-      const baseStringsKey = `listingPage.${data.stringsKey}`;
+      if (provided.stringsKey) {
+        parts.push(provided.stringsKey);
+      }
 
-      const parts = [baseStringsKey, contextStringsKey];
+      const options = {...provided};
+      delete options.stringsKey;
 
-      if (options.stringsKey) {
-        parts.push(options.stringsKey);
-        delete options.stringsKey;
-      }
+      return language.formatString(...parts, options);
+    }
 
-      return language.formatString(parts.join('.'), options);
-    };
+    const formatRow = ({context, row, attributes}) =>
+      (attributes?.href
+        ? html.tag('li',
+            html.tag('a',
+              attributes,
+              formatListingString({
+                context,
+                provided: row,
+              })))
+        : html.tag('li',
+            attributes,
+            formatListingString({
+              context,
+              provided: row,
+            })));
+
+    const formatRowList = ({context, rows, rowAttributes}) =>
+      html.tag(
+        (slots.listStyle === 'ordered' ? 'ol' : 'ul'),
+        stitchArrays({
+          row: rows,
+          attributes: rowAttributes ?? rows.map(() => null),
+        }).map(
+          bindOpts(formatRow, {
+            [bindOpts.bindIndex]: 0,
+            context,
+          })));
 
     return relations.layout.slots({
-      title: formatListingString('title'),
+      title: formatListingString({context: 'title'}),
+
       headingMode: 'sticky',
 
       mainContent: [
@@ -121,35 +188,78 @@ export default {
               listings: language.formatUnitList(relations.seeAlsoLinks),
             })),
 
+        slots.content,
+
         slots.type === 'rows' &&
-          html.tag(listTag,
-            slots.rows.map(row =>
-              html.tag('li',
-                formatListingString('item', row)))),
+          formatRowList({
+            context: 'item',
+            rows: slots.rows,
+            rowAttributes: slots.rowAttributes,
+          }),
 
         slots.type === 'chunks' &&
-          html.tag('dl',
+          html.tag('dl', [
+            slots.showSkipToSection && [
+              html.tag('dt',
+                language.$('listingPage.skipToSection')),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    title: slots.chunkTitles,
+                    id: slots.chunkIDs,
+                  }).filter(({id}) => id)
+                    .map(({title, id}) =>
+                      html.tag('li',
+                        relations.showSkipToSectionLinkTemplate
+                          .clone()
+                          .slots({
+                            hash: id,
+                            content:
+                              html.normalize(
+                                formatListingString({
+                                  context: 'chunk.title',
+                                  provided: title,
+                                }).toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
             stitchArrays({
               title: slots.chunkTitles,
+              titleAccent: slots.chunkTitleAccents,
+              id: slots.chunkIDs,
               rows: slots.chunkRows,
-            }).map(({title, rows}) => [
+              rowAttributes: slots.chunkRowAttributes,
+            }).map(({title, titleAccent, id, rows, rowAttributes}) => [
                 relations.chunkHeading
                   .clone()
                   .slots({
                     tag: 'dt',
-                    title: formatListingString('chunk.title', title),
+                    id,
+
+                    title:
+                      formatListingString({
+                        context: 'chunk.title',
+                        provided: title,
+                      }),
+
+                    accent:
+                      titleAccent &&
+                        formatListingString({
+                          context: ['chunk.title', title.stringsKey, 'accent'],
+                          provided: titleAccent,
+                        }),
                   }),
 
                 html.tag('dd',
-                  html.tag(listTag,
-                    rows.map(row =>
-                      html.tag('li',
-                        {class: row.stringsKey === 'rerelease' && 'rerelease'},
-                        formatListingString('chunk.item', row))))),
-              ])),
-
-        slots.type === 'custom' &&
-          slots.content,
+                  formatRowList({
+                    context: 'chunk.item',
+                    rows,
+                    rowAttributes,
+                  })),
+              ]),
+          ]),
       ],
 
       navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cd831ba7..5fa6e751 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -85,8 +85,10 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
-    relations.defaultFooterContent =
-      relation('transformContent', sprawl.footerContent);
+    if (sprawl.footerContent) {
+      relations.defaultFooterContent =
+        relation('transformContent', sprawl.footerContent);
+    }
 
     relations.colorStyleRules =
       relation('generateColorStyleRules');
@@ -231,7 +233,7 @@ export default {
 
     let footerContent = slots.footerContent;
 
-    if (html.isBlank(footerContent)) {
+    if (html.isBlank(footerContent) && relations.defaultFooterContent) {
       footerContent = relations.defaultFooterContent
         .slot('mode', 'multiline');
     }
@@ -449,7 +451,8 @@ export default {
             {[html.onlyIfContent]: true, class: 'skipper-list'},
             processSkippers([
               {id: 'tracks', string: 'tracks'},
-              {id: 'art', string: 'flashes'},
+              {id: 'art', string: 'artworks'},
+              {id: 'flashes', string: 'flashes'},
               {id: 'contributors', string: 'contributors'},
               {id: 'references', string: 'references'},
               {id: 'referenced-by', string: 'referencedBy'},
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index cb0860f5..a19f104c 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -11,7 +11,7 @@ export default {
     'transformContent',
   ],
 
-  extraDependencies: ['wikiData'],
+  extraDependencies: ['language', 'wikiData'],
 
   sprawl({albumData}, row) {
     const sprawl = {};
@@ -90,12 +90,14 @@ export default {
     data.paths =
       sprawl.albums
         .map(album =>
-          ['media.albumCover', album.directory, album.coverArtFileExtension]);
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
 
     return data;
   },
 
-  generate(data, relations) {
+  generate(data, relations, {language}) {
     // Grids and carousels share some slots! Very convenient.
     const commonSlots = {};
 
@@ -106,8 +108,16 @@ export default {
       stitchArrays({
         image: relations.images,
         path: data.paths,
-      }).map(({image, path}) =>
-          image.slot('path', path));
+        name: data.names ?? data.paths.slice().fill(null),
+      }).map(({image, path, name}) =>
+          image.slots({
+            path,
+            missingSourceContent:
+              name &&
+                language.$('misc.albumGrid.noCoverArt', {
+                  album: name,
+                }),
+            }));
 
     commonSlots.actionLinks =
       (relations.actionLinks
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 73c656e3..5de612e2 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -3,10 +3,20 @@ const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
 const MASTODON_DOMAINS = ['types.pl'];
 
 export default {
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'language', 'wikiData'],
 
-  data(url) {
-    return {url};
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  data(sprawl, url) {
+    const data = {url};
+
+    const {canonicalBase} = sprawl.wikiInfo;
+    if (canonicalBase) {
+      const {hostname: canonicalDomain} = new URL(canonicalBase);
+      Object.assign(data, {canonicalDomain});
+    }
+
+    return data;
   },
 
   slots: {
@@ -20,6 +30,7 @@ export default {
     let isLocal;
     let domain;
     let pathname;
+
     try {
       const url = new URL(data.url);
       domain = url.hostname;
@@ -28,6 +39,14 @@ export default {
       // No support for relative local URLs yet, sorry! (I.e, local URLs must
       // be absolute relative to the domain name in order to work.)
       isLocal = true;
+      domain = null;
+      pathname = null;
+    }
+
+    // isLocal also applies for URLs which match the 'Canonical Base' under
+    // wiki-info.yaml, if present.
+    if (data.canonicalDomain && domain === data.canonicalDomain) {
+      isLocal = true;
     }
 
     const link = html.tag('a',
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index d9af726c..a361a4e7 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -64,6 +64,14 @@ export default {
       style = `--primary-color: ${primary}; --dim-color: ${dim}`;
     }
 
+    if (slots.attributes?.style) {
+      if (style) {
+        style += '; ' + slots.attributes.style;
+      } else {
+        style = slots.attributes.style;
+      }
+    }
+
     if (slots.tooltip) {
       title = slots.tooltip;
     }
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 86c8cfa2..58c51a40 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,5 +1,11 @@
-import {stitchArrays, unique} from '#sugar';
-import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterByCount,
+  filterMultipleArrays,
+  sortAlphabetically,
+  sortByCount,
+} from '#wiki-data';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -96,68 +102,54 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          relations.artistLinksByTrackContributions,
-          data.countsByTrackContributions,
-          'countTracks',
-        ]],
-
-        ['artworks', [
-          relations.artistLinksByArtworkContributions,
-          data.countsByArtworkContributions,
-          'countArtworks',
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            relations.artistLinksByFlashContributions,
-            data.countsByFlashContributions,
-            'countFlashes',
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [artistLinks, counts, countFunction]]) => [
-          key,
-          html.tag('ul',
-            stitchArrays({
-              artistLink: artistLinks,
-              count: counts,
-            }).map(({artistLink, count}) =>
-                html.tag('li',
-                  language.$('listingPage.listArtists.byContribs.item', {
-                    artist: artistLink,
-                    contributions: language[countFunction](count, {unit: true}),
-                  })))),
-        ]));
+  generate(data, relations, {language}) {
+    const listChunkIDs = ['tracks', 'artworks', 'flashes'];
+    const listTitleStringsKeys = ['trackContributors', 'artContributors', 'flashContributors'];
+    const listCountFunctions = ['countTracks', 'countArtworks', 'countFlashes'];
+
+    const listArtistLinks = [
+      relations.artistLinksByTrackContributions,
+      relations.artistLinksByArtworkContributions,
+      relations.artistLinksByFlashContributions,
+    ];
+
+    const listArtistCounts = [
+      data.countsByTrackContributions,
+      data.countsByArtworkContributions,
+      data.countsByFlashContributions,
+    ];
+
+    filterMultipleArrays(
+      listChunkIDs,
+      listTitleStringsKeys,
+      listCountFunctions,
+      listArtistLinks,
+      listArtistCounts,
+      (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) =>
+        !empty(artistLinks));
 
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs: listChunkIDs,
+
+      chunkTitles:
+        listTitleStringsKeys.map(stringsKey => ({stringsKey})),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: listArtistLinks,
+          artistCounts: listArtistCounts,
+          countFunction: listCountFunctions,
+        }).map(({artistLinks, artistCounts, countFunction}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              artistCount: artistCounts,
+            }).map(({artistLink, artistCount}) => ({
+                artist: artistLink,
+                contributions: language[countFunction](artistCount, {unit: true}),
+              }))),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
new file mode 100644
index 00000000..3778b9e3
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,133 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterMultipleArrays,
+  getArtistNumContributions,
+  sortAlphabetically,
+} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {artistData, wikiInfo};
+  },
+
+  query(sprawl, spec) {
+    const artists = sortAlphabetically(sprawl.artistData.slice());
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(groups)) {
+      return {spec, artists};
+    }
+
+    const artistGroups =
+      artists.map(artist =>
+        unique(
+          unique([
+            ...artist.albumsAsAny,
+            ...artist.tracksAsAny.map(track => track.album),
+          ]).flatMap(album => album.groups)))
+
+    const artistsByGroup =
+      groups.map(group =>
+        artists.filter((artist, index) => artistGroups[index].includes(group)));
+
+    filterMultipleArrays(groups, artistsByGroup,
+      (group, artists) => !empty(artists));
+
+    return {spec, groups, artistsByGroup};
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artists) {
+      relations.artistLinks =
+        query.artists
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.artistLinksByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => relation('linkArtist', artist)));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.artists) {
+      data.counts =
+        query.artists
+          .map(artist => getArtistNumContributions(artist));
+    }
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => getArtistNumContributions(artist)));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return (
+      (relations.artistLinksByGroup
+        ? relations.page.slots({
+            type: 'chunks',
+
+            showSkipToSection: true,
+            chunkIDs:
+              data.groupDirectories
+                .map(directory => `contributed-to-${directory}`),
+
+            chunkTitles:
+              relations.groupLinks.map(groupLink => ({
+                group: groupLink,
+              })),
+
+            chunkRows:
+              stitchArrays({
+                artistLinks: relations.artistLinksByGroup,
+                counts: data.countsByGroup,
+              }).map(({artistLinks, counts}) =>
+                  stitchArrays({
+                    link: artistLinks,
+                    count: counts,
+                  }).map(({link, count}) => ({
+                      artist: link,
+                      contributions: language.countContributions(count, {unit: true}),
+                    }))),
+          })
+        : relations.page.slots({
+            type: 'rows',
+            rows:
+              stitchArrays({
+                link: relations.artistLinks,
+                count: data.counts,
+              }).map(({link, count}) => ({
+                  artist: link,
+                  contributions: language.countContributions(count, {unit: true}),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 3870afde..45f8390f 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -1,15 +1,16 @@
-import {transposeArrays, empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
+import T from '#things';
 
 import {
   chunkMultipleArrays,
-  compareCaseLessSensitive,
-  compareDates,
-  filterMultipleArrays,
-  reduceMultipleArrays,
   sortAlphabetically,
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
   sortMultipleArrays,
 } from '#wiki-data';
 
+const {Album, Flash} = T;
+
 export default {
   contentDependencies: [
     'generateListingPage',
@@ -20,348 +21,299 @@ export default {
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({artistData, wikiInfo}) {
-    return {
-      artistData,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
+  sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) =>
+    ({albumData, artistData, flashData, trackData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames}),
 
   query(sprawl, spec) {
-    const query = {
-      spec,
-      enableFlashesAndGames: sprawl.enableFlashesAndGames,
-    };
-
-    const queryContributionInfo = (
-      artistsKey,
-      chunkThingsKey,
-      datesKey,
-      datelessArtistsKey,
-      fn,
-    ) => {
-      const artists = sortAlphabetically(sprawl.artistData.slice());
-
-      // Each value stored in dateLists, corresponding to each artist,
-      // is going to be a list of dates and nulls. Any nulls represent
-      // a contribution which isn't associated with a particular date.
-      const [chunkThingLists, dateLists] =
-        transposeArrays(artists.map(artist => fn(artist)));
-
-      // Scrap artists who don't even have any relevant contributions.
-      // These artists may still have other contributions across the wiki, but
-      // they weren't returned by the callback and so aren't relevant to this
-      // list.
-      filterMultipleArrays(
-        artists,
-        chunkThingLists,
-        dateLists,
-        (artists, chunkThings, dates) => !empty(dates));
-
-      // Also exclude artists whose remaining contributions are all dateless.
-      // But keep track of the artists removed here, since they'll be displayed
-      // in an additional list in the final listing page.
-      const {removed: [datelessArtists]} =
-        filterMultipleArrays(
-          artists,
-          chunkThingLists,
-          dateLists,
-          (artist, chunkThings, dates) => !empty(dates.filter(Boolean)));
-
-      // Cut out dateless contributions. They're not relevant to finding the
-      // latest date.
-      for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) {
-        filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date);
+    //
+    // First main step is to get the latest thing each artist has contributed
+    // to, and the date associated with that contribution! Some notes:
+    //
+    // * Album and track contributions are considered before flashes, so
+    //   they'll take priority if an artist happens to have multiple contribs
+    //   landing on the same date to both an album and a flash.
+    //
+    // * The final (album) contribution list is chunked by album, but also by
+    //   date, because an individual album can cover a variety of dates.
+    //
+    // * If an artist has contributed both artworks and tracks to the album
+    //   containing their latest contribution, then that will be indicated
+    //   in an annotation, but *only if* those contributions were also on
+    //   the same date.
+    //
+    // * If an artist made contributions to multiple albums on the same date,
+    //   then the first of the *albums* sorted chronologically (latest first)
+    //   is the one that will count.
+    //
+    // * Same for artists who've contributed to multiple flashes which were
+    //   released on the same date.
+    //
+    // * The map may exclude artists none of whose contributions were dated.
+    //
+
+    const artistLatestContribMap = new Map();
+
+    const considerDate = (artist, date, thing, contribution) => {
+      if (!date) {
+        return;
       }
 
-      const [chunkThings, dates] =
-        transposeArrays(
-          transposeArrays([chunkThingLists, dateLists])
-            .map(([chunkThings, dates]) =>
-              reduceMultipleArrays(
-                chunkThings, dates,
-                (accChunkThing, accDate, chunkThing, date) =>
-                  (date && date > accDate
-                    ? [chunkThing, date]
-                    : [accChunkThing, accDate]))));
-
-      sortMultipleArrays(artists, dates, chunkThings,
-        (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => {
-          const dateComparison = compareDates(dateA, dateB, {latestFirst: true});
-          if (dateComparison !== 0) {
-            return dateComparison;
-          }
-
-          // TODO: Compare alphabetically, not just by directory.
-          return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory);
-        });
-
-      const chunks =
-        chunkMultipleArrays(artists, dates, chunkThings,
-          (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) =>
-            +date !== +lastDate || chunkThing !== lastChunkThing);
+      if (artistLatestContribMap.has(artist)) {
+        const latest = artistLatestContribMap.get(artist);
+        if (latest.date > date) {
+          return;
+        }
 
-      query[chunkThingsKey] =
-        chunks.map(([artists, dates, chunkThings]) => chunkThings[0]);
-
-      query[datesKey] =
-        chunks.map(([artists, dates, chunkThings]) => dates[0]);
+        if (latest.date === date) {
+          if (latest.thing === thing) {
+            // May combine differnt contributions to the same thing and date.
+            latest.contribution.add(contribution);
+          }
 
-      query[artistsKey] =
-        chunks.map(([artists, dates, chunkThings]) => artists);
+          // Earlier-processed things of same date take priority.
+          return;
+        }
+      }
 
-      query[datelessArtistsKey] = datelessArtists;
+      // First entry for artist or more recent contribution than latest date.
+      artistLatestContribMap.set(artist, {
+        date,
+        thing,
+        contribution: new Set([contribution]),
+      });
     };
 
-    queryContributionInfo(
-      'artistsByTrackContributions',
-      'albumsByTrackContributions',
-      'datesByTrackContributions',
-      'datelessArtistsByTrackContributions',
-      artist => {
-        const tracks =
-          [...artist.tracksAsArtist, ...artist.tracksAsContributor]
-            .filter(track => !track.originalReleaseTrack);
-
-        const albums = tracks.map(track => track.album);
-        const dates = tracks.map(track => track.date);
+    const getArtists = (thing, key) => thing[key].map(({who}) => who);
 
-        return [albums, dates];
-      });
+    const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
+    const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
+    const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice());
 
-    queryContributionInfo(
-      'artistsByArtworkContributions',
-      'albumsByArtworkContributions',
-      'datesByArtworkContributions',
-      'datelessArtistsByArtworkContributions',
-      artist => [
-        [
-          ...artist.tracksAsCoverArtist.map(track => track.album),
-          ...artist.albumsAsCoverArtist,
-          ...artist.albumsAsWallpaperArtist,
-          ...artist.albumsAsBannerArtist,
-        ],
-        [
-          // TODO: Per-artwork dates, see #90.
-          ...artist.tracksAsCoverArtist.map(track => track.coverArtDate ?? track.date),
-          ...artist.albumsAsCoverArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsBannerArtist.map(album => album.coverArtDate ?? album.date),
-        ],
-      ]);
-
-    if (sprawl.enableFlashesAndGames) {
-      queryContributionInfo(
-        'artistsByFlashContributions',
-        'flashesByFlashContributions',
-        'datesByFlashContributions',
-        'datelessArtistsByFlashContributions',
-        artist => [
-          [
-            ...artist.flashesAsContributor,
-          ],
-          [
-            ...artist.flashesAsContributor.map(flash => flash.date),
-          ],
-        ]);
+    for (const album of albumsLatestFirst) {
+      for (const artist of new Set([
+        ...getArtists(album, 'coverArtistContribs'),
+        ...getArtists(album, 'wallpaperArtistContribs'),
+        ...getArtists(album, 'bannerArtistContribs'),
+      ])) {
+        // Might combine later with 'track' of the same album and date.
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+      }
     }
 
-    return query;
-  },
-
-  relations(relation, query) {
-    const relations = {};
-
-    relations.page =
-      relation('generateListingPage', query.spec);
-
-    // Track contributors
-
-    relations.albumLinksByTrackContributions =
-      query.albumsByTrackContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByTrackContributions =
-      query.artistsByTrackContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
-
-    relations.datelessArtistLinksByTrackContributions =
-      query.datelessArtistsByTrackContributions
-        .map(artist => relation('linkArtist', artist));
-
-    // Artwork contributors
-
-    relations.albumLinksByArtworkContributions =
-      query.albumsByArtworkContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByArtworkContributions =
-      query.artistsByArtworkContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
+    for (const track of tracksLatestFirst) {
+      for (const artist of getArtists(track, 'coverArtistContribs')) {
+        // No special effect if artist already has 'artwork' for the same album and date.
+        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+      }
 
-    relations.datelessArtistLinksByArtworkContributions =
-      query.datelessArtistsByArtworkContributions
-        .map(artist => relation('linkArtist', artist));
+      for (const artist of new Set([
+        ...getArtists(track, 'artistContribs'),
+        ...getArtists(track, 'contributorContribs'),
+      ])) {
+        // Might be combining with 'artwork' of the same album and date.
+        considerDate(artist, track.date, track.album, 'track');
+      }
+    }
 
-    // Flash contributors
+    for (const flash of flashesLatestFirst) {
+      for (const artist of getArtists(flash, 'contributorContribs')) {
+        // Won't take priority above album contributions of the same date.
+        considerDate(artist, flash.date, flash, 'flash');
+      }
+    }
 
-    if (query.enableFlashesAndGames) {
-      relations.flashLinksByFlashContributions =
-        query.flashesByFlashContributions
-          .map(flash => relation('linkFlash', flash));
+    //
+    // Next up is to sort all the processed artist information!
+    //
+    // Entries with the same album/flash and the same date go together first,
+    // with the following rules for sorting artists therein:
+    //
+    // * If the contributions are different, which can only happen for albums,
+    //   then it's tracks-only first, tracks + artworks next, and artworks-only
+    //   last.
+    //
+    // * If the contributions are the same, then sort alphabetically.
+    //
+    // Entries with different albums/flashes follow another set of rules:
+    //
+    // * Later dates come before earlier dates.
+    //
+    // * On the same date, albums come before flashes.
+    //
+    // * Things of the same type *and* date are sorted alphabetically.
+    //
+
+    const artistsAlphabetically =
+      sortAlphabetically(sprawl.artistData.slice());
+
+    const artists =
+      Array.from(artistLatestContribMap.keys());
+
+    const artistContribEntries =
+      Array.from(artistLatestContribMap.values());
+
+    const artistThings =
+      artistContribEntries.map(({thing}) => thing);
+
+    const artistDates =
+      artistContribEntries.map(({date}) => date);
+
+    const artistContributions =
+      artistContribEntries.map(({contribution}) => contribution);
+
+    sortMultipleArrays(artistThings, artistDates, artistContributions, artists,
+      (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => {
+        if (date1 === date2 && thing1 === thing2) {
+          // Move artwork-only contribs after contribs with tracks.
+          if (!contrib1.has('track') && contrib2.has('track')) return 1;
+          if (!contrib2.has('track') && contrib1.has('track')) return -1;
+
+          // Move track-only contribs before tracks with tracks and artwork.
+          if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1;
+          if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1;
+
+          // Sort artists of the same type of contribution alphabetically,
+          // referring to a previous sort.
+          const index1 = artistsAlphabetically.indexOf(artist1);
+          const index2 = artistsAlphabetically.indexOf(artist2);
+          return index1 - index2;
+        } else {
+          // Move later dates before earlier ones.
+          if (date1 !== date2) return date2 - date1;
+
+          // Move albums before flashes.
+          if (thing1 instanceof Album && thing2 instanceof Flash) return -1;
+          if (thing1 instanceof Flash && thing2 instanceof Album) return 1;
+
+          // Sort two albums or two flashes alphabetically, referring to a
+          // previous sort (which was chronological but includes the correct
+          // ordering for things released on the same date).
+          const thingsLatestFirst =
+            (thing1 instanceof Album
+              ? albumsLatestFirst
+              : flashesLatestFirst);
+          const index1 = thingsLatestFirst.indexOf(thing1);
+          const index2 = thingsLatestFirst.indexOf(thing2);
+          return index2 - index1;
+        }
+      });
 
-      relations.artistLinksByFlashContributions =
-        query.artistsByFlashContributions
-          .map(artists =>
-            artists.map(artist => relation('linkArtist', artist)));
+    const chunks =
+      chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
+        (thing, lastThing, date, lastDate) =>
+          thing !== lastThing ||
+          +date !== +lastDate);
 
-      relations.datelessArtistLinksByFlashContributions =
-        query.datelessArtistsByFlashContributions
-          .map(artist => relation('linkArtist', artist));
-    }
+    const chunkThings =
+      chunks.map(([artistThings, , , ]) => artistThings[0]);
 
-    return relations;
-  },
+    const chunkDates =
+      chunks.map(([, artistDates, , ]) => artistDates[0]);
 
-  data(query) {
-    const data = {};
+    const chunkArtistContributions =
+      chunks.map(([, , artistContributions, ]) => artistContributions);
 
-    data.enableFlashesAndGames = query.enableFlashesAndGames;
+    const chunkArtists =
+      chunks.map(([, , , artists]) => artists);
 
-    data.datesByTrackContributions = query.datesByTrackContributions;
-    data.datesByArtworkContributions = query.datesByArtworkContributions;
+    // And one bonus step - keep track of all the artists whose contributions
+    // were all without date.
 
-    if (query.enableFlashesAndGames) {
-      data.datesByFlashContributions = query.datesByFlashContributions;
-    }
+    const datelessArtists =
+      artistsAlphabetically
+        .filter(artist => !artists.includes(artist));
 
-    return data;
+    return {
+      spec,
+      chunkThings,
+      chunkDates,
+      chunkArtistContributions,
+      chunkArtists,
+      datelessArtists,
+    };
   },
 
-  generate(data, relations, {html, language}) {
-    const chunkTitles = Object.fromEntries(
-      ([
-        ['tracks', [
-          'album',
-          relations.albumLinksByTrackContributions,
-          data.datesByTrackContributions,
-        ]],
-
-        ['artworks', [
-          'album',
-          relations.albumLinksByArtworkContributions,
-          data.datesByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            'flash',
-            relations.flashLinksByFlashContributions,
-            data.datesByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [stringsKey, links, dates]]) => [
-          key,
-          stitchArrays({link: links, date: dates})
-            .map(({link, date}) =>
-              html.tag('dt',
-                language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, {
-                  [stringsKey]: link,
-                  date: language.formatDate(date),
-                }))),
-        ]));
-
-    const chunkItems = Object.fromEntries(
-      ([
-        ['tracks', relations.artistLinksByTrackContributions],
-        ['artworks', relations.artistLinksByArtworkContributions],
-        data.enableFlashesAndGames &&
-          ['flashes', relations.artistLinksByFlashContributions],
-      ]).filter(Boolean)
-        .map(([key, artistLinkLists]) => [
-          key,
-          artistLinkLists.map(artistLinks =>
-            html.tag('dd',
-              html.tag('ul',
-                artistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.chunk.item', {
-                      artist: artistLink,
-                    })))))),
-        ]));
-
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          chunkTitles.tracks,
-          chunkItems.tracks,
-          relations.datelessArtistLinksByTrackContributions,
-        ]],
-
-        ['artworks', [
-          chunkTitles.artworks,
-          chunkItems.artworks,
-          relations.datelessArtistLinksByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            chunkTitles.flashes,
-            chunkItems.flashes,
-            relations.datelessArtistLinksByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [titles, items, datelessArtistLinks]]) => [
-          key,
-          html.tags([
-            html.tag('dl',
-              stitchArrays({
-                title: titles,
-                items: items,
-              }).map(({title, items}) => [title, items])),
-
-            !empty(datelessArtistLinks) && [
-              html.tag('p',
-                language.$('listingPage.listArtists.byLatest.dateless.title')),
-
-              html.tag('ul',
-                datelessArtistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.dateless.item', {
-                      artist: artistLink,
-                    })))),
-            ],
-          ]),
-        ]));
-
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    chunkAlbumLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Album
+            ? relation('linkAlbum', thing)
+            : null)),
+
+    chunkFlashLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Flash
+            ? relation('linkFlash', thing)
+            : null)),
+
+    chunkArtistLinks:
+      query.chunkArtists
+        .map(artists => artists
+          .map(artist => relation('linkArtist', artist))),
+
+    datelessArtistLinks:
+      query.datelessArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    chunkDates: query.chunkDates,
+    chunkArtistContributions: query.chunkArtistContributions,
+  }),
+
+  generate(data, relations, {language}) {
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.chunkAlbumLinks,
+          flashLink: relations.chunkFlashLinks,
+          date: data.chunkDates,
+        }).map(({albumLink, flashLink, date}) => ({
+            date: language.formatDate(date),
+            ...(albumLink
+              ? {stringsKey: 'album', album: albumLink}
+              : {stringsKey: 'flash', flash: flashLink}),
+          }))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [{stringsKey: 'dateless'}])),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.chunkArtistLinks,
+          contributions: data.chunkArtistContributions,
+        }).map(({artistLinks, contributions}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              contribution: contributions,
+            }).map(({artistLink, contribution}) => ({
+                artist: artistLink,
+                stringsKey:
+                  (contribution.has('track') && contribution.has('artwork')
+                    ? 'tracksAndArt'
+                 : contribution.has('track')
+                    ? 'tracks'
+                 : contribution.has('artwork')
+                    ? 'art'
+                    : null),
+              })))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [
+                  relations.datelessArtistLinks.map(artistLink => ({
+                    artist: artistLink,
+                  })),
+                ])),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
index 6c0ad836..554b4587 100644
--- a/src/content/dependencies/listArtistsByName.js
+++ b/src/content/dependencies/listArtistsByName.js
@@ -2,38 +2,33 @@ import {stitchArrays} from '#sugar';
 import {getArtistNumContributions, sortAlphabetically} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtist'],
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({artistData}) {
-    return {artistData};
-  },
+  sprawl: ({artistData, wikiInfo}) =>
+    ({artistData, wikiInfo}),
 
-  query({artistData}, spec) {
-    return {
-      spec,
+  query: (sprawl, spec) => ({
+    spec,
 
-      artists: sortAlphabetically(artistData.slice()),
-    };
-  },
+    artists:
+      sortAlphabetically(sprawl.artistData.slice()),
+  }),
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      artistLinks:
-        query.artists
-          .map(artist => relation('linkArtist', artist)),
-    };
-  },
+    artistLinks:
+      query.artists
+        .map(artist => relation('linkArtist', artist)),
+  }),
 
-  data(query) {
-    return {
-      counts:
-        query.artists
-          .map(artist => getArtistNumContributions(artist)),
-    };
-  },
+  data: (query) => ({
+    counts:
+      query.artists
+        .map(artist => getArtistNumContributions(artist)),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index 43bf7dd5..0b904019 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -1,48 +1,118 @@
+import {empty} from '#sugar';
+import {sortChronologically} from '#wiki-data';
+
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListRandomPageLinksGroupSection',
+    'generateListRandomPageLinksAlbumLink',
+    'linkGroup',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupData}) {
-    return {groupData};
-  },
+  sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}),
 
   query(sprawl, spec) {
-    const group = directory =>
-      sprawl.groupData.find(group => group.directory === directory);
-
-    return {
-      spec,
-      officialGroup: group('official'),
-      fandomGroup: group('fandom'),
-      beyondGroup: group('beyond'),
-    };
+    const query = {spec};
+
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    query.divideByGroups = !empty(groups);
+
+    if (query.divideByGroups) {
+      query.groups = groups;
+
+      query.groupAlbums =
+        groups
+          .map(group =>
+            group.albums.filter(album => album.tracks.length > 1));
+    } else {
+      query.undividedAlbums =
+        sortChronologically(sprawl.albumData.slice())
+          .filter(album => album.tracks.length > 1);
+    }
+
+    return query;
   },
 
   relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.divideByGroups) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.groupAlbumLinks =
+        query.groupAlbums
+          .map(albums => albums
+            .map(album =>
+              relation('generateListRandomPageLinksAlbumLink', album)));
+    } else {
+      relations.undividedAlbumLinks =
+        query.undividedAlbums
+          .map(album =>
+            relation('generateListRandomPageLinksAlbumLink', album));
+    }
+
+    return relations;
+  },
 
-      officialSection:
-        relation('generateListRandomPageLinksGroupSection', query.officialGroup),
+  data(query) {
+    const data = {};
 
-      fandomSection:
-        relation('generateListRandomPageLinksGroupSection', query.fandomGroup),
+    if (query.divideByGroups) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+    }
 
-      beyondSection:
-        relation('generateListRandomPageLinksGroupSection', query.beyondGroup),
-    };
+    return data;
   },
 
-  generate(relations, {html, language}) {
+  generate(data, relations, {html, language}) {
+    const miscellaneousChunkRows = [
+      {
+        stringsKey: 'randomArtist',
+
+        mainLink:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
+      },
+
+      {stringsKey: 'randomAlbumWholeSite'},
+      {stringsKey: 'randomTrackWholeSite'},
+    ];
+
+    const miscellaneousChunkRowAttributes = [
+      null,
+      {href: '#', 'data-random': 'album'},
+      {href: '#','data-random': 'track'},
+    ];
+
     return relations.page.slots({
-      type: 'custom',
+      type: 'chunks',
+
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine')),
+          language.$('listingPage.other.randomPages.chooseLinkLine', {
+            fromPart:
+              (relations.groupLinks
+                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
+                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
 
         html.tag('p',
           {class: 'js-hide-once-data'},
@@ -51,40 +121,71 @@ export default {
         html.tag('p',
           {class: 'js-show-once-data'},
           language.$('listingPage.other.randomPages.dataLoadedLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
 
-        html.tag('dl', [
-          html.tag('dt',
-            language.$('listingPage.other.randomPages.misc')),
-
-          html.tag('dd',
-            html.tag('ul', [
-              html.tag('li', [
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist'},
-                  language.$('listingPage.other.randomPages.misc.randomArtist')),
-
-                '(' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-                  language.$('listingPage.other.randomPages.misc.atLeastTwoContributions')) +
-                ')',
+      chunkTitles: [
+        {stringsKey: 'misc'},
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(groupLink => ({
+                stringsKey: 'fromGroup',
+                group: groupLink,
+              }))
+            : [{stringsKey: 'fromAlbum'}]),
+      ],
+
+      chunkTitleAccents: [
+        null,
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(() => ({
+                randomAlbum:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'album-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
+
+                randomTrack:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'track-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
+              }))
+            : [null]),
+      ],
+
+      chunkRows: [
+        miscellaneousChunkRows,
+
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })))
+            : [
+                relations.undividedAlbumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })),
               ]),
+      ],
 
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'album'},
-                  language.$('listingPage.other.randomPages.misc.randomAlbumWholeSite'))),
-
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'track'},
-                  language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))),
-            ])),
-
-          relations.officialSection,
-          relations.fandomSection,
-          relations.beyondSection,
-        ]),
+      chunkRowAttributes: [
+        miscellaneousChunkRowAttributes,
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(() => null))
+            : [relations.undividedAlbumLinks.map(() => null)]),
       ],
     });
   },
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index d6546e67..25beb739 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -71,8 +71,15 @@ export default {
               rerelease: rereleases,
             }).map(({trackLink, rerelease}) =>
                 (rerelease
-                  ? {track: trackLink, stringsKey: 'rerelease'}
+                  ? {stringsKey: 'rerelease', track: trackLink}
                   : {track: trackLink}))),
+
+      chunkRowAttributes:
+        data.rereleases.map(rereleases =>
+          rereleases.map(rerelease =>
+            (rerelease
+              ? {class: 'rerelease'}
+              : null))),
     });
   },
 };
diff --git a/src/data/language.js b/src/data/language.js
index 09466907..3fc14da7 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -1,39 +1,199 @@
+import EventEmitter from 'node:events';
 import {readFile} from 'node:fs/promises';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
 
-// It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+import chokidar from 'chokidar';
+import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
+import yaml from 'js-yaml';
 
 import T from '#things';
+import {colors, logWarn} from '#cli';
 
-export async function processLanguageFile(file) {
-  const contents = await readFile(file, 'utf-8');
-  const json = JSON.parse(contents);
+import {
+  annotateError,
+  annotateErrorWithFile,
+  showAggregate,
+  withAggregate,
+} from '#sugar';
+
+const {Language} = T;
+
+export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
+
+export const internalDefaultStringsFile =
+  path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    '../',
+    DEFAULT_STRINGS_FILE);
+
+export function processLanguageSpec(spec, {existingCode = null} = {}) {
+  const {
+    'meta.languageCode': code,
+    'meta.languageName': name,
+
+    'meta.languageIntlCode': intlCode = null,
+    'meta.hidden': hidden = false,
+
+    ...strings
+  } = spec;
+
+  withAggregate({message: `Errors validating language spec`}, ({push}) => {
+    if (!code) {
+      push(new Error(`Missing language code`));
+    }
+
+    if (!name) {
+      push(new Error(`Missing language name`));
+    }
+
+    if (code && existingCode && code !== existingCode) {
+      push(new Error(`Language code (${code}) doesn't match previous value\n(You'll have to reload hsmusic to load this)`));
+    }
+  });
+
+  return {code, intlCode, name, hidden, strings};
+}
 
-  const code = json['meta.languageCode'];
-  if (!code) {
-    throw new Error(`Missing language code (file: ${file})`);
+function flattenLanguageSpec(spec) {
+  const recursive = (keyPath, value) =>
+    (typeof value === 'object'
+      ? Object.assign({}, ...
+          Object.entries(value)
+            .map(([key, value]) =>
+              (key === '_'
+                ? {[keyPath]: value}
+                : recursive(
+                    (keyPath ? `${keyPath}.${key}` : key),
+                    value))))
+      : {[keyPath]: value});
+
+  return recursive('', spec);
+}
+
+async function processLanguageSpecFromFile(file, processLanguageSpecOpts) {
+  let contents;
+
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to read language file`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
   }
-  delete json['meta.languageCode'];
 
-  const intlCode = json['meta.languageIntlCode'] ?? null;
-  delete json['meta.languageIntlCode'];
+  let rawSpec;
+  let parseLanguage;
 
-  const name = json['meta.languageName'];
-  if (!name) {
-    throw new Error(`Missing language name (${code})`);
+  try {
+    if (path.extname(file) === '.yaml') {
+      parseLanguage = 'YAML';
+      rawSpec = yaml.load(contents);
+    } else {
+      parseLanguage = 'JSON';
+      rawSpec = JSON.parse(contents);
+    }
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to parse language file as valid ${parseLanguage}`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
   }
-  delete json['meta.languageName'];
 
-  const hidden = json['meta.hidden'] ?? false;
-  delete json['meta.hidden'];
+  const flattenedSpec = flattenLanguageSpec(rawSpec);
 
-  const language = new T.Language();
-  language.code = code;
-  language.intlCode = intlCode;
-  language.name = name;
-  language.hidden = hidden;
-  language.escapeHTML = (string) =>
+  try {
+    return processLanguageSpec(flattenedSpec, processLanguageSpecOpts);
+  } catch (caughtError) {
+    throw annotateErrorWithFile(caughtError, file);
+  }
+}
+
+export function initializeLanguageObject() {
+  const language = new Language();
+
+  language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
-  language.strings = json;
+
   return language;
 }
+
+export async function processLanguageFile(file) {
+  const language = initializeLanguageObject();
+  const properties = await processLanguageSpecFromFile(file);
+  return Object.assign(language, properties);
+}
+
+export function watchLanguageFile(file, {
+  logging = true,
+} = {}) {
+  const basename = path.basename(file);
+
+  const events = new EventEmitter();
+  const language = initializeLanguageObject();
+
+  let emittedReady = false;
+  let successfullyAppliedLanguage = false;
+
+  Object.assign(events, {language, close});
+
+  const watcher = chokidar.watch(file);
+  watcher.on('change', () => handleFileUpdated());
+
+  setImmediate(handleFileUpdated);
+
+  return events;
+
+  async function close() {
+    return watcher.close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (!successfullyAppliedLanguage) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  async function handleFileUpdated() {
+    let properties;
+
+    try {
+      properties = await processLanguageSpecFromFile(file, {
+        existingCode:
+          (successfullyAppliedLanguage
+            ? language.code
+            : null),
+      });
+    } catch (error) {
+      events.emit('error', error);
+
+      if (logging) {
+        const label =
+          (successfullyAppliedLanguage
+            ? `${language.name} (${language.code})`
+            : basename);
+
+        if (successfullyAppliedLanguage) {
+          logWarn`Failed to load language ${label} - using existing version`;
+        } else {
+          logWarn`Failed to load language ${label} - no prior version loaded`;
+        }
+        showAggregate(error, {showTraces: false});
+      }
+
+      return;
+    }
+
+    Object.assign(language, properties);
+    successfullyAppliedLanguage = true;
+
+    if (logging && emittedReady) {
+      const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+      console.log(colors.green(`[${timestamp}] Updated language ${language.name} (${language.code})`));
+    }
+
+    events.emit('update');
+    checkReadyConditions();
+  }
+}
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index e0350b86..a51723c4 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -107,6 +107,23 @@ export class Artist extends Thing {
     albumsAsBannerArtist:
       Artist.filterByContrib('albumData', 'bannerArtistContribs'),
 
+    albumsAsAny: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Artist.instance]: artist}) =>
+          albumData?.filter((album) =>
+            [
+              ...album.artistContribs,
+              ...album.coverArtistContribs,
+              ...album.wallpaperArtistContribs,
+              ...album.bannerArtistContribs,
+            ].some(({who}) => who === artist)) ?? [],
+      },
+    },
+
     albumsAsCommentator: {
       flags: {expose: true},
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 89053d62..3db9727b 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,9 +1,8 @@
 import {input} from '#composite';
 import find from '#find';
-import {isLanguageCode, isName, isURL} from '#validators';
+import {isColor, isLanguageCode, isName, isURL} from '#validators';
 
 import {
-  color,
   flag,
   name,
   referenceList,
@@ -32,7 +31,14 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: color(),
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
 
     // One-line description used for <meta rel="description"> tag.
     description: simpleString(),
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 2b33744a..9433ee68 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -44,12 +44,14 @@ listingSpec.push({
   directory: 'artists/by-name',
   stringsKey: 'listArtists.byName',
   contentFunction: 'listArtistsByName',
+  seeAlso: ['artists/by-contribs', 'artists/by-group'],
 });
 
 listingSpec.push({
   directory: 'artists/by-contribs',
   stringsKey: 'listArtists.byContribs',
   contentFunction: 'listArtistsByContributions',
+  seeAlso: ['artists/by-name', 'artists/by-group'],
 });
 
 listingSpec.push({
@@ -64,6 +66,15 @@ listingSpec.push({
   contentFunction: 'listArtistsByDuration',
 });
 
+// TODO: hide if no groups...
+listingSpec.push({
+  directory: 'artists/by-group',
+  stringsKey: 'listArtists.byGroup',
+  contentFunction: 'listArtistsByGroup',
+  featureFlag: 'enableGroupUI',
+  seeAlso: ['artists/by-name', 'artists/by-contribs'],
+});
+
 listingSpec.push({
   directory: 'artists/by-latest',
   stringsKey: 'listArtists.byLatest',
diff --git a/src/repl.js b/src/repl.js
index 26879be4..dd61133c 100644
--- a/src/repl.js
+++ b/src/repl.js
@@ -5,7 +5,7 @@ import {fileURLToPath} from 'node:url';
 
 import {logError, logWarn, parseOptions} from '#cli';
 import {isMain} from '#node-utils';
-import {processLanguageFile} from '#language';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
 import {bindOpts, showAggregate} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
 import {quickLoadAllFromYAML} from '#yaml';
@@ -44,10 +44,7 @@ export async function getContextAssignments({
 
   let language;
   try {
-    language = await processLanguageFile(
-      path.join(
-        path.dirname(fileURLToPath(import.meta.url)),
-        'strings-default.json'));
+    language = await processLanguageFile(internalDefaultStringsFile);
   } catch (error) {
     console.error(error);
     logWarn`Failed to create Language object`;
diff --git a/src/static/client2.js b/src/static/client2.js
index 523b48d8..0ec052bd 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -149,6 +149,47 @@ function addRandomLinkListeners() {
           a.href = openAlbum(pick(albumData).directory);
           break;
 
+        case 'track':
+          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
+          break;
+
+        case 'album-in-group-dl': {
+          const albumLinks =
+            Array.from(a
+              .closest('dt')
+              .nextElementSibling
+              .querySelectorAll('li a'))
+
+          const albumDirectories =
+            albumLinks.map(a =>
+              getComputedStyle(a).getPropertyValue('--album-directory'));
+
+          a.href = openAlbum(pick(albumDirectories));
+          break;
+        }
+
+        case 'track-in-group-dl': {
+          const albumLinks =
+            Array.from(a
+              .closest('dt')
+              .nextElementSibling
+              .querySelectorAll('li a'))
+
+          const albumDirectories =
+            albumLinks.map(a =>
+              getComputedStyle(a).getPropertyValue('--album-directory'));
+
+          const filteredAlbumData =
+            albumData.filter(album =>
+              albumDirectories.includes(album.directory));
+
+          a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData))));
+          break;
+        }
+
+        /* Legacy links, for old versions             *
+         * of generateListRandomPageLinksGroupSection */
+
         case 'album-in-official':
           a.href = openAlbum(pick(officialAlbumData).directory);
           break;
@@ -161,9 +202,7 @@ function addRandomLinkListeners() {
           a.href = openAlbum(pick(beyondAlbumData).directory);
           break;
 
-        case 'track':
-          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
-          break;
+        /* End legacy links */
 
         case 'track-in-album':
           a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
@@ -840,6 +879,10 @@ function updateStickySubheadingContent(index) {
     }
 
     for (const child of closestHeading.childNodes) {
+      if (child.classList?.contains('content-heading-accent')) {
+        continue;
+      }
+
       if (child.tagName === 'A') {
         for (const grandchild of child.childNodes) {
           stickySubheading.appendChild(grandchild.cloneNode(true));
diff --git a/src/static/site5.css b/src/static/site5.css
index 0eb7dcda..014e6d25 100644
--- a/src/static/site5.css
+++ b/src/static/site5.css
@@ -3,13 +3,7 @@
  * no need to re-run upd8.js when tweaking values here. Handy!
  */
 
-:root {
-  --primary-color: #0088ff;
-}
-
-/* Layout - Common
- *
- */
+/* Layout - Common */
 
 body {
   margin: 10px;
@@ -1275,6 +1269,12 @@ html[data-url-key="localized.home"] .carousel-container {
   animation-delay: 125ms;
 }
 
+.content-heading .content-heading-accent {
+  font-weight: normal;
+  font-size: 1rem;
+  margin-left: 0.25em;
+}
+
 h3.content-heading {
   clear: both;
 }
diff --git a/src/strings-default.json b/src/strings-default.json
deleted file mode 100644
index b6471bdf..00000000
--- a/src/strings-default.json
+++ /dev/null
@@ -1,513 +0,0 @@
-{
-  "meta.languageCode": "en",
-  "meta.languageName": "English",
-  "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.additionalFiles": "{FILES}",
-  "count.additionalFiles.withUnit.zero": "",
-  "count.additionalFiles.withUnit.one": "{FILES} file",
-  "count.additionalFiles.withUnit.two": "",
-  "count.additionalFiles.withUnit.few": "",
-  "count.additionalFiles.withUnit.many": "",
-  "count.additionalFiles.withUnit.other": "{FILES} files",
-  "count.albums": "{ALBUMS}",
-  "count.albums.withUnit.zero": "",
-  "count.albums.withUnit.one": "{ALBUMS} album",
-  "count.albums.withUnit.two": "",
-  "count.albums.withUnit.few": "",
-  "count.albums.withUnit.many": "",
-  "count.albums.withUnit.other": "{ALBUMS} albums",
-  "count.artworks": "{ARTWORKS}",
-  "count.artworks.withUnit.zero": "",
-  "count.artworks.withUnit.one": "{ARTWORKS} artwork",
-  "count.artworks.withUnit.two": "",
-  "count.artworks.withUnit.few": "",
-  "count.artworks.withUnit.many": "",
-  "count.artworks.withUnit.other": "{ARTWORKS} artworks",
-  "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.flashes": "{FLASHES}",
-  "count.flashes.withUnit.zero": "",
-  "count.flashes.withUnit.one": "{FLASHES} flashes & games",
-  "count.flashes.withUnit.two": "",
-  "count.flashes.withUnit.few": "",
-  "count.flashes.withUnit.many": "",
-  "count.flashes.withUnit.other": "{FLASHES} flashes & games",
-  "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": "_:__",
-  "count.fileSize.terabytes": "{TERABYTES} TB",
-  "count.fileSize.gigabytes": "{GIGABYTES} GB",
-  "count.fileSize.megabytes": "{MEGABYTES} MB",
-  "count.fileSize.kilobytes": "{KILOBYTES} kB",
-  "count.fileSize.bytes": "{BYTES} bytes",
-  "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.viewGallery": "View {LINK}!",
-  "releaseInfo.viewGallery.link": "gallery page",
-  "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!",
-  "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page",
-  "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page",
-  "releaseInfo.viewOriginalFile": "View {LINK}.",
-  "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).",
-  "releaseInfo.viewOriginalFile.link": "original file",
-  "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)",
-  "releaseInfo.listenOn": "Listen on {LINKS}.",
-  "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.",
-  "releaseInfo.visitOn": "Visit on {LINKS}.",
-  "releaseInfo.playOn": "Play on {LINKS}.",
-  "releaseInfo.readCommentary": "Read {LINK}.",
-  "releaseInfo.readCommentary.link": "artist commentary",
-  "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.tracksSampled": "Tracks that {TRACK} samples:",
-  "releaseInfo.tracksThatSample": "Tracks that sample {TRACK}:",
-  "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-  "releaseInfo.flashesThatFeature.item": "{FLASH}",
-  "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-  "releaseInfo.tracksFeatured": "Tracks that {FLASH} features:",
-  "releaseInfo.lyrics": "Lyrics:",
-  "releaseInfo.artistCommentary": "Artist commentary:",
-  "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-  "releaseInfo.artTags": "Tags:",
-  "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files",
-  "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:",
-  "releaseInfo.additionalFiles.entry": "{TITLE}",
-  "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
-  "releaseInfo.additionalFiles.file": "{FILE}",
-  "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
-  "releaseInfo.sheetMusicFiles.shortcut": "Download {LINK}.",
-  "releaseInfo.sheetMusicFiles.shortcut.link": "sheet music files",
-  "releaseInfo.sheetMusicFiles.heading": "Print or download sheet music files:",
-  "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.",
-  "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files",
-  "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:",
-  "releaseInfo.note": "Context notes:",
-  "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-  "trackList.group": "From {GROUP}:",
-  "trackList.group.fromOther": "From somewhere else:",
-  "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.artistLink": "{ARTIST}",
-  "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})",
-  "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})",
-  "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})",
-  "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.chronology.withNavigation": "{HEADING} ({NAVIGATION})",
-  "misc.external.domain": "External ({DOMAIN})",
-  "misc.external.local": "Wiki Archive (local upload)",
-  "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.newgrounds": "Newgrounds",
-  "misc.external.patreon": "Patreon",
-  "misc.external.poetryFoundation": "Poetry Foundation",
-  "misc.external.soundcloud": "SoundCloud",
-  "misc.external.spotify": "Spotify",
-  "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.missingImage": "(This image file is missing)",
-  "misc.missingLinkContent": "(Missing link content)",
-  "misc.nav.previous": "Previous",
-  "misc.nav.next": "Next",
-  "misc.nav.info": "Info",
-  "misc.nav.gallery": "Gallery",
-  "misc.pageTitle": "{TITLE}",
-  "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
-  "misc.skippers.skipTo": "Skip to:",
-  "misc.skippers.content": "Content",
-  "misc.skippers.sidebar": "Sidebar",
-  "misc.skippers.sidebar.left": "Sidebar (left)",
-  "misc.skippers.sidebar.right": "Sidebar (right)",
-  "misc.skippers.header": "Header",
-  "misc.skippers.footer": "Footer",
-  "misc.skippers.tracks": "Tracks",
-  "misc.skippers.art": "Artworks",
-  "misc.skippers.flashes": "Flashes & Games",
-  "misc.skippers.contributors": "Contributors",
-  "misc.skippers.references": "References...",
-  "misc.skippers.referencedBy": "Referenced by...",
-  "misc.skippers.samples": "Samples...",
-  "misc.skippers.sampledBy": "Sampled by...",
-  "misc.skippers.features": "Features...",
-  "misc.skippers.featuredIn": "Featured in...",
-  "misc.skippers.lyrics": "Lyrics",
-  "misc.skippers.sheetMusicFiles": "Sheet music files",
-  "misc.skippers.midiProjectFiles": "MIDI/project files",
-  "misc.skippers.additionalFiles": "Additional files",
-  "misc.skippers.commentary": "Commentary",
-  "misc.skippers.artistCommentary": "Commentary",
-  "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
-  "misc.jumpTo": "Jump to:",
-  "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-  "misc.contentWarnings": "cw: {WARNINGS}",
-  "misc.contentWarnings.reveal": "click to show",
-  "misc.albumGrid.details": "({TRACKS}, {TIME})",
-  "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})",
-  "misc.albumGrid.details.otherCoverArtists": "(With {ARTISTS})",
-  "misc.albumGrid.noCoverArt": "{ALBUM}",
-  "misc.albumGalleryGrid.noCoverArt": "{NAME}",
-  "misc.uiLanguage": "UI Language: {LANGUAGES}",
-  "homepage.title": "{TITLE}",
-  "homepage.news.title": "News",
-  "homepage.news.entry.viewRest": "(View rest of entry!)",
-  "albumSidebar.trackList.fallbackSectionName": "Track list",
-  "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",
-  "albumPage.nav.gallery": "Gallery",
-  "albumPage.nav.commentary": "Commentary",
-  "albumPage.socialEmbed.heading": "{GROUP}",
-  "albumPage.socialEmbed.title": "{ALBUM}",
-  "albumPage.socialEmbed.body.withDuration": "{DURATION}.",
-  "albumPage.socialEmbed.body.withTracks": "{TRACKS}.",
-  "albumPage.socialEmbed.body.withReleaseDate": "Released {DATE}.",
-  "albumPage.socialEmbed.body.withDuration.withTracks": "{DURATION}, {TRACKS}.",
-  "albumPage.socialEmbed.body.withDuration.withReleaseDate": "{DURATION}. Released {DATE}.",
-  "albumPage.socialEmbed.body.withTracks.withReleaseDate": "{TRACKS}. Released {DATE}.",
-  "albumPage.socialEmbed.body.withDuration.withTracks.withReleaseDate": "{DURATION}, {TRACKS}. Released {DATE}.",
-  "albumGalleryPage.title": "{ALBUM} - Gallery",
-  "albumGalleryPage.statsLine": "{TRACKS} totaling {DURATION}.",
-  "albumGalleryPage.statsLine.withDate": "{TRACKS} totaling {DURATION}. Released {DATE}.",
-  "albumGalleryPage.coverArtistsLine": "All track artwork by {ARTISTS}.",
-  "albumGalleryPage.noTrackArtworksLine": "This album doesn't have any track artwork.",
-  "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.withDuration": "{ALBUM} ({DURATION})",
-  "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
-  "artistPage.creditList.flashAct": "{ACT}",
-  "artistPage.creditList.flashAct.withDate": "{ACT} ({DATE})",
-  "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.withCount": "{GROUP} ({COUNT})",
-  "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})",
-  "artistPage.groupContributions.title.music": "Contributed music to groups:",
-  "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:",
-  "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})",
-  "artistPage.groupContributions.title.sorting.count": "Sorting by count.",
-  "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.",
-  "artistPage.groupContributions.item.countAccent": "({COUNT})",
-  "artistPage.groupContributions.item.durationAccent": "({DURATION})",
-  "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})",
-  "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})",
-  "artistPage.trackList.title": "Tracks",
-  "artistPage.artList.title": "Artworks",
-  "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}",
-  "flashSidebar.flashList.flashesInThisAct": "Flashes in this act",
-  "flashSidebar.flashList.entriesInThisSection": "Entries in this section",
-  "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}",
-  "groupInfoPage.albumList.item.withoutYear": "{ALBUM}",
-  "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}",
-  "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})",
-  "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.target.album": "Albums",
-  "listingPage.target.artist": "Artists",
-  "listingPage.target.group": "Groups",
-  "listingPage.target.track": "Tracks",
-  "listingPage.target.tag": "Tags",
-  "listingPage.target.other": "Other",
-  "listingPage.listingsFor": "Listings for {TARGET}: {LISTINGS}",
-  "listingPage.seeAlso": "Also check out: {LISTINGS}",
-  "listingPage.listAlbums.byName.title": "Albums - by Name",
-  "listingPage.listAlbums.byName.title.short": "...by Name",
-  "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
-  "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
-  "listingPage.listAlbums.byTracks.title.short": "...by Tracks",
-  "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
-  "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
-  "listingPage.listAlbums.byDuration.title.short": "...by Duration",
-  "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
-  "listingPage.listAlbums.byDate.title": "Albums - by Date",
-  "listingPage.listAlbums.byDate.title.short": "...by Date",
-  "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
-  "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki",
-  "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
-  "listingPage.listAlbums.byDateAdded.chunk.title": "{DATE}",
-  "listingPage.listAlbums.byDateAdded.chunk.item": "{ALBUM}",
-  "listingPage.listArtists.byName.title": "Artists - by Name",
-  "listingPage.listArtists.byName.title.short": "...by Name",
-  "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
-  "listingPage.listArtists.byContribs.title.short": "...by Contributions",
-  "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
-  "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries",
-  "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
-  "listingPage.listArtists.byDuration.title": "Artists - by Duration",
-  "listingPage.listArtists.byDuration.title.short": "...by Duration",
-  "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
-  "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
-  "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
-  "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})",
-  "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})",
-  "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}",
-  "listingPage.listArtists.byLatest.dateless.title": "These artists' contributions aren't dated:",
-  "listingPage.listArtists.byLatest.dateless.item": "{ARTIST}",
-  "listingPage.listGroups.byName.title": "Groups - by Name",
-  "listingPage.listGroups.byName.title.short": "...by Name",
-  "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
-  "listingPage.listGroups.byName.item.gallery": "Gallery",
-  "listingPage.listGroups.byCategory.title": "Groups - by Category",
-  "listingPage.listGroups.byCategory.title.short": "...by Category",
-  "listingPage.listGroups.byCategory.chunk.title": "{CATEGORY}",
-  "listingPage.listGroups.byCategory.chunk.item": "{GROUP} ({GALLERY})",
-  "listingPage.listGroups.byCategory.chunk.item.gallery": "Gallery",
-  "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
-  "listingPage.listGroups.byAlbums.title.short": "...by Albums",
-  "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
-  "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
-  "listingPage.listGroups.byTracks.title.short": "...by Tracks",
-  "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
-  "listingPage.listGroups.byDuration.title": "Groups - by Duration",
-  "listingPage.listGroups.byDuration.title.short": "...by Duration",
-  "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
-  "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
-  "listingPage.listGroups.byLatest.title.short": "...by Latest Album",
-  "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
-  "listingPage.listTracks.byName.title": "Tracks - by Name",
-  "listingPage.listTracks.byName.title.short": "...by Name",
-  "listingPage.listTracks.byName.item": "{TRACK}",
-  "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
-  "listingPage.listTracks.byAlbum.title.short": "...by Album",
-  "listingPage.listTracks.byAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.byAlbum.chunk.item": "{TRACK}",
-  "listingPage.listTracks.byDate.title": "Tracks - by Date",
-  "listingPage.listTracks.byDate.title.short": "...by Date",
-  "listingPage.listTracks.byDate.chunk.title": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.byDate.chunk.item": "{TRACK}",
-  "listingPage.listTracks.byDate.chunk.item.rerelease": "{TRACK} (re-release)",
-  "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
-  "listingPage.listTracks.byDuration.title.short": "...by Duration",
-  "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
-  "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
-  "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
-  "listingPage.listTracks.byDurationInAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.byDurationInAlbum.chunk.item": "{TRACK} ({DURATION})",
-  "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
-  "listingPage.listTracks.byTimesReferenced.title.short": "...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.title.short": "...in Flashes & Games (by Album)",
-  "listingPage.listTracks.inFlashes.byAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.inFlashes.byAlbum.chunk.item": "{TRACK} (in {FLASHES})",
-  "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
-  "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
-  "listingPage.listTracks.inFlashes.byFlash.chunk.title": "{FLASH}",
-  "listingPage.listTracks.inFlashes.byFlash.chunk.item": "{TRACK} (from {ALBUM})",
-  "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
-  "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
-  "listingPage.listTracks.withLyrics.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withLyrics.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withLyrics.chunk.item": "{TRACK}",
-  "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files",
-  "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.item": "{TRACK}",
-  "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files",
-  "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.item": "{TRACK}",
-  "listingPage.listTags.byName.title": "Tags - by Name",
-  "listingPage.listTags.byName.title.short": "...by Name",
-  "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
-  "listingPage.listTags.byUses.title": "Tags - by Uses",
-  "listingPage.listTags.byUses.title.short": "...by Uses",
-  "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
-  "listingPage.other.allSheetMusic.title": "All Sheet Music",
-  "listingPage.other.allSheetMusic.title.short": "All Sheet Music",
-  "listingPage.other.allSheetMusic.albumFiles": "Album sheet music:",
-  "listingPage.other.allSheetMusic.file": "{TITLE}",
-  "listingPage.other.allSheetMusic.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.allMidiProjectFiles.title": "All MIDI/Project Files",
-  "listingPage.other.allMidiProjectFiles.title.short": "All MIDI/Project Files",
-  "listingPage.other.allMidiProjectFiles.albumFiles": "Album MIDI/project files:",
-  "listingPage.other.allMidiProjectFiles.file": "{TITLE}",
-  "listingPage.other.allMidiProjectFiles.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.allAdditionalFiles.title": "All Additional Files",
-  "listingPage.other.allAdditionalFiles.title.short": "All Additional Files",
-  "listingPage.other.allAdditionalFiles.albumFiles": "Album additional files:",
-  "listingPage.other.allAdditionalFiles.file": "{TITLE}",
-  "listingPage.other.allAdditionalFiles.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.randomPages.title": "Random Pages",
-  "listingPage.other.randomPages.title.short": "Random Pages",
-  "listingPage.other.randomPages.chooseLinkLine": "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.",
-  "listingPage.other.randomPages.dataLoadingLine": "(Data files are downloading in the background! Please wait for data to load.)",
-  "listingPage.other.randomPages.dataLoadedLine": "(Data files have finished being downloaded. The links should work!)",
-  "listingPage.other.randomPages.misc": "Miscellaneous:",
-  "listingPage.other.randomPages.misc.randomArtist": "Random Artist",
-  "listingPage.other.randomPages.misc.atLeastTwoContributions": "at least 2 contributions",
-  "listingPage.other.randomPages.misc.randomAlbumWholeSite": "Random Album (whole site)",
-  "listingPage.other.randomPages.misc.randomTrackWholeSite": "Random Track (whole site)",
-  "listingPage.other.randomPages.group": "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})",
-  "listingPage.other.randomPages.group.randomAlbum": "Random Album",
-  "listingPage.other.randomPages.group.randomTrack": "Random Track",
-  "listingPage.other.randomPages.album": "{ALBUM}",
-  "listingPage.misc.trackContributors": "Track Contributors",
-  "listingPage.misc.artContributors": "Art Contributors",
-  "listingPage.misc.flashContributors": "Flash & Game Contributors",
-  "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
-  "newsIndex.title": "News",
-  "newsIndex.entry.viewRest": "(View rest of entry!)",
-  "newsEntryPage.title": "{ENTRY}",
-  "newsEntryPage.published": "(Published {DATE}.)",
-  "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",
-  "trackPage.socialEmbed.heading": "{ALBUM}",
-  "trackPage.socialEmbed.title": "{TRACK}",
-  "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.",
-  "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.",
-  "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}."
-}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
new file mode 100644
index 00000000..a21758e7
--- /dev/null
+++ b/src/strings-default.yaml
@@ -0,0 +1,1691 @@
+meta.languageCode: en
+meta.languageName: English
+
+#
+# count:
+#
+#   This covers pretty much any time that a specific number of things
+#   is represented! It's sectioned... like an alignment chart meme...
+#
+#   First counting specific wiki objects, then more abstract stuff,
+#   and finally numerical representations of kinds of quantities that
+#   aren't really "counting", per se.
+#
+#   These must be filled out according to the Unicode Common Locale
+#   Data Repository (Unicode CLDR). Check out info on their site:
+#   https://cldr.unicode.org
+#
+#   Specifically, you'll want to look into the Plural Rules for your
+#   language. Here's a summary on what those even are:
+#   https://cldr.unicode.org/index/cldr-spec/plural-rules
+#
+#   CLDR's charts are available online! This should bring you to the
+#   most recent table of plural rules:
+#   https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
+#
+#   Counting is generally done with the "Type: cardinal" section on
+#   that chart - for example, if the chart lists "one", "many", and
+#   "other" under the cardinal plural rules for your language, then
+#   your job is to fill in the correct pluralizations of the specific
+#   term for each of those.
+#
+#   If you adore technical details or want to better understand the
+#   "Rules" column, you'll want to check out the syntax outline here:
+#   https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules
+#
+count:
+
+  # Count things and objects
+
+  additionalFiles:
+    _: "{FILES}"
+    withUnit:
+      zero: ""
+      one: "{FILES} file"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FILES} files"
+
+  albums:
+    _: "{ALBUMS}"
+    withUnit:
+      zero: ""
+      one: "{ALBUMS} album"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ALBUMS} albums"
+
+  artworks:
+    _: "{ARTWORKS}"
+    withUnit:
+      zero: ""
+      one: "{ARTWORKS} artwork"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ARTWORKS} artworks"
+
+  commentaryEntries:
+    _: "{ENTRIES}"
+    withUnit:
+      zero: ""
+      one: "{ENTRIES} entry"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ENTRIES} entries"
+
+  contributions:
+    _: "{CONTRIBUTIONS}"
+    withUnit:
+      zero: ""
+      one: "{CONTRIBUTIONS} contribution"
+      two: ""
+      few: ""
+      many: ""
+      other: "{CONTRIBUTIONS} contributions"
+
+  coverArts:
+    _: "{COVER_ARTS}"
+    withUnit:
+      zero: ""
+      one: "{COVER_ARTS} cover art"
+      two: ""
+      few: ""
+      many: ""
+      other: "{COVER_ARTS} cover arts"
+
+  flashes:
+    _: "{FLASHES}"
+    withUnit:
+      zero: ""
+      one: "{FLASHES} flashes & games"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FLASHES} flashes & games"
+
+  tracks:
+    _: "{TRACKS}"
+    withUnit:
+      zero: ""
+      one: "{TRACKS} track"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TRACKS} tracks"
+
+  # Count more abstract stuff
+
+  days:
+    _: "{DAYS}"
+    withUnit:
+      zero: ""
+      one: "{DAYS} day"
+      two: ""
+      few: ""
+      many: ""
+      other: "{DAYS} days"
+
+  timesReferenced:
+    _: "{TIMES_REFERENCED}"
+    withUnit:
+      zero: ""
+      one: "{TIMES_REFERENCED} time referenced"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TIMES_REFERENCED} times referenced"
+
+  timesUsed:
+    _: "{TIMES_USED}"
+    withUnit:
+      zero: ""
+      one: "used {TIMES_USED} time"
+      two: ""
+      few: ""
+      many: ""
+      other: "used {TIMES_USED} times"
+
+  words:
+    _: "{WORDS}"
+    thousand: "{WORDS}k"
+    withUnit:
+      zero: ""
+      one: "{WORDS} word"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WORDS} words"
+
+  # Numerical things that aren't exactly counting, per se
+
+  duration:
+    missing: "_:__"
+    approximate: "~{DURATION}"
+    hours:
+      _:        "{HOURS}:{MINUTES}:{SECONDS}"
+      withUnit: "{HOURS}:{MINUTES}:{SECONDS} hours"
+    minutes:
+      _:        "{MINUTES}:{SECONDS}"
+      withUnit: "{MINUTES}:{SECONDS} minutes"
+
+  fileSize:
+    terabytes: "{TERABYTES} TB"
+    gigabytes: "{GIGABYTES} GB"
+    megabytes: "{MEGABYTES} MB"
+    kilobytes: "{KILOBYTES} kB"
+    bytes: "{BYTES} bytes"
+
+  # Indexes in a list
+  # These use "Type: ordinal" on CLDR's chart of plural rules.
+
+  index:
+    zero: ""
+    one: "{INDEX}st"
+    two: "{INDEX}nd"
+    few: "{INDEX}rd"
+    many: ""
+    other: "{INDEX}th"
+
+#
+# releaseInfo:
+#
+#   This covers a lot of generic strings - they're used in a variety
+#   of contexts. They're sorted below with descriptions first, then
+#   actions further down.
+#
+releaseInfo:
+
+  # Descriptions
+
+  by: "By {ARTISTS}."
+  from: "From {ALBUM}."
+
+  coverArtBy: "Cover art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
+  bannerArtBy: "Banner art by {ARTISTS}."
+
+  released: "Released {DATE}."
+  artReleased: "Art released {DATE}."
+  addedToWiki: "Added to wiki {DATE}."
+
+  duration: "Duration: {DURATION}."
+
+  contributors: "Contributors:"
+  lyrics: "Lyrics:"
+  note: "Context notes:"
+
+  alsoReleasedAs:
+    _: "Also released as:"
+    item: "{TRACK} (on {ALBUM})"
+
+  tracksReferenced: "Tracks that {TRACK} references:"
+  tracksThatReference: "Tracks that reference {TRACK}:"
+  tracksSampled: "Tracks that {TRACK} samples:"
+  tracksThatSample: "Tracks that sample {TRACK}:"
+
+  flashesThatFeature:
+    _: "Flashes & games that feature {TRACK}:"
+    item:
+      _: "{FLASH}"
+      asDifferentRelease: "{FLASH} (as {TRACK})"
+
+  tracksFeatured: "Tracks that {FLASH} features:"
+
+  artTags:
+    _: "Tags:"
+    inline: "Tags: {TAGS}"
+
+  # Actions
+
+  viewCommentary:
+    _: "View {LINK}!"
+    link: "commentary page"
+
+  viewGallery:
+    _: "View {LINK}!"
+    link: "gallery page"
+
+  viewGalleryOrCommentary:
+    _: "View {GALLERY} or {COMMENTARY}!"
+    gallery: "gallery page"
+    commentary: "commentary page"
+
+  viewOriginalFile:
+    _: "View {LINK}."
+    withSize: "View {LINK} ({SIZE})."
+    link: "original file"
+    sizeWarning: >-
+      (Heads up! If you're on a mobile plan, this is a large download.)
+
+  listenOn:
+    _: "Listen on {LINKS}."
+    noLinks: >-
+      This wiki doesn't have any listening links for {NAME}.
+
+  visitOn: "Visit on {LINKS}."
+  playOn: "Play on {LINKS}."
+
+  readCommentary:
+    _: "Read {LINK}."
+    link: "artist commentary"
+
+  artistCommentary:
+    _: "Artist commentary:"
+    seeOriginalRelease: "See {ORIGINAL}!"
+
+  additionalFiles:
+    heading: "View or download {ADDITIONAL_FILES}:"
+
+    entry:
+      _: "{TITLE}"
+      withDescription: "{TITLE}: {DESCRIPTION}"
+
+    file:
+      _: "{FILE}"
+      withSize: "{FILE} ({SIZE})"
+
+    shortcut:
+      _: "View {ANCHOR_LINK}: {TITLES}"
+      anchorLink: "additional files"
+
+  sheetMusicFiles:
+    heading: "Print or download sheet music files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "sheet music files"
+
+  midiProjectFiles:
+    heading: "Download MIDI/project files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "MIDI/project files"
+
+#
+# trackList:
+#
+#   A list of tracks! These are used pretty much across the wiki.
+#   Track lists can be split into sections, groups, or not split at
+#   all. "Track sections" are divisions in the list which suit the
+#   album as a whole, like if it has multiple discs or bonus tracks.
+#   "Groups" are actual group objects (see ex. groupInfoPage).
+#
+trackList:
+  section:
+    withDuration: "{SECTION} ({DURATION}):"
+
+  group:
+    _: "From {GROUP}:"
+    fromOther: "From somewhere else:"
+
+  item:
+    withDuration: "({DURATION}) {TRACK}"
+    withDuration.withArtists: "({DURATION}) {TRACK} {BY}"
+    withArtists: "{TRACK} {BY}"
+    withArtists.by: "by {ARTISTS}"
+    rerelease: "{TRACK} (re-release)"
+
+#
+# misc:
+#
+#   These cover a whole host of general things across the wiki, and
+#   aren't specially organized. Sorry! See each entry for details.
+#
+misc:
+
+  # alt:
+  #   Fallback text for the alt text of images and artworks - these
+  #   are read aloud by screen readers.
+
+  alt:
+    albumCover: "album cover"
+    albumBanner: "album banner"
+    trackCover: "track cover"
+    artistAvatar: "artist avatar"
+    flashArt: "flash art"
+
+  # artistLink:
+  #   Artist links have special accents which are made conditionally
+  #   present in a variety of places across the wiki.
+
+  artistLink:
+    _: "{ARTIST}"
+
+    # Contribution to a track, artwork, or other thing.
+    withContribution: "{ARTIST} ({CONTRIB})"
+
+    # External links to visit the artist's own websites or profiles.
+    withExternalLinks: "{ARTIST} ({LINKS})"
+
+    # Combination of above.
+    withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
+
+  # chronology:
+  #
+  #   "Chronology links" are a section that appear in the nav bar for
+  #   most things with individual contributors across the wiki! These
+  #   allow for quick navigation between older and newer releases of
+  #   a given artist, or seeing at a glance how many contributions an
+  #   artist made before the one you're currently viewing.
+  #
+  #   Chronology information is described for each artist and shows
+  #   the kind of thing which is being contributed to, since all of
+  #   the entries are displayed together in one list.
+  #
+
+  chronology:
+
+    # seeArtistPages:
+    #   If the thing you're viewing has a lot of contributors, their
+    #   chronology info will be exempt from the nav bar, which'll
+    #   show this message instead.
+
+    seeArtistPages: "(See artist pages for chronology info!)"
+
+    # withNavigation:
+    #   Navigation refers to previous/next links.
+
+    withNavigation: "{HEADING} ({NAVIGATION})"
+
+    heading:
+      coverArt: "{INDEX} cover art by {ARTIST}"
+      flash: "{INDEX} flash/game by {ARTIST}"
+      track: "{INDEX} track by {ARTIST}"
+
+  # external:
+  #   Links which will generally bring you somewhere off of the wiki.
+  #   The list of sites is hard-coded into the wiki software, so it
+  #   may be out of date or missing ones that are relevant to another
+  #   wiki - sorry!
+
+  external:
+
+    # domain:
+    #   General domain when one the URL doesn't match one of the
+    #   sites below.
+
+    domain: "External ({DOMAIN})"
+
+    # local:
+    #   Files which are locally available on the wiki (under its media
+    #   directory).
+
+    local: "Wiki Archive (local upload)"
+
+    deviantart: "DeviantArt"
+    instagram: "Instagram"
+    newgrounds: "Newgrounds"
+    patreon: "Patreon"
+    poetryFoundation: "Poetry Foundation"
+    soundcloud: "SoundCloud"
+    spotify: "Spotify"
+    tumblr: "Tumblr"
+    twitter: "Twitter"
+    wikipedia: "Wikipedia"
+
+    bandcamp:
+      _: "Bandcamp"
+      domain: "Bandcamp ({DOMAIN})"
+
+    mastodon:
+      _: "Mastodon"
+      domain: "Mastodon ({DOMAIN})"
+
+    youtube:
+      _: "YouTube"
+      playlist: "YouTube (playlist)"
+      fullAlbum: "YouTube (full album)"
+
+    flash:
+      bgreco: "{LINK} (HQ Audio)"
+      youtube: "{LINK} (on any device)"
+      homestuck:
+        page: "{LINK} (page {PAGE})"
+        secret: "{LINK} (secret page)"
+
+  # missingImage:
+  #   Fallback text displayed in an image when it's sourced to a file
+  #   that isn't available under the wiki's media directory. While it
+  #   shouldn't display on a correct build of the site, it may be
+  #   displayed when working on data locally (for example adding a
+  #   track before you've brought in its cover art).
+
+  missingImage: "(This image file is missing)"
+
+  # misingLinkContent:
+  #   Generic fallback when a link is completely missing its content.
+  #   This is only to make those links visible in the first place -
+  #   it should never appear on the website and is only intended for
+  #   debugging.
+
+  missingLinkContent: "(Missing link content)"
+
+  # nav:
+  #   Generic navigational elements. These usually only appear in the
+  #   wiki's nav bar, at the top of the page.
+
+  nav:
+    previous: "Previous"
+    next: "Next"
+    info: "Info"
+    gallery: "Gallery"
+
+  # pageTitle:
+  #   Title set under the page's <title> HTML element, which is
+  #   displayed in the browser tab bar, bookmarks list, etc.
+
+  pageTitle:
+    _: "{TITLE}"
+    withWikiName: "{TITLE} | {WIKI_NAME}"
+
+  # skippers:
+  #
+  #   These are navigational links that only show up when you're
+  #   navigating the wiki using the Tab key (or some other method of
+  #   "tabbing" between links and interactive elements). They move
+  #   the browser's nav focus to the selected element when pressed.
+  #
+  #   There are a lot of definitions here, and they're mostly shown
+  #   conditionally, based on the elements that are actually apparent
+  #   on the current page.
+  #
+
+  skippers:
+    skipTo: "Skip to:"
+
+    content: "Content"
+    header: "Header"
+    footer: "Footer"
+
+    sidebar:
+      _: "Sidebar"
+      left: "Sidebar (left)"
+      right: "Sidebar (right)"
+
+    # Displayed on artist info page.
+
+    tracks: "Tracks"
+    artworks: "Artworks"
+    flashes: "Flashes & Games"
+
+    # Displayed on track and flash info pages.
+
+    contributors: "Contributors"
+
+    # Displayed on track info page.
+
+    references: "References..."
+    referencedBy: "Referenced by..."
+    samples: "Samples..."
+    sampledBy: "Sampled by..."
+    features: "Features..."
+    featuredIn: "Featured in..."
+
+    lyrics: "Lyrics"
+
+    sheetMusicFiles: "Sheet music files"
+    midiProjectFiles: "MIDI/project files"
+
+    # Displayed on track and album info pages.
+
+    commentary: "Commentary"
+
+    artistCommentary: "Commentary"
+    additionalFiles: "Additional files"
+
+  # socialEmbed:
+  #   Social embeds describe how the page should be represented on
+  #   social platforms, chat messaging apps, and so on.
+
+  socialEmbed:
+    heading: "{WIKI_NAME} | {HEADING}"
+
+  # jumpTo:
+  #   Generic action displayed at the top of some longer pages, for
+  #   quickly scrolling down to a particular section.
+
+  jumpTo:
+    _: "Jump to:"
+    withLinks: "Jump to: {LINKS}."
+
+  # contentWarnings:
+  #   Displayed for some artworks, informing of possibly sensitive
+  #   content and giving the viewer a chance to consider before
+  #   clicking through.
+
+  contentWarnings:
+    _: "cw: {WARNINGS}"
+    reveal: "click to show"
+
+  # albumGrid:
+  #   Generic strings for various sorts of gallery grids, displayed
+  #   on the homepage, album galleries, artist artwork galleries, and
+  #   so on. These get the name of the thing being represented and,
+  #   often, a bit of text providing pertinent extra details about
+  #   that thing.
+
+  albumGrid:
+    noCoverArt: "{ALBUM}"
+
+    details:
+      _: "({TRACKS}, {TIME})"
+      coverArtists: "(Illust. {ARTISTS})"
+      otherCoverArtists: "(With {ARTISTS})"
+
+  albumGalleryGrid:
+    noCoverArt: "{NAME}"
+
+  # uiLanguage:
+  #   Displayed in the footer, for switching between languages.
+
+  uiLanguage: "UI Language: {LANGUAGES}"
+
+#
+# homepage:
+#   This is the main index and home for the whole wiki! There isn't
+#   much for strings here as the layout is very customizable and
+#   includes mostly wiki-provided content.
+#
+homepage:
+  title: "{TITLE}"
+
+  # news:
+  #   If the wiki has news entries enabled, then there's a box in the
+  #   homepage's sidebar (beneath custom sidebar content, if any)
+  #   which displays the bodies the latest few entries up to a split.
+
+  news:
+    title: "News"
+
+    entry:
+      viewRest: "(View rest of entry!)"
+
+#
+# albumSidebar:
+#   This sidebar is displayed on both the album and track info pages!
+#   It displays the groups that the album is from (each getting its
+#   own box on the album page, all conjoined in one box on the track
+#   page) and the list of tracks in the album, which can be sectioned
+#   similarly to normal track lists, but displays the range of tracks
+#   in each section rather than the section's duration.
+#
+albumSidebar:
+  trackList:
+    item: "{TRACK}"
+
+    # fallbackSectionName:
+    #   If an album's track list isn't sectioned, the track list here
+    #   will still have all the tracks grouped under a list that can
+    #   be toggled open and closed. This controls how that list gets
+    #   titled.
+
+    fallbackSectionName: "Track list"
+
+    # group:
+    #   "Group" is a misnomer - these are track sections. Some albums
+    #   don't use track numbers at all, and for these, the default
+    #   string will be used instead of group.withRange.
+
+    group:
+      _: "{GROUP}"
+      withRange: "{GROUP} ({RANGE})"
+
+  # groupBox:
+  #   This is the box for groups. Apart from the next and previous
+  #   links, it also gets "visit on" and the group's descripton
+  #   (up to a split).
+
+  groupBox:
+    title: "{GROUP}"
+    next: "Next: {ALBUM}"
+    previous: "Previous: {ALBUM}"
+
+#
+# albumPage:
+#
+#   Albums group together tracks and provide quick access to each of
+#   their pages, have release data (and sometimes credits) that are
+#   generally inherited by the album's tracks plus commentary and
+#   other goodies of their own, and are generally the main object on
+#   the wiki!
+#
+#   Most of the strings on the album info page are tracked under
+#   releaseInfo, so there isn't a lot here.
+#
+albumPage:
+  title: "{ALBUM}"
+
+  nav:
+    album: "{ALBUM}"
+    randomTrack: "Random Track"
+    gallery: "Gallery"
+    commentary: "Commentary"
+
+  socialEmbed:
+    heading: "{GROUP}"
+    title: "{ALBUM}"
+
+    # body:
+    #   These permutations are a bit awkward. "Tracks" is a counted
+    #   string, ex. "63 tracks".
+
+    body:
+      withDuration: "{DURATION}."
+      withTracks: "{TRACKS}."
+      withReleaseDate: Released {DATE}.
+      withDuration.withTracks: "{DURATION}, {TRACKS}."
+      withDuration.withReleaseDate: "{DURATION}. Released {DATE}."
+      withTracks.withReleaseDate: "{TRACKS}. Released {DATE}."
+      withDuration.withTracks.withReleaseDate: "{DURATION}, {TRACKS}. Released {DATE}."
+
+#
+# albumGalleryPage:
+#   Album galleries provide an alternative way to navigate the album,
+#   and put all its artwork - including for each track - into the
+#   spotlight. Apart from the main gallery grid (which usually lists
+#   each artwork's illustrators), this page also has a quick stats
+#   line about the album, and may display a message about all of the
+#   artworks if one applies.
+#
+albumGalleryPage:
+  title: "{ALBUM} - Gallery"
+
+  # statsLine:
+  #   Most albums have release dates, but not all. These strings
+  #   react accordingly.
+
+  statsLine: >-
+    {TRACKS} totaling {DURATION}.
+
+  statsLine.withDate: >-
+    {TRACKS} totaling {DURATION}. Released {DATE}.
+
+  # coverArtistsLine:
+  #   This is displayed if every track (which has artwork at all)
+  #   has the same illustration credits.
+
+  coverArtistsLine: >-
+    All track artwork by {ARTISTS}.
+
+  # noTrackArtworksLine:
+  #   This is displayed if none of the tracks on the album have any
+  #   artwork at all. Generally, this means the album gallery won't
+  #   be linked from the album's other pages, but it is possible to
+  #   end up on "stub galleries" using nav links on another gallery.
+
+  noTrackArtworksLine: >-
+    This album doesn't have any track artwork.
+
+#
+# albumCommentaryPage:
+#   The album commentary page is a more minimal layout that brings
+#   the commentary for the album, and each of its tracks, to the
+#   front. It's basically inspired by reading in a library, or by
+#   following along with an album's booklet or liner notes while
+#   playing it back on a treasured dinky CD player late at night.
+#
+albumCommentaryPage:
+  title: "{ALBUM} - Commentary"
+
+  nav:
+    album: "Album: {ALBUM}"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}.
+
+  entry:
+    title:
+      albumCommentary: "Album commentary"
+      trackCommentary: "{TRACK}"
+
+#
+# artistInfoPage:
+#   The artist info page is an artist's main home on the wiki, and
+#   automatically includes a full list of all the things they've
+#   contributed to and been credited on. It's split into a section
+#   for each of the kinds of things the artist is credited for,
+#   including tracks, artworks, flashes/games, and commentary.
+#
+artistPage:
+  title: "{ARTIST}"
+
+  nav:
+    artist: "Artist: {ARTIST}"
+
+  creditList:
+
+    # album:
+    #   Tracks are chunked by albums, as long as the tracks are all
+    #   of the same date (if applicable).
+
+    album:
+      _: "{ALBUM}"
+      withDate: "{ALBUM} ({DATE})"
+      withDuration: "{ALBUM} ({DURATION})"
+      withDate.withDuration: "{ALBUM} ({DATE}; {DURATION})"
+
+    # flashAct:
+    #   Flashes are chunked by flash act, though a single flash act
+    #   might be split into multiple chunks if it spans a long range
+    #   and the artist contributed to a flash from some other act
+    #   between. A date range will be shown if an act has at least
+    #   two differently dated flashes.
+
+    flashAct:
+      _: "{ACT}"
+      withDate: "{ACT} ({DATE})"
+      withDateRange: "{ACT} ({DATE_RANGE})"
+
+    # entry:
+    #   This section covers strings for all kinds of individual
+    #   things which an artist has contributed to, and refers to the
+    #   items in each of the chunks described above.
+
+    entry:
+
+      # withContribution:
+      #   The specific contribution that an artist made to a given
+      #   thing may be described with a word or two, and that's shown
+      #   in the list.
+
+      withContribution: "{ENTRY} ({CONTRIBUTION})"
+
+      # withArtists:
+      #   This lists co-artists or co-contributors, depending on how
+      #   the artist themselves was credited.
+
+      withArtists: "{ENTRY} (with {ARTISTS})"
+
+      withArtists.withContribution: "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})"
+
+      # rerelease:
+      #   Tracks which aren't the original release don't display co-
+      #   artists or contributors, and get dimmed a little compared
+      #   to original release track entries.
+
+      rerelease: "{ENTRY} (re-release)"
+
+      # track:
+      #   The string without duration is used in both the artist's
+      #   track credits list as well as their commentary list.
+
+      track:
+        _: "{TRACK}"
+        withDuration: "({DURATION}) {TRACK}"
+
+      # album:
+      #   The artist info page doesn't display if the artist is
+      #   musically credited outright for the album as a whole,
+      #   opting to show each of the tracks from that album instead.
+      #   But other parts belonging specifically to the album have
+      #   credits too, and those entreis get the strings below.
+
+      album:
+        coverArt: "(cover art)"
+        wallpaperArt: "(wallpaper art)"
+        bannerArt: "(banner art)"
+        commentary: "(album commentary)"
+
+      flash:
+        _: "{FLASH}"
+
+  # contributedDurationLine:
+  #   This is shown at the top of the artist's track list, provided
+  #   any of their tracks have durations at all.
+
+  contributedDurationLine: >-
+    {ARTIST} has contributed {DURATION} of music shared on this wiki.
+
+  # groupContributions:
+  #   This is a special "chunk" shown at the top of an artist's
+  #   track and artwork lists. It lists which groups an artist has
+  #   contributed the most (and least) to, and is interactive -
+  #   it can be sorted by count or, for tracks, by duration.
+
+  groupContributions:
+    title:
+      music: "Contributed music to groups:"
+      artworks: "Contributed artworks to groups:"
+      withSortButton: "{TITLE} ({SORT})"
+
+      sorting:
+        count: "Sorting by count."
+        duration: "Sorting by duration."
+
+    item:
+      countAccent: "({COUNT})"
+      durationAccent: "({DURATION})"
+      countDurationAccent: "({COUNT} — {DURATION})"
+      durationCountAccent: "({DURATION} — {COUNT})"
+
+  trackList:
+    title: "Tracks"
+
+  artList:
+    title: "Artworks"
+
+  flashList:
+    title: "Flashes & Games"
+
+  commentaryList:
+    title: "Commentary"
+
+  # viewArtGallery:
+  #   This is shown twice on the page - once at almost the very top
+  #   of the page, just beneath visiting links, and once above the
+  #   list of credited artworks, where it gets the longer
+  #   orBrowseList form.
+
+  viewArtGallery:
+    _: "View {LINK}!"
+    orBrowseList: "View {LINK}! Or browse the list:"
+    link: "art gallery"
+
+#
+# artistGalleryPage:
+#   The artist gallery page shows a neat grid of all of the album and
+#   track artworks an artist has contributed to! Co-illustrators are
+#   also displayed when applicable.
+#
+artistGalleryPage:
+  title: "{ARTIST} - Gallery"
+
+  infoLine: >-
+    Contributed to {COVER_ARTS}.
+
+#
+# commentaryIndex:
+#   The commentary index page shows a summary of all the commentary
+#   across the entire wiki, with a list linking to each album's
+#   dedicated commentary page.
+#
+commentaryIndex:
+  title: "Commentary"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}, in all.
+
+  albumList:
+    title: "Choose an album:"
+    item: "{ALBUM} ({WORDS} across {ENTRIES})"
+
+#
+# flashIndex:
+#   The flash index page shows a very long grid including every flash
+#   on the wiki, sectioned with big headings for each act. It's also
+#   got jump links at the top to skip to a specific overarching
+#   section ("side") of flash acts.
+#
+flashIndex:
+  title: "Flashes & Games"
+
+#
+# flashSidebar:
+#   The flash sidebar is used on both the flash info and flash act
+#   gallery pages, and has two boxes - one showing all the flashes in
+#   the current flash act, and one showing all the flash acts on the
+#   wiki, sectioned by "side".
+#
+flashSidebar:
+  flashList:
+
+    # These two strings are the default ones used when a flash act
+    # doesn't specify a custom phrasing.
+    flashesInThisAct: "Flashes in this act"
+    entriesInThisSection: "Entries in this section"
+
+#
+# flashPage:
+#   The flash info page shows release information, links to check the
+#   flash out, and lists of contributors and featured tracks. Most of
+#   those strings are under releaseInfo, so there aren't a lot of
+#   strings here.
+#
+flashPage:
+  title: "{FLASH}"
+
+  nav:
+    flash: "{FLASH}"
+
+#
+# groupSidebar:
+#   The group sidebar is used on both the group info and group
+#   gallery pages, and is formed of just one box, showing all the
+#   groups on the wiki, sectioned by "category".
+#
+groupSidebar:
+  title: "Groups"
+
+  groupList:
+    category: "{CATEGORY}"
+    item: "{GROUP}"
+
+#
+# groupPage:
+#   This section represents strings common to multiple group pages.
+#
+groupPage:
+  nav:
+    group: "Group: {GROUP}"
+
+#
+# groupInfoPage:
+#   The group info page shows visiting links, the group's full
+#   description, and a list of albums from the group.
+#
+groupInfoPage:
+  title: "{GROUP}"
+
+  viewAlbumGallery:
+    _: "View {LINK}! Or browse the list:"
+    link: "album gallery"
+
+  # albumList:
+  #   Many albums are present under multiple groups, and these get an
+  #   accent indicating what other group is highest on the album's
+  #   list of groups.
+
+  albumList:
+    title: "Albums"
+
+    item:
+      _: "({YEAR}) {ALBUM}"
+      withoutYear: "{ALBUM}"
+      withAccent: "{ITEM} {ACCENT}"
+      otherGroupAccent: "(from {GROUP})"
+
+#
+# groupGalleryPage:
+#   The group gallery page shows a grid of all the albums from that
+#   group, each including the number of tracks and duration, as well
+#   as a stats line for the group as a whole, and a neat carousel, if
+#   pre-configured!
+#
+groupGalleryPage:
+  title: "{GROUP} - Gallery"
+
+  infoLine: >-
+    {TRACKS} across {ALBUMS}, totaling {TIME}.
+
+#
+# listingIndex:
+#   The listing index page shows all available listings on the wiki,
+#   and a very exciting stats line for the wiki as a whole.
+#
+listingIndex:
+  title: "Listings"
+
+  infoLine: >-
+    {WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.
+
+  exploreList: >-
+    Feel free to explore any of the listings linked below and in the sidebar!
+
+#
+# listingPage:
+#
+#   There are a lot of listings! Each is automatically generated and
+#   sorts or organizes the data on the wiki in some way that provides
+#   useful or interesting information. Most listings work primarily
+#   with one kind of data and are sectioned accordingly, for example
+#   "listAlbums.byDuration" or "listTracks.byDate".
+#
+#   There are also some miscellaneous strings here, most of which are
+#   common to a variety of listings, and are often navigational in
+#   nature.
+#
+listingPage:
+
+  # target:
+  #   Just the names for each of the sections - each chunk on the
+  #   listing index (and in the sidebar) gets is titled with one of
+  #   these.
+
+  target:
+    album: "Albums"
+    artist: "Artists"
+    group: "Groups"
+    track: "Tracks"
+    tag: "Tags"
+    other: "Other"
+
+  # misc:
+  #   Common, generic terminology across multiple listings.
+
+  misc:
+    trackContributors: "Track Contributors"
+    artContributors: "Art Contributors"
+    flashContributors: "Flash & Game Contributors"
+    artAndFlashContributors: "Art & Flash Contributors"
+
+  # listingFor:
+  #   Displays quick links to navigate to other listings for the
+  #   current target.
+
+  listingsFor: "Listings for {TARGET}: {LISTINGS}"
+
+  # seeAlso:
+  #   Displays directly related listings, which might be from other
+  #   targets besides the current one.
+
+  seeAlso: "Also check out: {LISTINGS}"
+
+  # skipToSection:
+  #   Some listings which use a chunked-list layout also show links
+  #   to scroll down to each of these sections - this is the title
+  #   for the list of those links.
+
+  skipToSection: "Skip to a section:"
+
+  listAlbums:
+
+    # listAlbums.byName:
+    #   Lists albums alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of tracks for
+    #   each album.
+
+    byName:
+      title: "Albums - by Name"
+      title.short: "...by Name"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byTracks:
+    #   Lists albums by number of tracks, most to least, or by name
+    #   alphabetically, if two albums have the same track count.
+    #   Albums without any tracks are totally excluded.
+
+    byTracks:
+      title: "Albums - by Tracks"
+      title.short: "...by Tracks"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byDuration:
+    #   Lists albums by total duration of all tracks, longest to
+    #   shortest, falling back to an alphabetical sort if two albums
+    #   are the same duration. Albums with zero duration are totally
+    #   excluded.
+
+    byDuration:
+      title: "Albums - by Duration"
+      title.short: "...by Duration"
+      item: "{ALBUM} ({DURATION})"
+
+    # listAlbums.byDate:
+    #   Lists albums by release date, oldest to newest, falling back
+    #   to an alphabetical sort if two albums were released on the
+    #   same date. Dateless albums are totally excluded.
+
+    byDate:
+      title: "Albums - by Date"
+      title.short: "...by Date"
+      item: "{ALBUM} ({DATE})"
+
+    # listAlbums.byDateAdded:
+    #   Lists albums by the date they were added to the wiki, oldest
+    #   to newest, and chunks these by date, since albums are usually
+    #   added in bunches at a time. The albums in each chunk are
+    #   sorted alphabetically, and albums which are missing the
+    #   "Date Added" field are totally excluded.
+
+    byDateAdded:
+      title: "Albums - by Date Added to Wiki"
+      title.short: "...by Date Added to Wiki"
+      chunk:
+        title: "{DATE}"
+        item: "{ALBUM}"
+
+  listArtists:
+
+    # listArtists.byName:
+    #   Lists artists alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of contributions
+    #   from each artist.
+
+    byName:
+      title: "Artists - by Name"
+      title.short: "...by Name"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byContribs:
+    #   Lists artists by number of contributions, most to least,
+    #   with separate lists for contributions to tracks, artworks,
+    #   and flashes. Falls back alphabetically if two artists have
+    #   the same number of contributions. Artists who aren't credited
+    #   for any contributions to each of these categories are
+    #   excluded from the respective list.
+
+    byContribs:
+      title: "Artists - by Contributions"
+      title.short: "...by Contributions"
+      chunk:
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+        title:
+          trackContributors: "Contributed tracks:"
+          artContributors: "Contributed artworks:"
+          flashContributors: "Contributed to flashes & games:"
+
+    # listArtists.byCommentary:
+    #   Lists artists by number of commentary entries, most to least,
+    #   falling back to an alphabetical sort if two artists have the
+    #   same count. Artists who don't have any commentary entries are
+    #   totally excluded.
+
+    byCommentary:
+      title: "Artists - by Commentary Entries"
+      title.short: "...by Commentary Entries"
+      item: "{ARTIST} ({ENTRIES})"
+
+    # listArtists.byDuration:
+    #   Lists artists by total duration of the tracks which they're
+    #   credited on (as either artist or contributor), longest sum to
+    #   shortest, falling back alphabetically if two artists have
+    #   the same duration. Artists who haven't contributed any music,
+    #   or whose tracks all lack durations, are totally excluded.
+
+    byDuration:
+      title: "Artists - by Duration"
+      title.short: "...by Duration"
+      item: "{ARTIST} ({DURATION})"
+
+    # listArtists.byGroup:
+    #   Lists artists who have contributed to each of the main groups
+    #   of a wiki (its "Divide Track Lists By Groups" field), sorted
+    #   alphabetically. Artists who aren't credited for contributions
+    #   under each of the groups are exlcuded from the respective
+    #   list.
+
+    byGroup:
+      title: "Artists - by Group"
+      title.short: "...by Group"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        title: "Contributed to {GROUP}:"
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byLatest:
+    #   Lists artists by the date of their latest contribution
+    #   overall, and chunks artists together by the album or flash
+    #   which that contribution belongs to. Within albums, each
+    #   artist is accented with the kind of contribution they made -
+    #   tracks, artworks, or both - and sorted so those of the same
+    #   sort of contribution are bunched together, then by name.
+    #   Artists who aren't credited for any dated contributions are
+    #   included at the bottom under a separate chunk.
+
+    byLatest:
+      title: "Artists - by Latest Contribution"
+      title.short: "...by Latest Contribution"
+      chunk:
+        title:
+          album: "{ALBUM} ({DATE})"
+          flash: "{FLASH} ({DATE})"
+          dateless: "These artists' contributions aren't dated:"
+        item:
+          _: "{ARTIST}"
+          tracks: "{ARTIST} (tracks)"
+          tracksAndArt: "{ARTIST} (tracks, art)"
+          art: "{ARTIST} (art)"
+
+  listGroups:
+
+    # listGroups.byName:
+    #   Lists groups alphabetically without sorting or chunking by
+    #   any other criteria. Also displays a link to each group's
+    #   gallery page.
+
+    byName:
+      title: "Groups - by Name"
+      title.short: "...by Name"
+      item: "{GROUP} ({GALLERY})"
+      item.gallery: "Gallery"
+
+    # listGroups.byCategory:
+    #   Lists groups directly reflecting the way they're sorted in
+    #   the wiki's groups.yaml data file, with no automatic sorting,
+    #   chunked (as sectioned in groups.yaml) by category. Also shows
+    #   a link to each group's gallery page.
+
+    byCategory:
+      title: "Groups - by Category"
+      title.short: "...by Category"
+
+      chunk:
+        title: "{CATEGORY}"
+        item: "{GROUP} ({GALLERY})"
+        item.gallery: "Gallery"
+
+    # listGroups.byAlbums:
+    #   Lists groups by number of belonging albums, most to least,
+    #   falling back alphabetically if two groups have the same
+    #   number of albums. Groups without any albums are totally
+    #   excluded.
+
+    byAlbums:
+      title: "Groups - by Albums"
+      title.short: "...by Albums"
+      item: "{GROUP} ({ALBUMS})"
+
+    # listGroups.byTracks:
+    #   Lists groups by number of tracks under each group's albums,
+    #   most to least, falling back to an alphabetical sort if two
+    #   groups have the same track counts. Groups without any tracks
+    #   are totally excluded.
+
+    byTracks:
+      title: "Groups - by Tracks"
+      title.short: "...by Tracks"
+      item: "{GROUP} ({TRACKS})"
+
+    # listGroups.byDuration:
+    #   Lists groups by sum of durations of all the tracks under each
+    #   of the group's albums, longest to shortest, falling back to
+    #   an alphabetical sort if two groups have the same duration.
+    #   Groups whose total duration is zero are totally excluded.
+
+    byDuration:
+      title: "Groups - by Duration"
+      title.short: "...by Duration"
+      item: "{GROUP} ({DURATION})"
+
+    # listGroups.byLatest:
+    #   List groups by release date of each group's most recent
+    #   album, most recent to longest ago, falling back to sorting
+    #   alphabetically if two groups' latest albums were released
+    #   on the same date. Groups which don't have any albums, or
+    #   whose albums are all dateless, are totally excluded.
+
+    byLatest:
+      title: "Groups - by Latest Album"
+      title.short: "...by Latest Album"
+      item: "{GROUP} ({DATE})"
+
+  listTracks:
+
+    # listTracks.byName:
+    #   List tracks alphabetically without sorting or chunking by
+    #   any other criteria.
+
+    byName:
+      title: "Tracks - by Name"
+      title.short: "...by Name"
+      item: "{TRACK}"
+
+    # listTracks.byAlbum:
+    #   List tracks chunked by the album they're from, retaining the
+    #   position each track occupies in its album, and sorting albums
+    #   from oldest to newest (or alphabetically, if two albums were
+    #   released on the same date). Dateless albums are included at
+    #   the bottom of the list. Custom "Date First Released" fields
+    #   on individual tracks are totally ignored.
+
+    byAlbum:
+      title: "Tracks - by Album"
+      title.short: "...by Album"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK}"
+
+    # listTracks.byDate:
+    #   List tracks according to their own release dates, which may
+    #   differ from that of the album via the "Date First Released"
+    #   field, oldest to newest, and chunked by album when multiple
+    #   tracks from one album were released on the same date. Track
+    #   order within a given album is preserved where possible.
+    #   Dateless albums are excluded, except for contained tracks
+    #   which have custom "Date First Released" fields.
+
+    byDate:
+      title: "Tracks - by Date"
+      title.short: "...by Date"
+
+      chunk:
+        title: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+        item.rerelease: "{TRACK} (re-release)"
+
+    # listTracks.byDuration:
+    #   List tracks by duration, longest to shortest, falling back to
+    #   an alphabetical sort if two tracks have the same duration.
+    #   Tracks which don't have any duration are totally excluded.
+
+    byDuration:
+      title: "Tracks - by Duration"
+      title.short: "...by Duration"
+      item: "{TRACK} ({DURATION})"
+
+    # listTracks.byDurationInAlbum:
+    #   List tracks chunked by the album they're from, then sorted
+    #   by duration, longest to shortest; albums are sorted by date,
+    #   oldest to newest, and both sorts fall back alphabetically.
+    #   Dateless albums are included at the bottom of the list.
+
+    byDurationInAlbum:
+      title: "Tracks - by Duration (in Album)"
+      title.short: "...by Duration (in Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} ({DURATION})"
+
+    # listTracks.byTimesReferenced:
+    #   List tracks by how many other tracks' reference lists each
+    #   appears in, most times referenced to fewest, falling back
+    #   alphabetically if two tracks have been referenced the same
+    #   number of times. Tracks that aren't referenced by any other
+    #   tracks are totally excluded from the list.
+
+    byTimesReferenced:
+      title: "Tracks - by Times Referenced"
+      title.short: "...by Times Referenced"
+      item: "{TRACK} ({TIMES_REFERENCED})"
+
+    # listTracks.inFlashes.byAlbum:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   and display the list of flashes that eack track is featured
+    #   in. Tracks which aren't featured in any flashes are totally
+    #   excluded from the list.
+
+    inFlashes.byAlbum:
+      title: "Tracks - in Flashes & Games (by Album)"
+      title.short: "...in Flashes & Games (by Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} (in {FLASHES})"
+
+    # listTracks.inFlashes.byFlash:
+    #   List tracks, chunked by flash (which are sorted by date,
+    #   retaining their positions in a common act where applicable,
+    #   or else by the two acts' names) and sorted according to the
+    #   featured list of the flash, and display a link to the album
+    #   each track is contained in. Tracks which aren't featured in
+    #   any flashes are totally excluded from the list.
+
+    inFlashes.byFlash:
+      title: "Tracks - in Flashes & Games (by Flash)"
+      title.short: "...in Flashes & Games (by Flash)"
+
+      chunk:
+        title: "{FLASH}"
+        item: "{TRACK} (from {ALBUM})"
+
+    # listTracks.withLyrics:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have lyrics. The chunk titles
+    #   also display the date each album was released, and tracks'
+    #   own custom "Date First Released" fields are totally ignored.
+
+    withLyrics:
+      title: "Tracks - with Lyrics"
+      title.short: "...with Lyrics"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withSheetMusicFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have sheet music files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withSheetMusicFiles:
+      title: "Tracks - with Sheet Music Files"
+      title.short: "...with Sheet Music Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withMidiProjectFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have MIDI & project files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withMidiProjectFiles:
+      title: "Tracks - with MIDI & Project Files"
+      title.short: "...with MIDI & Project Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+  listTags:
+
+    # listTags.byName:
+    #   List art tags alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of times each
+    #   art tag has been featured.
+
+    byName:
+      title: "Tags - by Name"
+      title.short: "...by Name"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listTags.byUses:
+    #   List art tags by number of times used, falling back to an
+    #   alphabetical sort if two art tags have been featured the same
+    #   number of times. Art tags which haven't haven't been featured
+    #   at all yet are totally excluded from the list.
+
+    byUses:
+      title: "Tags - by Uses"
+      title.short: "...by Uses"
+      item: "{TAG} ({TIMES_USED})"
+
+  other:
+
+    # other.allSheetMusic:
+    #   List all sheet music files, sectioned by album (which are
+    #   sorted by date, falling back alphabetically) and then by
+    #   track (which retain album ordering). If one "file" entry
+    #   contains multiple files, then it's displayed as an expandable
+    #   list, collapsed by default, accented with the number of
+    #   downloadable files.
+
+    allSheetMusic:
+      title: "All Sheet Music"
+      title.short: "All Sheet Music"
+      albumFiles: "Album sheet music:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.midiProjectFiles:
+    #   Same as other.allSheetMusic, but for MIDI & project files.
+
+    allMidiProjectFiles:
+      title: "All MIDI/Project Files"
+      title.short: "All MIDI/Project Files"
+      albumFiles: "Album MIDI/project files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.additionalFiles:
+    #   Same as other.allSheetMusic, but for additional files.
+
+    allAdditionalFiles:
+      title: "All Additional Files"
+      title.short: "All Additional Files"
+      albumFiles: "Album additional files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.randomPages:
+    #   Special listing which shows a bunch of buttons that each
+    #   link to a random page on the wiki under a particular scope.
+
+    randomPages:
+      title: "Random Pages"
+      title.short: "Random Pages"
+
+      # chooseLinkLine:
+      #   Introductory line explaining the links on this listing.
+
+      chooseLinkLine:
+        _: "{FROM_PART} {BROWSER_SUPPORT_PART}"
+
+        fromPart:
+          dividedByGroups: >-
+            Choose a link to go to a random page in that group or album!
+          notDividedByGroups: >-
+            Choose a link to go to a random page in that album!
+
+        browserSupportPart: >-
+          If your browser doesn't support relatively modern JavaScript
+          or you've disabled it, these links won't work - sorry.
+
+      # dataLoadingLine, dataLoadedLine:
+      #   Since the links on this page depend on access to a fairly
+      #   large data file that is downloaded separately and in the
+      #   background, these messages indicate the status of that
+      #   download and whether or not the links will work yet.
+
+      dataLoadingLine: >-
+        (Data files are downloading in the background! Please wait for data to load.)
+
+      dataLoadedLine: >-
+        (Data files have finished being downloaded. The links should work!)
+
+      chunk:
+
+        title:
+          misc: "Miscellaneous:"
+
+          # fromAlbum:
+          #   If the wiki hasn't got "Divide Track Lists By Groups"
+          #   set, all albums across the wiki are grouped in one
+          #   long chunk.
+
+          fromAlbum: "From an album:"
+
+          # fromGroup:
+          #   If the wiki does have "Divide Track Lists By Groups"
+          #   set, there's one chunk past Miscellaneous for each of
+          #   those groups, listing all the albums from that group,
+          #   each of which links to a random track from that album.
+
+          fromGroup:
+            _: "From {GROUP}:"
+
+            accent:
+              _: "({RANDOM_ALBUM}, {RANDOM_TRACK})"
+              randomAlbum: "Random Album"
+              randomTrack: "Random Track"
+
+        item:
+          album: "{ALBUM}"
+
+          randomArtist:
+            _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})"
+            mainLink: "Random Artist"
+            atLeastTwoContributions: "at least 2 contributions"
+
+          randomAlbumWholeSite: "Random Album (whole site)"
+          randomTrackWholeSite: "Random Track (whole site)"
+
+#
+# newsIndex:
+#   The news index page shows a list of every news entry on the wiki!
+#   (If it's got news entries enabled.) Each entry gets a stylized
+#   heading with its name of and date, sorted newest to oldest, as
+#   well as its body (up to a split) and a link to view the rest of
+#   the entry on its dedicated news entry page.
+#
+newsIndex:
+  title: "News"
+
+  entry:
+    viewRest: "(View rest of entry!)"
+
+#
+# newsEntryPage:
+#   The news entry page displays all the content of a news entry,
+#   as well as its date published, in one big list, and has nav links
+#   to go to the previous and next news entry.
+#
+newsEntryPage:
+  title: "{ENTRY}"
+  published: "(Published {DATE}.)"
+
+#
+# redirectPage:
+#   Static "placeholder" pages when redirecting a visitor from one
+#   page to another - this generally happens automatically, before
+#   you have a chance to read the page, so content is concise.
+#
+redirectPage:
+  title: "Moved to {TITLE}"
+
+  infoLine: >-
+    This page has been moved to {TARGET}.
+
+#
+# tagPage:
+#   The tag gallery page displays all the artworks that a tag has
+#   been featured in, in one neat grid, with each artwork displaying
+#   its illustrators, as well as a short info line that indicates
+#   how many artworks the tag's part of.
+#
+tagPage:
+  title: "{TAG}"
+
+  nav:
+    tag: "Tag: {TAG}"
+
+  infoLine: >-
+    Appears in {COVER_ARTS}.
+
+#
+# trackPage:
+#
+#   The track info page is pretty much the most discrete and common
+#   chunk of information across the whole site, displaying info about
+#   the track like its release date, artists, cover illustrators,
+#   commentary, and more, as well as relational info, like the tracks
+#   it references and tracks which reference it, and flashes which
+#   it's been featured in. Tracks can also have extra related files,
+#   like sheet music and MIDI/project files.
+#
+#   Most of the details about tracks use strings that are defined
+#   under releaseInfo, so this section is a little sparse.
+#
+trackPage:
+  title: "{TRACK}"
+
+  nav:
+    random: "Random"
+
+    track:
+      _: "{TRACK}"
+      withNumber: "{NUMBER}. {TRACK}"
+
+  socialEmbed:
+    heading: "{ALBUM}"
+    title: "{TRACK}"
+
+    body:
+      withArtists.withCoverArtists: "By {ARTISTS}; art by {COVER_ARTISTS}."
+      withArtists: "By {ARTISTS}."
+      withCoverArtists: "Art by {COVER_ARTISTS}."
diff --git a/src/upd8.js b/src/upd8.js
index 3d7da800..ebb278b2 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -40,7 +40,8 @@ import wrap from 'word-wrap';
 
 import CacheableObject from '#cacheable-object';
 import {displayCompositeCacheAnalysis} from '#composite';
-import {processLanguageFile} from '#language';
+import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
+  from '#language';
 import {isMain, traverse} from '#node-utils';
 import bootRepl from '#repl';
 import {empty, showAggregate, withEntries} from '#sugar';
@@ -56,7 +57,6 @@ import {
   logError,
   parseOptions,
   progressCallAll,
-  progressPromiseAll,
 } from '#cli';
 
 import genThumbs, {
@@ -94,8 +94,6 @@ try {
 
 const BUILD_TIME = new Date();
 
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
-
 const STATUS_NOT_STARTED       = `not started`;
 const STATUS_NOT_APPLICABLE    = `not applicable`;
 const STATUS_STARTED_NOT_DONE  = `started but not yet done`;
@@ -291,6 +289,18 @@ async function main() {
       type: 'flag',
     },
 
+    'no-input': {
+      help: `Don't wait on input from stdin - assume the device is headless`,
+      type: 'flag',
+    },
+
+    'no-language-reloading': {
+      help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`,
+      type: 'flag',
+    },
+
+    'no-language-reload': {alias: 'no-language-reloading'},
+
     // Want sweet, sweet trace8ack info in aggreg8te error messages? This
     // will print all the juicy details (or at least the first relevant
     // line) right to your output, 8ut also pro8a8ly give you a headache
@@ -457,6 +467,8 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const skipReferenceValidation = cliOptions['skip-reference-validation'] ?? false;
   const noBuild = cliOptions['no-build'] ?? false;
+  const noInput = cliOptions['no-input'] ?? false;
+  let noLanguageReloading = cliOptions['no-language-reloading'] ?? null; // Will get default later.
 
   showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
@@ -567,12 +579,24 @@ async function main() {
   }
 
   if (noBuild) {
+    logInfo`Won't generate any site or page files this run (--no-build passed).`;
+
     Object.assign(stepStatusSummary.performBuild, {
       status: STATUS_NOT_APPLICABLE,
       annotation: `--no-build provided`,
     });
+  } else if (usingDefaultBuildMode) {
+    logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`;
+  } else {
+    logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
   }
 
+  noLanguageReloading ??=
+    ({
+      'static-build': true,
+      'live-dev-server': false,
+    })[selectedBuildModeFlag];
+
   if (skipThumbs && thumbsOnly) {
     logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
     return false;
@@ -766,14 +790,6 @@ async function main() {
     thumbsCache = result.cache;
   }
 
-  if (noBuild) {
-    logInfo`Not generating any site or page files this run (--no-build passed).`;
-  } else if (usingDefaultBuildMode) {
-    logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`;
-  } else {
-    logInfo`Using specified build mode: ${selectedBuildModeFlag}`;
-  }
-
   if (showInvalidPropertyAccesses) {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
@@ -1085,18 +1101,52 @@ async function main() {
   });
 
   let internalDefaultLanguage;
+  let internalDefaultLanguageWatcher;
 
-  try {
-    internalDefaultLanguage =
-      await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
+  let errorLoadingInternalDefaultLanguage = false;
 
-    Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
-      status: STATUS_DONE_CLEAN,
-      timeEnd: Date.now(),
-    });
-  } catch (error) {
-    console.error(error);
+  if (noLanguageReloading) {
+    internalDefaultLanguageWatcher = null;
 
+    try {
+      internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile);
+    } catch (error) {
+      niceShowAggregate(error);
+      errorLoadingInternalDefaultLanguage = true;
+    }
+  } else {
+    internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile);
+
+    try {
+      await new Promise((resolve, reject) => {
+        const watcher = internalDefaultLanguageWatcher;
+
+        const onReady = () => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          resolve();
+        };
+
+        const onError = error => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          watcher.close();
+          reject(error);
+        };
+
+        watcher.on('ready', onReady);
+        watcher.on('error', onError);
+      });
+
+      internalDefaultLanguage = internalDefaultLanguageWatcher.language;
+    } catch (_error) {
+      // No need to display the error here - it's already printed by
+      // watchLanguageFile.
+      errorLoadingInternalDefaultLanguage = true;
+    }
+  }
+
+  if (errorLoadingInternalDefaultLanguage) {
     logError`There was an error reading the internal language file.`;
     fileIssue();
 
@@ -1109,6 +1159,17 @@ async function main() {
     return false;
   }
 
+  if (!noLanguageReloading) {
+    // Bypass node.js special-case handling for uncaught error events
+    internalDefaultLanguageWatcher.on('error', () => {});
+  }
+
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
+
+  let customLanguageWatchers;
   let languages;
 
   if (langPath) {
@@ -1118,20 +1179,103 @@ async function main() {
     });
 
     const languageDataFiles = await traverse(langPath, {
-      filterFile: name => path.extname(name) === '.json',
+      filterFile: name =>
+        path.extname(name) === '.json' ||
+        path.extname(name) === '.yaml',
       pathStyle: 'device',
     });
 
-    let results;
+    let errorLoadingCustomLanguages = false;
 
-    // TODO: Aggregate errors (with Promise.allSettled).
-    try {
-      results =
-        await progressPromiseAll(`Reading & processing language files.`,
-          languageDataFiles.map((file) => processLanguageFile(file)));
-    } catch (error) {
-      console.error(error);
+    if (noLanguageReloading) {
+      languages = {};
+
+      const results =
+        await Promise.allSettled(
+          languageDataFiles
+            .map(file => processLanguageFile(file)));
+
+      for (const {status, value: language, reason: error} of results) {
+        if (status === 'rejected') {
+          errorLoadingCustomLanguages = true;
+          niceShowAggregate(error);
+        } else {
+          languages[language.code] = language;
+        }
+      }
+    } else watchCustomLanguages: {
+      customLanguageWatchers =
+        languageDataFiles.map(file => {
+          const watcher = watchLanguageFile(file);
+
+          // Bypass node.js special-case handling for uncaught error events
+          watcher.on('error', () => {});
+
+          return watcher;
+        });
 
+      const waitingOnWatchers = new Set(customLanguageWatchers);
+
+      const initialResults =
+        await Promise.allSettled(
+          customLanguageWatchers
+            .map(watcher => new Promise((resolve, reject) => {
+              const onReady = () => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                waitingOnWatchers.delete(watcher);
+                resolve();
+              };
+
+              const onError = error => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                reject(error);
+              };
+
+              watcher.on('ready', onReady);
+              watcher.on('error', onError);
+            })));
+
+      if (initialResults.some(({status}) => status === 'rejected')) {
+        logWarn`There were errors loading custom languages from the language path`;
+        logWarn`provided: ${langPath}`;
+
+        if (noInput) {
+          internalDefaultLanguageWatcher.close();
+
+          for (const watcher of Object.values(customLanguageWatchers)) {
+            watcher.close();
+          }
+
+          errorLoadingCustomLanguages = true;
+          break watchCustomLanguages;
+        }
+
+        logWarn`The build should start automatically if you investigate these.`;
+        logWarn`Or, exit by pressing ^C here (control+C) and run again without`;
+        logWarn`providing ${'--lang-path'} (or ${'HSMUSIC_LANG'}) to build without custom`;
+        logWarn`languages.`;
+
+        await new Promise(resolve => {
+          for (const watcher of waitingOnWatchers) {
+            watcher.once('ready', () => {
+              waitingOnWatchers.remove(watcher);
+              if (empty(waitingOnWatchers)) {
+                resolve();
+              }
+            });
+          }
+        });
+      }
+
+      languages =
+        Object.fromEntries(
+          customLanguageWatchers
+            .map(({language}) => [language.code, language]));
+    }
+
+    if (errorLoadingCustomLanguages) {
       logError`Failed to load language files. Please investigate these, or don't provide`;
       logError`--lang-path (or HSMUSIC_LANG) and build again.`;
 
@@ -1144,13 +1288,15 @@ async function main() {
       return false;
     }
 
-    languages =
-      Object.fromEntries(
-        results.map((language) => [language.code, language]));
-
     Object.assign(stepStatusSummary.loadLanguageFiles, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+        annotation:
+        (noLanguageReloading
+          ? (selectedBuildModeFlag === 'static-build'
+              ? `loaded statically, default for --static-build`
+              : `loaded statically, --no-language-reloading provided`)
+          : `watching for changes`),
     });
   } else {
     languages = {};
@@ -1161,57 +1307,107 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  const customDefaultLanguage =
-    languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
   let finalDefaultLanguage;
+  let finalDefaultLanguageWatcher;
+  let finalDefaultLanguageAnnotation;
+
+  if (wikiData.wikiInfo.defaultLanguage) {
+    const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage];
+
+    if (!customDefaultLanguage) {
+      logError`Wiki info file specified default language is ${wikiData.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-path'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+      }
+
+      Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki specifies default language whose file is not available`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
 
-  if (customDefaultLanguage) {
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
-    customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
+
     finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_DONE_CLEAN,
-      annotation: `using wiki-specified custom default language`,
-      timeEnd: Date.now(),
-    });
-  } else if (wikiData.wikiInfo.defaultLanguage) {
-    logError`Wiki info file specified default language is ${wikiData.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-path'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
     }
+  } else if (languages[internalDefaultLanguage.code]) {
+    const customDefaultLanguage = languages[internalDefaultLanguage.code];
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `wiki specifies default language whose file is not available`,
-      timeEnd: Date.now(),
-    });
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using inferred custom default language`;
 
-    return false;
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
+    }
   } else {
     languages[internalDefaultLanguage.code] = internalDefaultLanguage;
+
     finalDefaultLanguage = internalDefaultLanguage;
-    stepStatusSummary.initializeDefaultLanguage.status = STATUS_DONE_CLEAN;
+    finalDefaultLanguageAnnotation = `no custom default language specified`;
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_DONE_CLEAN,
-      annotation: `no custom default language specified`,
-      timeEnd: Date.now(),
-    });
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher = internalDefaultLanguageWatcher;
+    }
+  }
+
+  const inheritStringsFromInternalLanguage = () => {
+    // The custom default language, if set, will be the new one providing fallback
+    // strings for other languages. But on its own, it still might not be a complete
+    // list of strings - so it falls back to the internal default language, which
+    // won't otherwise be presented in the build.
+    if (finalDefaultLanguage === internalDefaultLanguage) return;
+    const {strings: inheritedStrings} = internalDefaultLanguage;
+    Object.assign(finalDefaultLanguage, {inheritedStrings});
+  };
+
+  const inheritStringsFromDefaultLanguage = () => {
+    const {strings: inheritedStrings} = finalDefaultLanguage;
+    for (const language of Object.values(languages)) {
+      if (language === finalDefaultLanguage) continue;
+      Object.assign(language, {inheritedStrings});
+    }
+  };
+
+  if (finalDefaultLanguage !== internalDefaultLanguage) {
+    inheritStringsFromInternalLanguage();
   }
 
-  for (const language of Object.values(languages)) {
-    if (language === finalDefaultLanguage) {
-      continue;
+  inheritStringsFromDefaultLanguage();
+
+  if (!noLanguageReloading) {
+    if (finalDefaultLanguage !== internalDefaultLanguage) {
+      internalDefaultLanguageWatcher.on('update', () => {
+        inheritStringsFromInternalLanguage();
+        inheritStringsFromDefaultLanguage();
+      });
     }
 
-    language.inheritedStrings = finalDefaultLanguage.strings;
+    finalDefaultLanguageWatcher.on('update', () => {
+      inheritStringsFromDefaultLanguage();
+    });
   }
 
   logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
 
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    annotation: finalDefaultLanguageAnnotation,
+    timeEnd: Date.now(),
+  });
+
   const urls = generateURLs(urlSpec);
 
   Object.assign(stepStatusSummary.verifyImagePaths, {
diff --git a/src/util/html.js b/src/util/html.js
index 282a52da..5b6743e0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -181,6 +181,10 @@ export function tags(content, attributes = null) {
   return new Tag(null, attributes, content);
 }
 
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
 export class Tag {
   #tagName = '';
   #content = null;
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 5cb499b1..a4c5dac1 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -8,7 +8,7 @@ import {getColors} from '#colors';
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
-import {processLanguageFile} from '#language';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
 import {empty, showAggregate} from '#sugar';
 import {generateURLs, thumb, urlSpec} from '#urls';
 
@@ -22,7 +22,7 @@ export function testContentFunctions(t, message, fn) {
   t.test(message, async t => {
     let loadedContentDependencies;
 
-    const language = await processLanguageFile('./src/strings-default.json');
+    const language = await processLanguageFile(internalDefaultStringsFile);
     const mocks = [];
 
     const evaluate = ({
@@ -50,8 +50,15 @@ export function testContentFunctions(t, message, fn) {
             thumb,
             to,
             urls,
+
+            pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
+
+            wikiData: {
+              wikiInfo: {},
+            },
+
             ...extraDependencies,
           },
         });