« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content-function.js596
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js97
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js59
-rw-r--r--src/content/dependencies/generateAlbumBanner.js37
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js166
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js23
-rw-r--r--src/content/dependencies/generateAlbumGalleryInfoLine.js38
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js137
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js286
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js114
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js101
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js98
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js75
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js87
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js98
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js77
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js48
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js59
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js137
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js72
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js114
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js213
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js308
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js188
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js81
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js50
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js16
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js111
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js134
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js23
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js185
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js100
-rw-r--r--src/content/dependencies/generateBanner.js28
-rw-r--r--src/content/dependencies/generateChronologyLinks.js82
-rw-r--r--src/content/dependencies/generateColorStyleRules.js27
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js33
-rw-r--r--src/content/dependencies/generateContentHeading.js19
-rw-r--r--src/content/dependencies/generateCoverArtwork.js77
-rw-r--r--src/content/dependencies/generateCoverCarousel.js54
-rw-r--r--src/content/dependencies/generateCoverGrid.js42
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js44
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js216
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js170
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js142
-rw-r--r--src/content/dependencies/generateGroupSidebar.js35
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js77
-rw-r--r--src/content/dependencies/generateListingIndexList.js130
-rw-r--r--src/content/dependencies/generateListingPage.js142
-rw-r--r--src/content/dependencies/generateListingSidebar.js20
-rw-r--r--src/content/dependencies/generatePageLayout.js546
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js32
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js42
-rw-r--r--src/content/dependencies/generateSecondaryNav.js19
-rw-r--r--src/content/dependencies/generateStaticPage.js39
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js33
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js29
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js662
-rw-r--r--src/content/dependencies/generateTrackList.js49
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js53
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js87
-rw-r--r--src/content/dependencies/image.js204
-rw-r--r--src/content/dependencies/index.js255
-rw-r--r--src/content/dependencies/linkAlbum.js8
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkAlbumCommentary.js8
-rw-r--r--src/content/dependencies/linkAlbumGallery.js8
-rw-r--r--src/content/dependencies/linkArtTag.js8
-rw-r--r--src/content/dependencies/linkArtist.js8
-rw-r--r--src/content/dependencies/linkArtistGallery.js8
-rw-r--r--src/content/dependencies/linkContribution.js72
-rw-r--r--src/content/dependencies/linkExternal.js90
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js46
-rw-r--r--src/content/dependencies/linkExternalFlash.js41
-rw-r--r--src/content/dependencies/linkFlash.js8
-rw-r--r--src/content/dependencies/linkGroup.js8
-rw-r--r--src/content/dependencies/linkGroupExtra.js34
-rw-r--r--src/content/dependencies/linkGroupGallery.js8
-rw-r--r--src/content/dependencies/linkListing.js14
-rw-r--r--src/content/dependencies/linkListingIndex.js12
-rw-r--r--src/content/dependencies/linkNewsEntry.js8
-rw-r--r--src/content/dependencies/linkStaticPage.js8
-rw-r--r--src/content/dependencies/linkStationaryIndex.js24
-rw-r--r--src/content/dependencies/linkTemplate.js67
-rw-r--r--src/content/dependencies/linkThing.js84
-rw-r--r--src/content/dependencies/linkTrack.js8
-rw-r--r--src/content/dependencies/listAlbumsByDate.js52
-rw-r--r--src/content/dependencies/listAlbumsByDateAdded.js59
-rw-r--r--src/content/dependencies/listAlbumsByDuration.js51
-rw-r--r--src/content/dependencies/listAlbumsByName.js50
-rw-r--r--src/content/dependencies/listAlbumsByTracks.js51
-rw-r--r--src/content/dependencies/listArtistsByCommentaryEntries.js55
-rw-r--r--src/content/dependencies/listArtistsByContributions.js163
-rw-r--r--src/content/dependencies/listArtistsByDuration.js55
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js367
-rw-r--r--src/content/dependencies/listArtistsByName.js55
-rw-r--r--src/content/dependencies/listGroupsByAlbums.js51
-rw-r--r--src/content/dependencies/listGroupsByCategory.js76
-rw-r--r--src/content/dependencies/listGroupsByDuration.js55
-rw-r--r--src/content/dependencies/listGroupsByLatestAlbum.js78
-rw-r--r--src/content/dependencies/listGroupsByName.js49
-rw-r--r--src/content/dependencies/listGroupsByTracks.js55
-rw-r--r--src/content/dependencies/transformContent.js322
-rw-r--r--src/content/util/getChronologyRelations.js42
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/things/album.js24
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/homepage-layout.js4
-rw-r--r--src/data/things/language.js10
-rw-r--r--src/data/things/thing.js16
-rw-r--r--src/data/things/validators.js49
-rw-r--r--src/data/yaml.js1
-rw-r--r--src/listing-spec.js570
-rw-r--r--src/misc-templates.js956
-rw-r--r--src/page/album-commentary.js137
-rw-r--r--src/page/album.js706
-rw-r--r--src/page/artist-alias.js18
-rw-r--r--src/page/artist.js739
-rw-r--r--src/page/group.js315
-rw-r--r--src/page/index.js47
-rw-r--r--src/page/listing.js24
-rw-r--r--src/page/static.js30
-rw-r--r--src/page/track.js550
-rw-r--r--src/static/client.js45
-rw-r--r--src/static/site4.css (renamed from src/static/site3.css)83
-rw-r--r--src/strings-default.json63
-rwxr-xr-xsrc/upd8.js1
-rw-r--r--src/util/html.js837
-rw-r--r--src/util/link.js168
-rw-r--r--src/util/replacer.js11
-rw-r--r--src/util/sugar.js212
-rw-r--r--src/util/transform-content.js3
-rw-r--r--src/util/wiki-data.js422
-rw-r--r--src/write/bind-utilities.js168
-rw-r--r--src/write/build-modes/live-dev-server.js175
-rw-r--r--src/write/build-modes/static-build.js209
-rw-r--r--src/write/page-template.js150
-rw-r--r--src/write/validate-writes.js2
138 files changed, 11946 insertions, 4460 deletions
diff --git a/src/content-function.js b/src/content-function.js
new file mode 100644
index 00000000..9d6f6af9
--- /dev/null
+++ b/src/content-function.js
@@ -0,0 +1,596 @@
+import {
+  annotateFunction,
+  empty,
+  setIntersection,
+} from './util/sugar.js';
+
+export class ContentFunctionSpecError extends Error {}
+
+export default function contentFunction({
+  contentDependencies = [],
+  extraDependencies = [],
+
+  slots,
+  sprawl,
+  query,
+  relations,
+  data,
+  generate,
+}) {
+  const expectedContentDependencyKeys = new Set(contentDependencies);
+  const expectedExtraDependencyKeys = new Set(extraDependencies);
+
+  // Initial checks. These only need to be run once per description of a
+  // content function, and don't depend on any mutable context (e.g. which
+  // dependencies have been fulfilled so far).
+
+  const overlappingContentExtraDependencyKeys =
+    setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys);
+
+  if (!empty(overlappingContentExtraDependencyKeys)) {
+    throw new ContentFunctionSpecError(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`);
+  }
+
+  if (!generate) {
+    throw new ContentFunctionSpecError(`Expected generate function`);
+  }
+
+  if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) {
+    throw new ContentFunctionSpecError(`Content functions which sprawl must specify wikiData in extraDependencies`);
+  }
+
+  if (slots && !expectedExtraDependencyKeys.has('html')) {
+    throw new ContentFunctionSpecError(`Content functions with slots must specify html in extraDependencies`);
+  }
+
+  // Pass all the details to expectDependencies, which will recursively build
+  // up a set of fulfilled dependencies and make functions like `relations`
+  // and `generate` callable only with sufficient fulfilled dependencies.
+
+  return expectDependencies({
+    slots,
+    sprawl,
+    query,
+    relations,
+    data,
+    generate,
+
+    expectedContentDependencyKeys,
+    expectedExtraDependencyKeys,
+    missingContentDependencyKeys: new Set(expectedContentDependencyKeys),
+    missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys),
+    invalidatingDependencyKeys: new Set(),
+    fulfilledDependencyKeys: new Set(),
+    fulfilledDependencies: {},
+  });
+}
+
+contentFunction.identifyingSymbol = Symbol(`Is a content function?`);
+
+export function expectDependencies({
+  slots,
+  sprawl,
+  query,
+  relations,
+  data,
+  generate,
+
+  expectedContentDependencyKeys,
+  expectedExtraDependencyKeys,
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
+  fulfilledDependencies,
+}) {
+  const hasSprawlFunction = !!sprawl;
+  const hasQueryFunction = !!query;
+  const hasRelationsFunction = !!relations;
+  const hasDataFunction = !!data;
+  const hasSlotsDescription = !!slots;
+
+  const isInvalidated = !empty(invalidatingDependencyKeys);
+  const isMissingContentDependencies = !empty(missingContentDependencyKeys);
+  const isMissingExtraDependencies = !empty(missingExtraDependencyKeys);
+
+  let wrappedGenerate;
+
+  if (isInvalidated) {
+    wrappedGenerate = function() {
+      throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'});
+    wrappedGenerate.fulfilled = false;
+  } else if (isMissingContentDependencies || isMissingExtraDependencies) {
+    wrappedGenerate = function() {
+      throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
+    wrappedGenerate.fulfilled = false;
+  } else {
+    const callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => {
+      if (hasDataFunction && !arg1) {
+        throw new Error(`Expected data`);
+      }
+
+      if (hasDataFunction && hasRelationsFunction && !arg2) {
+        throw new Error(`Expected relations`);
+      }
+
+      if (hasRelationsFunction && !arg1) {
+        throw new Error(`Expected relations`);
+      }
+
+      if (hasDataFunction && hasRelationsFunction) {
+        return generate(arg1, arg2, ...extraArgs, fulfilledDependencies);
+      } else if (hasDataFunction || hasRelationsFunction) {
+        return generate(arg1, ...extraArgs, fulfilledDependencies);
+      } else {
+        return generate(...extraArgs, fulfilledDependencies);
+      }
+    };
+
+    if (hasSlotsDescription) {
+      const stationery = fulfilledDependencies.html.stationery({
+        annotation: generate.name,
+
+        // These extra slots are for the data and relations (positional) args.
+        // No hacks to store them temporarily or otherwise "invisibly" alter
+        // the behavior of the template description's `content`, since that
+        // would be expressly against the purpose of templates!
+        slots: {
+          _cfArg1: {validate: v => v.isObject},
+          _cfArg2: {validate: v => v.isObject},
+          ...slots,
+        },
+
+        content(slots) {
+          const args = [slots._cfArg1, slots._cfArg2];
+          return callUnderlyingGenerate(args, slots);
+        },
+      });
+
+      wrappedGenerate = function(...args) {
+        return stationery.template().slots({
+          _cfArg1: args[0] ?? null,
+          _cfArg2: args[1] ?? null,
+        });
+      };
+    } else {
+      wrappedGenerate = function(...args) {
+        return callUnderlyingGenerate(args);
+      };
+    }
+
+    wrappedGenerate.fulfill = function() {
+      throw new Error(`All dependencies already fulfilled (${generate.name})`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'});
+    wrappedGenerate.fulfilled = true;
+  }
+
+  wrappedGenerate[contentFunction.identifyingSymbol] = true;
+
+  if (hasSprawlFunction) {
+    wrappedGenerate.sprawl = sprawl;
+  }
+
+  if (hasQueryFunction) {
+    wrappedGenerate.query = query;
+  }
+
+  if (hasRelationsFunction) {
+    wrappedGenerate.relations = relations;
+  }
+
+  if (hasDataFunction) {
+    wrappedGenerate.data = data;
+  }
+
+  wrappedGenerate.fulfill ??= function fulfill(dependencies) {
+    // To avoid unneeded destructuring, `fullfillDependencies` is a mutating
+    // function. But `fulfill` itself isn't meant to mutate! We create a copy
+    // of these variables, so their original values are kept for additional
+    // calls to this same `fulfill`.
+    const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys);
+    const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys);
+    const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys);
+    const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys);
+    const newlyFulfilledDependencies = {...fulfilledDependencies};
+
+    try {
+      fulfillDependencies(dependencies, {
+        missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+        missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+        invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+        fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+        fulfilledDependencies: newlyFulfilledDependencies,
+      });
+    } catch (error) {
+      error.message += ` (${generate.name})`;
+      throw error;
+    }
+
+    return expectDependencies({
+      slots,
+      sprawl,
+      query,
+      relations,
+      data,
+      generate,
+
+      expectedContentDependencyKeys,
+      expectedExtraDependencyKeys,
+      missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+      missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+      invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+      fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+      fulfilledDependencies: newlyFulfilledDependencies,
+    });
+
+  };
+
+  Object.assign(wrappedGenerate, {
+    contentDependencies: expectedContentDependencyKeys,
+    extraDependencies: expectedExtraDependencyKeys,
+  });
+
+  return wrappedGenerate;
+}
+
+export function fulfillDependencies(dependencies, {
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
+  fulfilledDependencies,
+}) {
+  // This is a mutating function. Be aware: it WILL mutate the provided sets
+  // and objects EVEN IF there are errors. This function doesn't exit early,
+  // so all provided dependencies which don't have an associated error should
+  // be treated as fulfilled (this is reflected via fulfilledDependencyKeys).
+
+  const errors = [];
+
+  for (let [key, value] of Object.entries(dependencies)) {
+    if (fulfilledDependencyKeys.has(key)) {
+      errors.push(new Error(`Dependency ${key} is already fulfilled`));
+      continue;
+    }
+
+    const isContentKey = missingContentDependencyKeys.has(key);
+    const isExtraKey = missingExtraDependencyKeys.has(key);
+
+    if (!isContentKey && !isExtraKey) {
+      errors.push(new Error(`Dependency ${key} is not expected`));
+      continue;
+    }
+
+    if (value === undefined) {
+      errors.push(new Error(`Dependency ${key} was provided undefined`));
+      continue;
+    }
+
+    const isContentFunction =
+      !!value?.[contentFunction.identifyingSymbol];
+
+    const isFulfilledContentFunction =
+      isContentFunction && value.fulfilled;
+
+    if (isContentKey) {
+      if (!isContentFunction) {
+        errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`));
+        continue;
+      }
+
+      if (!isFulfilledContentFunction) {
+        invalidatingDependencyKeys.add(key);
+      }
+
+      missingContentDependencyKeys.delete(key);
+    } else if (isExtraKey) {
+      if (isContentFunction) {
+        errors.push(new Error(`Extra dependency ${key} is a content function`));
+        continue;
+      }
+
+      missingExtraDependencyKeys.delete(key);
+    }
+
+    fulfilledDependencyKeys.add(key);
+    fulfilledDependencies[key] = value;
+  }
+
+  if (!empty(errors)) {
+    throw new AggregateError(errors, `Errors fulfilling dependencies`);
+  }
+}
+
+export function getArgsForRelationsAndData(contentFunction, wikiData, ...args) {
+  const insertArgs = [];
+
+  if (contentFunction.sprawl) {
+    insertArgs.push(contentFunction.sprawl(wikiData, ...args));
+  }
+
+  if (contentFunction.query) {
+    insertArgs.unshift(contentFunction.query(...insertArgs, ...args));
+  }
+
+  // Note: Query is generally intended to "filter" the provided args/sprawl,
+  // so in most cases it shouldn't be necessary to access the original args
+  // or sprawl afterwards. These are left available for now (as the second
+  // and later arguments in relations/data), but if they don't find any use,
+  // we can refactor this step to remove them.
+
+  return [...insertArgs, ...args];
+}
+
+export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) {
+  const relationIdentifier = Symbol('Relation');
+
+  function recursive(contentFunctionName, ...args) {
+    const contentFunction = dependencies[contentFunctionName];
+    if (!contentFunction) {
+      throw new Error(`Couldn't find dependency ${contentFunctionName}`);
+    }
+
+    // TODO: It's a bit awkward to pair this list of arguments with the output of
+    // getRelationsTree, but we do need to evaluate it right away (for the upcoming
+    // call to relations), and we're going to be reusing the same results for a
+    // later call to data (outside of getRelationsTree). There might be a nicer way
+    // of handling this.
+    const argsForRelationsAndData =
+      getArgsForRelationsAndData(
+        contentFunction,
+        wikiData,
+        ...args);
+
+    const result = {
+      name: contentFunctionName,
+      args: argsForRelationsAndData,
+    };
+
+    if (contentFunction.relations) {
+      const listedDependencies = new Set(contentFunction.contentDependencies);
+
+      const relationSlots = {};
+
+      const relationSymbolMessage = (() => {
+        let num = 1;
+        return name => `#${num++} ${name}`;
+      })();
+
+      const relationFunction = (name, ...args) => {
+        if (!listedDependencies.has(name)) {
+          throw new Error(`Called relation('${name}') but ${contentFunctionName} doesn't list that dependency`);
+        }
+
+        const relationSymbol = Symbol(relationSymbolMessage(name));
+        relationSlots[relationSymbol] = {name, args};
+        return {[relationIdentifier]: relationSymbol};
+      };
+
+      const relationsLayout =
+        contentFunction.relations(relationFunction, ...argsForRelationsAndData);
+
+      const relationsTree = Object.fromEntries(
+        Object.getOwnPropertySymbols(relationSlots)
+          .map(symbol => [symbol, relationSlots[symbol]])
+          .map(([symbol, {name, args}]) => [
+            symbol,
+            recursive(name, ...args),
+          ]));
+
+      result.relations = {
+        layout: relationsLayout,
+        slots: relationSlots,
+        tree: relationsTree,
+      };
+    }
+
+    return result;
+  }
+
+  const root = recursive(contentFunctionName, ...args);
+
+  return {root, relationIdentifier};
+}
+
+export function flattenRelationsTree({root, relationIdentifier}) {
+  const flatRelationSlots = {};
+
+  function recursive(node) {
+    if (node.relations) {
+      const {tree, slots} = node.relations;
+      for (const slot of Object.getOwnPropertySymbols(slots)) {
+        flatRelationSlots[slot] = recursive(tree[slot]);
+      }
+    }
+
+    return {
+      name: node.name,
+      args: node.args,
+      relations: node.relations?.layout ?? null,
+    };
+  }
+
+  return {
+    root: recursive(root),
+    relationIdentifier,
+    flatRelationSlots,
+  };
+}
+
+export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, layout) {
+  function recursive(object) {
+    if (typeof object !== 'object' || object === null) {
+      return object;
+    }
+
+    if (Array.isArray(object)) {
+      return object.map(recursive);
+    }
+
+    if (relationIdentifier in object) {
+      return results[object[relationIdentifier]];
+    }
+
+    if (object.constructor !== Object) {
+      throw new Error(`Expected primitive, array, relation, or normal {key: value} style Object, got constructor ${object.constructor?.name}`);
+    }
+
+    return Object.fromEntries(
+      Object.entries(object)
+        .map(([key, value]) => [key, recursive(value)]));
+  }
+
+  return recursive(layout);
+}
+
+export function getNeededContentDependencyNames(contentDependencies, name) {
+  const set = new Set();
+
+  function recursive(name) {
+    const contentFunction = contentDependencies[name];
+    for (const dependencyName of contentFunction?.contentDependencies ?? []) {
+      recursive(dependencyName);
+    }
+    set.add(name);
+  }
+
+  recursive(name);
+
+  return set;
+}
+
+export function quickEvaluate({
+  contentDependencies: allContentDependencies,
+  extraDependencies: allExtraDependencies,
+
+  name,
+  args = [],
+  slots = null,
+  multiple = null,
+  postprocess = null,
+}) {
+  if (multiple !== null) {
+    return multiple.map(opts =>
+      quickEvaluate({
+        contentDependencies: allContentDependencies,
+        extraDependencies: allExtraDependencies,
+
+        ...opts,
+        name: opts.name ?? name,
+        args: opts.args ?? args,
+        slots: opts.slots ?? slots,
+        postprocess: opts.postprocess ?? postprocess,
+      }));
+  }
+
+  const treeInfo = getRelationsTree(allContentDependencies, name, allExtraDependencies.wikiData ?? {}, ...args);
+  const flatTreeInfo = flattenRelationsTree(treeInfo);
+  const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+  const neededContentDependencyNames =
+    getNeededContentDependencyNames(allContentDependencies, name);
+
+  // Content functions aren't recursive, so by following the set above
+  // sequentually, we will always provide fulfilled content functions as the
+  // dependencies for later content functions.
+  const fulfilledContentDependencies = {};
+  for (const name of neededContentDependencyNames) {
+    const unfulfilledContentFunction = allContentDependencies[name];
+    if (!unfulfilledContentFunction) continue;
+
+    const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+    if (empty(contentDependencies) && empty(extraDependencies)) {
+      fulfilledContentDependencies[name] = unfulfilledContentFunction;
+      continue;
+    }
+
+    const fulfillments = {};
+
+    for (const dependencyName of contentDependencies ?? []) {
+      if (dependencyName in fulfilledContentDependencies) {
+        fulfillments[dependencyName] =
+          fulfilledContentDependencies[dependencyName];
+      }
+    }
+
+    for (const dependencyName of extraDependencies ?? []) {
+      if (dependencyName in allExtraDependencies) {
+        fulfillments[dependencyName] =
+          allExtraDependencies[dependencyName];
+      }
+    }
+
+    fulfilledContentDependencies[name] =
+      unfulfilledContentFunction.fulfill(fulfillments);
+  }
+
+  // There might still be unfulfilled content functions if dependencies weren't
+  // provided as part of allContentDependencies or allExtraDependencies.
+  // Catch and report these early, together in an aggregate error.
+  const unfulfilledErrors = [];
+  const unfulfilledNames = [];
+  for (const name of neededContentDependencyNames) {
+    const contentFunction = fulfilledContentDependencies[name];
+    if (!contentFunction) continue;
+    if (!contentFunction.fulfilled) {
+      try {
+        contentFunction();
+      } catch (error) {
+        error.message = `(${name}) ${error.message}`;
+        unfulfilledErrors.push(error);
+        unfulfilledNames.push(name);
+      }
+    }
+  }
+
+  if (!empty(unfulfilledErrors)) {
+    throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+  }
+
+  const slotResults = {};
+
+  function runContentFunction({name, args, relations: layout}) {
+    const contentFunction = fulfilledContentDependencies[name];
+
+    if (!contentFunction) {
+      throw new Error(`Content function ${name} unfulfilled or not listed`);
+    }
+
+    const generateArgs = [];
+
+    if (contentFunction.data) {
+      generateArgs.push(contentFunction.data(...args));
+    }
+
+    if (layout) {
+      generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout));
+    }
+
+    return contentFunction(...generateArgs);
+  }
+
+  for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+    slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+  }
+
+  let topLevelResult = runContentFunction(root);
+
+  if (slots) {
+    topLevelResult.setSlots(slots);
+  }
+
+  if (postprocess) {
+    topLevelResult = postprocess(topLevelResult);
+  }
+
+  return topLevelResult;
+}
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
new file mode 100644
index 00000000..d280a633
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -0,0 +1,97 @@
+import {empty} from '../../util/sugar.js';
+
+function validateFileMapping(v, validateValue) {
+  return value => {
+    v.isObject(value);
+
+    const valueErrors = [];
+    for (const [fileKey, fileValue] of Object.entries(value)) {
+      if (fileValue === null) {
+        continue;
+      }
+
+      try {
+        validateValue(fileValue);
+      } catch (error) {
+        error.message = `(${fileKey}) ` + error.message;
+        valueErrors.push(error);
+      }
+    }
+
+    if (!empty(valueErrors)) {
+      throw new AggregateError(valueErrors, `Errors validating values`);
+    }
+  };
+}
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(additionalFiles) {
+    return {
+      // Additional files are already a serializable format.
+      additionalFiles,
+    };
+  },
+
+  slots: {
+    fileLinks: {
+      validate: v => validateFileMapping(v, v.isHTML),
+    },
+
+    fileSizes: {
+      validate: v => validateFileMapping(v, v.isWholeNumber),
+    },
+  },
+
+  generate(data, slots, {html, language}) {
+    if (!slots.fileLinks) {
+      return html.blank();
+    }
+
+    const filesWithLinks = new Set(
+      Object.entries(slots.fileLinks)
+        .filter(([key, value]) => value)
+        .map(([key]) => key));
+
+    if (empty(filesWithLinks)) {
+      return html.blank();
+    }
+
+    const filteredFileGroups = data.additionalFiles
+      .map(({title, description, files}) => ({
+        title,
+        description,
+        files: files.filter(f => filesWithLinks.has(f)),
+      }))
+      .filter(({files}) => !empty(files));
+
+    if (empty(filteredFileGroups)) {
+      return html.blank();
+    }
+
+    return html.tag('dl',
+      filteredFileGroups.flatMap(({title, description, files}) => [
+        html.tag('dt',
+          (description
+            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
+                title,
+                description,
+              })
+            : language.$('releaseInfo.additionalFiles.entry', {title}))),
+
+        html.tag('dd',
+          html.tag('ul',
+            files.map(file =>
+              html.tag('li',
+                (slots.fileSizes?.[file]
+                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
+                      file: slots.fileLinks[file],
+                      size: language.formatFileSize(slots.fileSizes[file]),
+                    })
+                  : language.$('releaseInfo.additionalFiles.file', {
+                      file: slots.fileLinks[file],
+                    })))))),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
new file mode 100644
index 00000000..17280da5
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesShortcut.js
@@ -0,0 +1,27 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(additionalFiles) {
+    return {
+      titles: additionalFiles.map(fileGroup => fileGroup.title),
+    };
+  },
+
+  generate(data, {html, language}) {
+    if (empty(data.titles)) {
+      return html.blank();
+    }
+
+    return language.$('releaseInfo.additionalFiles.shortcut', {
+      anchorLink:
+        html.tag('a',
+          {href: '#additional-files'},
+          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
+
+      titles:
+        language.formatUnitList(data.titles),
+    });
+  },
+}
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
new file mode 100644
index 00000000..23f32bf5
--- /dev/null
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -0,0 +1,59 @@
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesList',
+    'linkAlbumAdditionalFile',
+  ],
+
+  extraDependencies: [
+    'getSizeOfAdditionalFile',
+    'html',
+    'urls',
+  ],
+
+  data(album, additionalFiles) {
+    return {
+      albumDirectory: album.directory,
+      fileLocations: additionalFiles.flatMap(({files}) => files),
+    };
+  },
+
+  relations(relation, album, additionalFiles) {
+    return {
+      additionalFilesList:
+        relation('generateAdditionalFilesList', additionalFiles),
+
+      additionalFileLinks:
+        Object.fromEntries(
+          additionalFiles
+            .flatMap(({files}) => files)
+            .map(file => [
+              file,
+              relation('linkAlbumAdditionalFile', album, file),
+            ])),
+    };
+  },
+
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
+  },
+
+  generate(data, relations, slots, {
+    getSizeOfAdditionalFile,
+    urls,
+  }) {
+    return relations.additionalFilesList
+      .slots({
+        fileLinks: relations.additionalFileLinks,
+        fileSizes:
+          Object.fromEntries(data.fileLocations.map(file => [
+            file,
+            (slots.showFileSizes
+              ? getSizeOfAdditionalFile(
+                  urls
+                    .from('media.root')
+                    .to('media.albumAdditionalFile', data.albumDirectory, file))
+              : 0),
+          ])),
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js
new file mode 100644
index 00000000..3cc141bc
--- /dev/null
+++ b/src/content/dependencies/generateAlbumBanner.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: ['generateBanner'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      banner: relation('generateBanner'),
+    };
+  },
+
+  data(album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      path: ['media.albumBanner', album.directory, album.bannerFileExtension],
+      dimensions: album.bannerDimensions,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    if (!relations.banner) {
+      return html.blank();
+    }
+
+    return relations.banner.slots({
+      path: data.path,
+      dimensions: data.dimensions,
+      alt: language.$('misc.alt.albumBanner'),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
new file mode 100644
index 00000000..ea31292c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -0,0 +1,166 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAlbumNavAccent',
+    'generateAlbumStyleRules',
+    'generateColorStyleRules',
+    'generateColorStyleVariables',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album);
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', album.color);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    if (album.commentary) {
+      relations.albumCommentaryContent =
+        relation('transformContent', album.commentary);
+    }
+
+    const tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => commentary);
+
+    relations.trackCommentaryHeadings =
+      tracksWithCommentary
+        .map(() => relation('generateContentHeading'));
+
+    relations.trackCommentaryLinks =
+      tracksWithCommentary
+        .map(track => relation('linkTrack', track));
+
+    relations.trackCommentaryContent =
+      tracksWithCommentary
+        .map(track => relation('transformContent', track.commentary));
+
+    relations.trackCommentaryColorVariables =
+      tracksWithCommentary
+        .map(track =>
+          (track.color === album.color
+            ? null
+            : relation('generateColorStyleVariables', track.color)));
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.name = album.name;
+
+    const tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => commentary);
+
+    const thingsWithCommentary =
+      (album.commentary
+        ? [album, ...tracksWithCommentary]
+        : tracksWithCommentary);
+
+    data.entryCount = thingsWithCommentary.length;
+
+    data.wordCount =
+      thingsWithCommentary
+        .map(({commentary}) => commentary)
+        .join(' ')
+        .split(' ')
+        .length;
+
+    data.trackCommentaryDirectories =
+      tracksWithCommentary
+        .map(track => track.directory);
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('albumCommentaryPage.title', {
+            album: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$('albumCommentaryPage.infoLine', {
+              words:
+                html.tag('b',
+                  language.formatWordCount(data.wordCount, {unit: true})),
+
+              entries:
+                html.tag('b',
+                  language.countCommentaryEntries(data.entryCount, {unit: true})),
+            })),
+
+          relations.albumCommentaryContent && [
+            html.tag('h3',
+              {class: ['content-heading']},
+              language.$('albumCommentaryPage.entry.title.albumCommentary')),
+
+            html.tag('blockquote',
+              relations.albumCommentaryContent),
+          ],
+
+          stitchArrays({
+            heading: relations.trackCommentaryHeadings,
+            link: relations.trackCommentaryLinks,
+            directory: data.trackCommentaryDirectories,
+            content: relations.trackCommentaryContent,
+            colorVariables: relations.trackCommentaryColorVariables,
+          }).map(({heading, link, directory, content, colorVariables}) => [
+              heading.slots({
+                tag: 'h3',
+                id: directory,
+                title: link,
+              }),
+              html.tag('blockquote', {style: colorVariables}, content),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: 'commentary',
+              }),
+          },
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
new file mode 100644
index 00000000..f7e86303
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations(relation, album) {
+    return {
+      coverArtwork:
+        relation('generateCoverArtwork', album.artTags),
+    };
+  },
+
+  data(album) {
+    return {
+      path: ['media.albumCover', album.directory, album.coverArtFileExtension],
+    };
+  },
+
+  generate(data, relations) {
+    return relations.coverArtwork
+      .slots({
+        path: data.path,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryInfoLine.js b/src/content/dependencies/generateAlbumGalleryInfoLine.js
new file mode 100644
index 00000000..d4bd4d75
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryInfoLine.js
@@ -0,0 +1,38 @@
+import {getTotalDuration} from '../../util/wiki-data.js';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(album) {
+    return {
+      name: album.name,
+      date: album.date,
+      duration: getTotalDuration(album.tracks),
+      numTracks: album.tracks.length,
+    };
+  },
+
+  generate(data, {html, language}) {
+    const parts = ['albumGalleryPage.infoLine'];
+    const options = {};
+
+    options.tracks =
+      html.tag('b',
+        language.countTracks(data.numTracks, {unit: true}));
+
+    options.duration =
+      html.tag('b',
+        language.formatDuration(data.duration, {unit: true}));
+
+    if (data.date) {
+      parts.push('withDate');
+      options.date =
+        html.tag('b',
+          language.formatDate(data.date));
+    }
+
+    return (
+      html.tag('p', {class: 'quick-info'},
+        language.formatString(parts.join('.'), options)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
new file mode 100644
index 00000000..b39b4c80
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -0,0 +1,137 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryInfoLine',
+    'generateAlbumNavAccent',
+    'generateAlbumStyleRules',
+    'generateColorStyleRules',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album);
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', album.color);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    relations.infoLine =
+      relation('generateAlbumGalleryInfoLine', album);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.links =
+      album.tracks.map(track =>
+        relation('linkTrack', track));
+
+    relations.images =
+      album.tracks.map(track =>
+        (track.hasUniqueCoverArt
+          ? relation('image', track.artTags)
+          : relation('image')));
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.name = album.name;
+
+    data.names =
+      album.tracks.map(track => track.name);
+
+    data.coverArtists =
+      album.tracks.map(track =>
+        (track.hasUniqueCoverArt
+          ? track.coverArtistContribs.map(({who: artist}) => artist.name)
+          : null));
+
+    data.paths =
+      album.tracks.map(track =>
+        (track.hasUniqueCoverArt
+          ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+          : null));
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('albumGalleryPage.title', {
+            album: data.name,
+          }),
+
+        headingMode: 'static',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.infoLine,
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              names: data.names,
+              images:
+                stitchArrays({
+                  image: relations.images,
+                  path: data.paths,
+                  name: data.names,
+                }).map(({image, path, name}) =>
+                    image.slots({
+                      path,
+                      missingSourceContent:
+                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                    })),
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.albumGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: 'gallery',
+              }),
+          },
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
new file mode 100644
index 00000000..8fbb81f9
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -0,0 +1,286 @@
+import getChronologyRelations from '../util/getChronologyRelations.js';
+import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumBanner',
+    'generateAlbumCoverArtwork',
+    'generateAlbumNavAccent',
+    'generateAlbumReleaseInfo',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumSocialEmbed',
+    'generateAlbumStyleRules',
+    'generateAlbumTrackList',
+    'generateChronologyLinks',
+    'generateColorStyleRules',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkArtist',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album);
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', album.color);
+
+    relations.socialEmbed =
+      relation('generateAlbumSocialEmbed', album);
+
+    relations.coverArtistChronologyContributions =
+      getChronologyRelations(album, {
+        contributions: album.coverArtistContribs,
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ]),
+      });
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    relations.chronologyLinks =
+      relation('generateChronologyLinks');
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.sidebar =
+      relation('generateAlbumSidebar', album, null);
+
+    if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateAlbumCoverArtwork', album);
+    }
+
+    if (album.hasBannerArt) {
+      relations.banner =
+        relation('generateAlbumBanner', album);
+    }
+
+    // Section: Release info
+
+    relations.releaseInfo =
+      relation('generateAlbumReleaseInfo', album);
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
+      extra.galleryLink =
+        relation('linkAlbumGallery', album);
+    }
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      extra.commentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    if (!empty(album.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', album.additionalFiles);
+    }
+
+    // Section: Track list
+
+    relations.trackList =
+      relation('generateAlbumTrackList', album);
+
+    // Section: Additional files
+
+    if (!empty(album.additionalFiles)) {
+      const additionalFiles = sections.additionalFiles = {};
+
+      additionalFiles.heading =
+        relation('generateContentHeading');
+
+      additionalFiles.additionalFilesList =
+        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (album.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', album.commentary);
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.name = album.name;
+
+    if (!empty(album.additionalFiles)) {
+      data.numAdditionalFiles = album.additionalFiles.length;
+    }
+
+    data.dateAddedToWiki = album.dateAddedToWiki;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('albumPage.title', {album: data.name}),
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        cover:
+          relations.cover
+            ?.slots({
+              alt: language.$('misc.alt.albumCover'),
+            })
+            ?? null,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {
+              [html.onlyIfContent]: true,
+              [html.joinChildren]: html.tag('br'),
+            },
+            [
+              sec.extra.additionalFilesShortcut,
+
+              sec.extra.galleryLink && sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewGalleryOrCommentary', {
+                  gallery:
+                    sec.extra.galleryLink
+                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
+                  commentary:
+                    sec.extra.commentaryLink
+                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
+                }),
+
+              sec.extra.galleryLink && !sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewGallery', {
+                  link:
+                    sec.extra.galleryLink
+                      .slot('content', language.$('releaseInfo.viewGallery.link')),
+                }),
+
+              !sec.extra.galleryLink && sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewCommentary', {
+                  link:
+                    sec.extra.commentaryLink
+                      .slot('content', language.$('releaseInfo.viewCommentary.link')),
+                }),
+            ]),
+
+          relations.trackList,
+
+          html.tag('p',
+            {
+              [html.onlyIfContent]: true,
+              [html.joinChildren]: '<br>',
+            },
+            [
+              data.dateAddedToWiki &&
+                language.$('releaseInfo.addedToWiki', {
+                  date: language.formatDate(data.dateAddedToWiki),
+                }),
+            ]),
+
+          sec.additionalFiles && [
+            sec.additionalFiles.heading
+              .slots({
+                id: 'additional-files',
+                title:
+                  language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles:
+                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                  }),
+              }),
+
+            sec.additionalFiles.additionalFilesList,
+          ],
+
+          sec.artistCommentary && [
+            sec.artistCommentary.heading
+              .slots({
+                id: 'artist-commentary',
+                title: language.$('releaseInfo.artistCommentary')
+              }),
+
+            html.tag('blockquote',
+              sec.artistCommentary.content
+                .slot('mode', 'multiline')),
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
+              }),
+          },
+        ],
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        banner: relations.banner ?? null,
+        bannerPosition: 'top',
+
+        secondaryNav: relations.secondaryNav,
+
+        ...relations.sidebar,
+
+        // socialEmbed: relations.socialEmbed,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
new file mode 100644
index 00000000..0237fdec
--- /dev/null
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -0,0 +1,114 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkTrack',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album, track) {
+    const relations = {};
+
+    relations.previousNextLinks =
+      relation('generatePreviousNextLinks');
+
+    relations.previousTrackLink = null;
+    relations.nextTrackLink = null;
+
+    if (track) {
+      const index = album.tracks.indexOf(track);
+
+      if (index > 0) {
+        relations.previousTrackLink =
+          relation('linkTrack', album.tracks[index - 1]);
+      }
+
+      if (index < album.tracks.length - 1) {
+        relations.nextTrackLink =
+          relation('linkTrack', album.tracks[index + 1]);
+      }
+    }
+
+    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
+      relations.albumGalleryLink =
+        relation('linkAlbumGallery', album);
+    }
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      relations.albumCommentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    return relations;
+  },
+
+  data(album, track) {
+    return {
+      hasMultipleTracks: album.tracks.length > 1,
+      isTrackPage: !!track,
+    };
+  },
+
+  slots: {
+    showTrackNavigation: {type: 'boolean', default: false},
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery', 'commentary'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          relations.albumGalleryLink?.slots({
+            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+            content: language.$('albumPage.nav.gallery'),
+          }),
+
+          relations.albumCommentaryLink?.slots({
+            attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+            content: language.$('albumPage.nav.commentary'),
+          }),
+        ]};
+
+    const {content: previousNextLinks = []} =
+      slots.showTrackNavigation &&
+      data.isTrackPage &&
+      data.hasMultipleTracks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousTrackLink,
+          nextLink: relations.nextTrackLink,
+        });
+
+    const randomLink =
+      slots.showTrackNavigation &&
+      data.hasMultipleTracks &&
+        html.tag('a',
+          {
+            href: '#',
+            'data-random': 'track-in-album',
+            id: 'random-button',
+          },
+          (data.isTrackPage
+            ? language.$('trackPage.nav.random')
+            : language.$('albumPage.nav.randomTrack')));
+
+    const allLinks = [
+      ...previousNextLinks,
+      ...extraLinks,
+      randomLink,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
+  },
+};
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
new file mode 100644
index 00000000..86e6dfe9
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -0,0 +1,101 @@
+import {accumulateSum, empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.artistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.artistContribs);
+
+    relations.coverArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
+
+    relations.wallpaperArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
+
+    relations.bannerArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
+
+    if (!empty(album.urls)) {
+      relations.externalLinks =
+        album.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    if (album.date) {
+      data.date = album.date;
+    }
+
+    if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+      data.coverArtDate = album.coverArtDate;
+    }
+
+    data.duration = accumulateSum(album.tracks, track => track.duration);
+    data.durationApproximate = album.tracks.length > 1;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tags([
+      html.tag('p',
+        {
+          [html.onlyIfContent]: true,
+          [html.joinChildren]: html.tag('br'),
+        },
+        [
+          relations.artistContributionsLine
+            .slots({stringKey: 'releaseInfo.by'}),
+
+          relations.coverArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.coverArtBy'}),
+
+          relations.wallpaperArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
+
+          relations.bannerArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
+
+          data.date &&
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            }),
+
+          data.coverArtDate &&
+            language.$('releaseInfo.artReleased', {
+              date: language.formatDate(data.coverArtDate),
+            }),
+
+          data.duration &&
+            language.$('releaseInfo.duration', {
+              duration:
+                language.formatDuration(data.duration, {
+                  approximate: data.durationApproximate,
+                }),
+            }),
+        ]),
+
+      relations.externalLinks &&
+        html.tag('p',
+          language.$('releaseInfo.listenOn', {
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('mode', 'album'))),
+          })),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
new file mode 100644
index 00000000..6616f20e
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -0,0 +1,98 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+    'generateSecondaryNav',
+    'linkAlbum',
+    'linkGroup',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.secondaryNav =
+      relation('generateSecondaryNav');
+
+    relations.groupParts =
+      album.groups.map(group => {
+        const relations = {};
+
+        relations.groupLink =
+          relation('linkGroup', group);
+
+        relations.colorVariables =
+          relation('generateColorStyleVariables', group.color);
+
+        if (album.date) {
+          const albums = group.albums.filter(album => album.date);
+          const index = albums.indexOf(album);
+          const previousAlbum = (index > 0) && albums[index - 1];
+          const nextAlbum = (index < albums.length - 1) && albums[index + 1];
+
+          if (previousAlbum) {
+            relations.previousAlbumLink =
+              relation('linkAlbum', previousAlbum);
+          }
+
+          if (nextAlbum) {
+            relations.nextAlbumLink =
+              relation('linkAlbum', nextAlbum);
+          }
+        }
+
+        return relations;
+      });
+
+    return relations;
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate(relations, slots, {html, language}) {
+    return relations.secondaryNav.slots({
+      class: 'nav-links-groups',
+      content:
+        relations.groupParts.map(({
+          colorVariables,
+          groupLink,
+          previousAlbumLink,
+          nextAlbumLink,
+        }) => {
+          const links = [
+            previousAlbumLink
+              ?.slots({
+                color: false,
+                content: language.$('misc.nav.previous'),
+              }),
+
+            nextAlbumLink
+              ?.slots({
+                color: false,
+                content: language.$('misc.nav.next'),
+              }),
+          ].filter(Boolean);
+
+          return (
+            (slots.mode === 'album' && !empty(links)
+              ? html.tag('span', {style: colorVariables}, [
+                  language.$('albumSidebar.groupBox.title', {
+                    group: groupLink,
+                  }),
+                  `(${language.formatUnitList(links)})`,
+                ])
+              : language.$('albumSidebar.groupBox.title', {
+                  group: groupLink,
+                })));
+        }),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
new file mode 100644
index 00000000..a84f4357
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -0,0 +1,75 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarTrackSection',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations(relation, album, track) {
+    const relations = {};
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.groupBoxes =
+      album.groups.map(group =>
+        relation('generateAlbumSidebarGroupBox', album, group));
+
+    relations.trackSections =
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection));
+
+    return relations;
+  },
+
+  data(album, track) {
+    return {isAlbumPage: !track};
+  },
+
+  generate(data, relations, {html}) {
+    const trackListBox = {
+      content:
+        html.tags([
+          html.tag('h1', relations.albumLink),
+          relations.trackSections,
+        ]),
+    };
+
+    if (data.isAlbumPage) {
+      const groupBoxes =
+        relations.groupBoxes
+          .map(content => content.slot('mode', 'album'))
+          .map(content => ({content}));
+
+      return {
+        leftSidebarMultiple: [
+          ...groupBoxes,
+          trackListBox,
+        ],
+      };
+    }
+
+    const conjoinedGroupBox = {
+      content:
+        relations.groupBoxes
+          .flatMap((content, i, {length}) => [
+            content.slot('mode', 'track'),
+            i < length - 1 &&
+              html.tag('hr', {
+                style: `border-color: var(--primary-color); border-style: none none dotted none`
+              }),
+          ])
+          .filter(Boolean),
+    };
+
+    return {
+      // leftSidebarStickyMode: 'column',
+      leftSidebarMultiple: [
+        trackListBox,
+        conjoinedGroupBox,
+      ],
+    };
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
new file mode 100644
index 00000000..874dcc20
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,87 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album, group) {
+    const relations = {};
+
+    relations.groupLink =
+      relation('linkGroup', group);
+
+    relations.externalLinks =
+      group.urls.map(url =>
+        relation('linkExternal', url));
+
+    if (group.descriptionShort) {
+      relations.description =
+        relation('transformContent', group.descriptionShort);
+    }
+
+    if (album.date) {
+      const albums = group.albums.filter(album => album.date);
+      const index = albums.indexOf(album);
+      const previousAlbum = (index > 0) && albums[index - 1];
+      const nextAlbum = (index < albums.length - 1) && albums[index + 1];
+
+      if (previousAlbum) {
+        relations.previousAlbumLink =
+          relation('linkAlbum', previousAlbum);
+      }
+
+      if (nextAlbum) {
+        relations.nextAlbumLink =
+          relation('linkAlbum', nextAlbum);
+      }
+    }
+
+    return relations;
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  generate(relations, slots, {html, language}) {
+    return html.tags([
+      html.tag('h1',
+        language.$('albumSidebar.groupBox.title', {
+          group: relations.groupLink,
+        })),
+
+      slots.mode === 'album' &&
+        relations.description
+          ?.slot('mode', 'multiline'),
+
+      !empty(relations.externalLinks) &&
+        html.tag('p',
+          language.$('releaseInfo.visitOn', {
+            links: language.formatDisjunctionList(relations.externalLinks),
+          })),
+
+      slots.mode === 'album' &&
+      relations.nextAlbumLink &&
+        html.tag('p', {class: 'group-chronology-link'},
+          language.$('albumSidebar.groupBox.next', {
+            album: relations.nextAlbumLink,
+          })),
+
+      slots.mode === 'album' &&
+      relations.previousAlbumLink &&
+        html.tag('p', {class: 'group-chronology-link'},
+          language.$('albumSidebar.groupBox.previous', {
+            album: relations.previousAlbumLink,
+          })),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
new file mode 100644
index 00000000..2aca6da1
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -0,0 +1,98 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  relations(relation, album, track, trackSection) {
+    const relations = {};
+
+    relations.trackLinks =
+      trackSection.tracks.map(track =>
+        relation('linkTrack', track));
+
+    return relations;
+  },
+
+  data(album, track, trackSection) {
+    const data = {};
+
+    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.isTrackPage = !!track;
+
+    data.name = trackSection.name;
+    data.color = trackSection.color;
+    data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+
+    data.firstTrackNumber = trackSection.startIndex + 1;
+    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+
+    if (track) {
+      const index = trackSection.tracks.indexOf(track);
+      if (index !== -1) {
+        data.includesCurrentTrack = true;
+        data.currentTrackIndex = index;
+      }
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {getColors, html, language}) {
+    const sectionName =
+      html.tag('span', {class: 'group-name'},
+        (data.isDefaultTrackSection
+          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          : data.name));
+
+    let style;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      style = `--primary-color: ${primary}`;
+    }
+
+    const trackListItems =
+      relations.trackLinks.map((trackLink, index) =>
+        html.tag('li',
+          {
+            class:
+              data.includesCurrentTrack &&
+              index === data.currentTrackIndex &&
+              'current',
+          },
+          language.$('albumSidebar.trackList.item', {
+            track: trackLink,
+          })));
+
+    return html.tag('details',
+      {
+        class: data.includesCurrentTrack && 'current',
+
+        open: (
+          // Leave sidebar track sections collapsed on album info page,
+          // since there's already a view of the full track listing
+          // in the main content area.
+          data.isTrackPage &&
+
+          // Only expand the track section which includes the track
+          // currently being viewed by default.
+          data.includesCurrentTrack),
+      },
+      [
+        html.tag('summary', {style},
+          html.tag('span',
+            (data.hasTrackNumbers
+              ? language.$('albumSidebar.trackList.group.withRange', {
+                  group: sectionName,
+                  range: `${data.firstTrackNumber}&ndash;${data.lastTrackNumber}`
+                })
+              : language.$('albumSidebar.trackList.group', {
+                  group: sectionName,
+                })))),
+
+        (data.hasTrackNumbers
+          ? html.tag('ol',
+              {start: data.firstTrackNumber},
+              trackListItems)
+          : html.tag('ul', trackListItems)),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
new file mode 100644
index 00000000..079899d3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -0,0 +1,77 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language', 'urls'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.description =
+      relation('generateAlbumSocialEmbedDescription', album);
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.hasHeading = !empty(album.groups);
+
+    if (data.hasHeading) {
+      const firstGroup = album.groups[0];
+      data.headingGroupName = firstGroup.directory;
+      data.headingGroupDirectory = firstGroup.directory;
+    }
+
+    data.hasImage = album.hasCoverArt;
+
+    if (data.hasImage) {
+      data.coverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    }
+
+    data.albumName = album.name;
+    data.albumColor = album.color;
+
+    return data;
+  },
+
+  generate(data, relations, {absoluteTo, language, urls}) {
+    const socialEmbed = {};
+
+    if (data.hasHeading) {
+      socialEmbed.heading =
+        language.$('albumPage.socialEmbed.heading', {
+          group: data.headingGroupName,
+        });
+
+      socialEmbed.headingLink =
+        absoluteTo('localized.album', data.headingGroupDirectory);
+    } else {
+      socialEmbed.heading = '';
+      socialEmbed.headingLink = null;
+    }
+
+    socialEmbed.title =
+      language.$('albumPage.socialEmbed.title', {
+        album: data.albumName,
+      });
+
+    socialEmbed.description = relations.description;
+
+    if (data.hasImage) {
+      const imagePath = urls
+        .from('shared.root')
+        .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension);
+      socialEmbed.image = '/' + imagePath;
+    }
+
+    socialEmbed.color = data.albumColor;
+
+    return socialEmbed;
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
new file mode 100644
index 00000000..40f696f8
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -0,0 +1,48 @@
+import {accumulateSum} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['language'],
+
+  data(album) {
+    const data = {};
+
+    const duration = accumulateSum(album.tracks, track => track.duration);
+
+    data.hasDuration = duration > 0;
+    data.hasTracks = album.tracks.length > 0;
+    data.hasDate = !!album.date;
+    data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration);
+
+    if (!data.hasAny)
+      return data;
+
+    if (data.hasDuration)
+      data.duration = duration;
+
+    if (data.hasTracks)
+      data.tracks = album.tracks.length;
+
+    if (data.hasDate)
+      data.date = album.date;
+
+    return data;
+  },
+
+  generate(data, {language}) {
+    return language.formatString(
+      'albumPage.socialEmbed.body' + [
+        data.hasDuration && '.withDuration',
+        data.hasTracks && '.withTracks',
+        data.hasDate && '.withReleaseDate',
+      ].filter(Boolean).join(''),
+
+      Object.fromEntries([
+        data.hasDuration &&
+          ['duration', language.formatDuration(data.duration)],
+        data.hasTracks &&
+          ['tracks', language.countTracks(data.tracks, {unit: true})],
+        data.hasDate &&
+          ['date', language.formatDate(data.date)],
+      ].filter(Boolean)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
new file mode 100644
index 00000000..6a894d71
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -0,0 +1,59 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['to'],
+
+  data(album) {
+    const data = {};
+
+    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasWallpaper) {
+      data.hasWallpaperStyle = !!album.wallpaperStyle;
+      data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
+      data.wallpaperStyle = album.wallpaperStyle;
+    }
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    return data;
+  },
+
+  generate(data, {to}) {
+    const wallpaperPart =
+      (data.hasWallpaper
+        ? [
+            `body::before {`,
+            `    background-image: url("${to(...data.wallpaperPath)}");`,
+            ...(data.hasWallpaperStyle
+              ? data.wallpaperStyle
+                  .split('\n')
+                  .map(line => `    ${line}`)
+              : []),
+            `}`,
+          ]
+        : []);
+
+    const bannerPart =
+      (data.hasBannerStyle
+        ? [
+            `#banner img {`,
+            ...data.bannerStyle
+              .split('\n')
+              .map(line => `    ${line}`),
+            `}`,
+          ]
+        : []);
+
+    return [
+      ...wallpaperPart,
+      ...bannerPart,
+    ]
+      .filter(Boolean)
+      .join('\n');
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 00000000..b222799b
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,137 @@
+import {accumulateSum, empty, stitchArrays} from '../../util/sugar.js';
+
+function displayTrackSections(album) {
+  if (empty(album.trackSections)) {
+    return false;
+  }
+
+  if (album.trackSections.length > 1) {
+    return true;
+  }
+
+  if (!album.trackSections[0].isDefaultTrackSection) {
+    return true;
+  }
+
+  return false;
+}
+
+function displayTracks(album) {
+  if (empty(album.tracks)) {
+    return false;
+  }
+
+  return true;
+}
+
+function getDisplayMode(album) {
+  if (displayTrackSections(album)) {
+    return 'trackSections';
+  } else if (displayTracks(album)) {
+    return 'tracks';
+  } else {
+    return 'none';
+  }
+}
+
+export default {
+  contentDependencies: ['generateAlbumTrackListItem', 'generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    return {
+      displayMode: getDisplayMode(album),
+    };
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        relations.trackSectionHeadings =
+          album.trackSections.map(() =>
+            relation('generateContentHeading'));
+
+        relations.itemsByTrackSection =
+          album.trackSections.map(section =>
+            section.tracks.map(track =>
+              relation('generateAlbumTrackListItem', track, album)));
+
+        break;
+
+      case 'tracks':
+        relations.itemsByTrack =
+          album.tracks.map(track =>
+            relation('generateAlbumTrackListItem', track, album));
+        break;
+    }
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.displayMode = query.displayMode;
+    data.hasTrackNumbers = album.hasTrackNumbers;
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        data.trackSectionInfo =
+          album.trackSections.map(section => {
+            const info = {};
+
+            info.name = section.name;
+            info.duration = accumulateSum(section.tracks, track => track.duration);
+            info.durationApproximate = section.tracks.length > 1;
+
+            if (album.hasTrackNumbers) {
+              info.startIndex = section.startIndex;
+            }
+
+            return info;
+          });
+        break;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const listTag = (data.hasTrackNumbers ? 'ol' : 'ul');
+
+    switch (data.displayMode) {
+      case 'trackSections':
+        return html.tag('dl', {class: 'album-group-list'},
+          stitchArrays({
+            heading: relations.trackSectionHeadings,
+            items: relations.itemsByTrackSection,
+            info: data.trackSectionInfo,
+          }).map(({heading, items, info}) => [
+              heading.slots({
+                tag: 'dt',
+                title:
+                  language.$('trackList.section.withDuration', {
+                    section: info.name,
+                    duration:
+                      language.formatDuration(info.duration, {
+                        approximate: info.durationApproximate,
+                      }),
+                  }),
+              }),
+
+              html.tag('dd',
+                html.tag(listTag,
+                  data.hasTrackNumbers ? {start: info.startIndex + 1} : {},
+                  items)),
+            ]));
+
+      case 'tracks':
+        return html.tag(listTag, relations.itemsByTrack);
+
+      default:
+        return html.blank();
+    }
+  }
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
new file mode 100644
index 00000000..15aecba0
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -0,0 +1,72 @@
+import {compareArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkContribution',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.contributionLinks =
+      track.artistContribs
+        .map(contrib => relation('linkContribution', contrib));
+
+    relations.trackLink =
+      relation('linkTrack', track);
+
+    return relations;
+  },
+
+  data(track, album) {
+    const data = {};
+
+    data.duration = track.duration ?? 0;
+
+    if (track.color !== album.color) {
+      data.color = track.color;
+    }
+
+    data.showArtists =
+      !compareArrays(
+        track.artistContribs.map(c => c.who),
+        album.artistContribs.map(c => c.who),
+        {checkOrder: false});
+
+    return data;
+  },
+
+  generate(data, relations, {getColors, html, language}) {
+    let style;
+
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      style = `--primary-color: ${primary}`;
+    }
+
+    const parts = ['trackList.item.withDuration'];
+    const options = {};
+
+    options.duration =
+      language.formatDuration(data.duration);
+
+    options.track =
+      relations.trackLink
+        .slot('color', false);
+
+    if (data.showArtists) {
+      parts.push('withArtists');
+      options.by =
+        html.tag('span', {class: 'by'},
+          language.$('trackList.item.withArtists.by', {
+            artists: language.formatConjunctionList(relations.contributionLinks),
+          }));
+    }
+
+    return html.tag('li', {style},
+      language.formatString(parts.join('.'), options));
+  },
+};
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
new file mode 100644
index 00000000..d1ec3efe
--- /dev/null
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -0,0 +1,114 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+
+// TODO: Very awkward we have to duplicate this functionality in relations and data.
+function getGalleryThings(artist) {
+  const galleryThings = [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist];
+  sortAlbumsTracksChronologically(galleryThings, {latestFirst: true});
+  return galleryThings;
+}
+
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, artist) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    const galleryThings = getGalleryThings(artist);
+
+    relations.links =
+      galleryThings.map(thing =>
+        (thing.album
+          ? relation('linkTrack', thing)
+          : relation('linkAlbum', thing)));
+
+    relations.images =
+      galleryThings.map(thing =>
+        relation('image', thing.artTags));
+
+    return relations;
+  },
+
+  data(artist) {
+    const data = {};
+
+    data.name = artist.name;
+
+    const galleryThings = getGalleryThings(artist);
+
+    data.numArtworks = galleryThings.length;
+
+    data.names =
+      galleryThings.map(thing => thing.name);
+
+    data.paths =
+      galleryThings.map(thing =>
+        (thing.album
+          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
+          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('artistGalleryPage.title', {
+            artist: data.name,
+          }),
+
+        headingMode: 'static',
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('artistGalleryPage.infoLine', {
+              coverArts: language.countCoverArts(data.numArtworks, {
+                unit: true,
+              }),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              names: data.names,
+              images:
+                stitchArrays({
+                  image: relations.images,
+                  path: data.paths,
+                }).map(({image, path}) =>
+                    image.slot('path', path)),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+              currentExtra: 'gallery',
+            })
+            .content,
+      })
+  },
+}
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
new file mode 100644
index 00000000..1e7086ed
--- /dev/null
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -0,0 +1,213 @@
+import {
+  empty,
+  filterProperties,
+  stitchArrays,
+  unique,
+} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {
+      groupOrder: groupCategoryData.flatMap(category => category.groups),
+    }
+  },
+
+  query(sprawl, tracksAndAlbums) {
+    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
+    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+
+    const allAlbums = unique([
+      ...filteredAlbums,
+      ...filteredTracks.map(track => track.album),
+    ]);
+
+    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
+    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+
+    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
+    const groupToCountMap = new Map(mapTemplate);
+    const groupToDurationMap = new Map(mapTemplate);
+    const groupToDurationCountMap = new Map(mapTemplate);
+
+    for (const album of filteredAlbums) {
+      for (const group of album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+      }
+    }
+
+    for (const track of filteredTracks) {
+      for (const group of track.album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+        if (track.duration) {
+          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
+          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        }
+      }
+    }
+
+    const groupsSortedByCount =
+      allGroupsOrdered
+        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+
+    // The filter here ensures all displayed groups have at least some duration
+    // when sorting by duration.
+    const groupsSortedByDuration =
+      allGroupsOrdered
+        .filter(group => groupToDurationMap.get(group) > 0)
+        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+
+    const groupCountsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    const groupCountsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    return {
+      groupsSortedByCount,
+      groupsSortedByDuration,
+
+      groupCountsSortedByCount,
+      groupDurationsSortedByCount,
+      groupDurationsApproximateSortedByCount,
+
+      groupCountsSortedByDuration,
+      groupDurationsSortedByDuration,
+      groupDurationsApproximateSortedByDuration,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      groupLinksSortedByCount:
+        query.groupsSortedByCount
+          .map(group => relation('linkGroup', group)),
+
+      groupLinksSortedByDuration:
+        query.groupsSortedByDuration
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return filterProperties(query, [
+      'groupCountsSortedByCount',
+      'groupDurationsSortedByCount',
+      'groupDurationsApproximateSortedByCount',
+
+      'groupCountsSortedByDuration',
+      'groupDurationsSortedByDuration',
+      'groupDurationsApproximateSortedByDuration',
+    ]);
+  },
+
+  slots: {
+    title: {type: 'html'},
+    showBothColumns: {type: 'boolean'},
+    showSortButton: {type: 'boolean'},
+    visible: {type: 'boolean', default: true},
+
+    sort: {validate: v => v.is('count', 'duration')},
+    countUnit: {validate: v => v.is('tracks', 'artworks')},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+      return html.blank();
+    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+      return html.blank();
+    }
+
+    const getCounts = counts =>
+      counts.map(count => {
+        switch (slots.countUnit) {
+          case 'tracks': return language.countTracks(count, {unit: true});
+          case 'artworks': return language.countArtworks(count, {unit: true});
+        }
+      });
+
+    // We aren't displaying the "~" approximate symbol here for now.
+    // The general notion that these sums aren't going to be 100% accurate
+    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+    // line that's always displayed above this table.
+    const getDurations = (durations, approximate) =>
+      stitchArrays({
+        duration: durations,
+        approximate: approximate,
+      }).map(({duration}) => language.formatDuration(duration));
+
+    const topLevelClasses = [
+      'group-contributions-sorted-by-' + slots.sort,
+      slots.visible && 'visible',
+    ];
+
+    return html.tags([
+      html.tag('dt', {class: topLevelClasses},
+        (slots.showSortButton
+          ? language.$('artistPage.groupContributions.title.withSortButton', {
+              title: slots.title,
+              sort:
+                html.tag('a', {href: '#', class: 'group-contributions-sort-button'},
+                  (slots.sort === 'count'
+                    ? language.$('artistPage.groupContributions.title.sorting.count')
+                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
+            })
+          : slots.title)),
+
+      html.tag('dd', {class: topLevelClasses},
+        html.tag('ul', {class: 'group-contributions-table', role: 'list'},
+          (slots.sort === 'count'
+            ? stitchArrays({
+                group: relations.groupLinksSortedByCount,
+                count: getCounts(data.groupCountsSortedByCount),
+                duration: getDurations(data.groupDurationsSortedByCount, data.groupDurationsApproximateSortedByCount),
+              }).map(({group, count, duration}) =>
+                html.tag('li',
+                  html.tag('div', {class: 'group-contributions-row'}, [
+                    group,
+                    html.tag('span', {class: 'group-contributions-metrics'},
+                      // When sorting by count, duration details aren't necessarily
+                      // available for all items.
+                      (slots.showBothColumns && duration
+                        ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
+                        : language.$('artistPage.groupContributions.item.countAccent', {count}))),
+                  ])))
+            : stitchArrays({
+                group: relations.groupLinksSortedByDuration,
+                count: getCounts(data.groupCountsSortedByDuration),
+                duration: getDurations(data.groupDurationsSortedByDuration, data.groupDurationsApproximateSortedByCount),
+              }).map(({group, count, duration}) =>
+                html.tag('li',
+                  html.tag('div', {class: 'group-contributions-row'}, [
+                    group,
+                    html.tag('span', {class: 'group-contributions-metrics'},
+                      // Count details are always available, since they're just the
+                      // number of contributions directly. And duration details are
+                      // guaranteed for every item when sorting by duration.
+                      (slots.showBothColumns
+                        ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
+                        : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
+                  ])))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
new file mode 100644
index 00000000..7f79a609
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,308 @@
+import {empty, unique} from '../../util/sugar.js';
+import {getTotalDuration} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistGroupContributionsInfo',
+    'generateArtistInfoPageArtworksChunkedList',
+    'generateArtistInfoPageCommentaryChunkedList',
+    'generateArtistInfoPageFlashesChunkedList',
+    'generateArtistInfoPageTracksChunkedList',
+    'generateArtistNavLinks',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkArtistGallery',
+    'linkExternal',
+    'linkGroup',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  query(sprawl, artist) {
+    return {
+      // Even if an artist has served as both "artist" (compositional) and
+      // "contributor" (instruments, production, etc) on the same track, that
+      // track only counts as one unique contribution.
+      allTracks:
+        unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]),
+
+      // Artworks are different, though. We intentionally duplicate album data
+      // objects when the artist has contributed some combination of cover art,
+      // wallpaper, and banner - these each count as a unique contribution.
+      allArtworks: [
+        ...artist.albumsAsCoverArtist,
+        ...artist.albumsAsWallpaperArtist,
+        ...artist.albumsAsBannerArtist,
+        ...artist.tracksAsCoverArtist,
+      ],
+
+      // Banners and wallpapers don't show up in the artist gallery page, only
+      // cover art.
+      hasGallery:
+        !empty(artist.albumsAsCoverArtist) ||
+        !empty(artist.tracksAsCoverArtist),
+    };
+  },
+
+  relations(relation, query, sprawl, artist) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    if (artist.hasAvatar) {
+      relations.cover =
+        relation('generateCoverArtwork', []);
+    }
+
+    if (artist.contextNotes) {
+      const contextNotes = sections.contextNotes = {};
+      contextNotes.content = relation('transformContent', artist.contextNotes);
+    }
+
+    if (!empty(artist.urls)) {
+      const visit = sections.visit = {};
+      visit.externalLinks =
+        artist.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    if (!empty(query.allTracks)) {
+      const tracks = sections.tracks = {};
+      tracks.heading = relation('generateContentHeading');
+      tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist);
+      tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks);
+    }
+
+    if (!empty(query.allArtworks)) {
+      const artworks = sections.artworks = {};
+      artworks.heading = relation('generateContentHeading');
+      artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist);
+      artworks.groupInfo =
+        relation('generateArtistGroupContributionsInfo', query.allArtworks);
+
+      if (query.hasGallery) {
+        artworks.artistGalleryLink =
+          relation('linkArtistGallery', artist);
+      }
+    }
+
+    if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) {
+      const flashes = sections.flashes = {};
+      flashes.heading = relation('generateContentHeading');
+      flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist);
+    }
+
+    if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) {
+      const commentary = sections.commentary = {};
+      commentary.heading = relation('generateContentHeading');
+      commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist);
+    }
+
+    return relations;
+  },
+
+  data(query, sprawl, artist) {
+    const data = {};
+
+    data.name = artist.name;
+    data.directory = artist.directory;
+
+    if (artist.hasAvatar) {
+      data.avatarFileExtension = artist.avatarFileExtension;
+    }
+
+    data.totalTrackCount = query.allTracks.length;
+    data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true});
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                path: [
+                  'media.artistAvatar',
+                  data.directory,
+                  data.avatarFileExtension,
+                ],
+              })
+            : null),
+
+        mainContent: [
+          sec.contextNotes && [
+            html.tag('p', language.$('releaseInfo.note')),
+            html.tag('blockquote',
+              sec.contextNotes.content),
+          ],
+
+          sec.visit &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links: language.formatDisjunctionList(sec.visit.externalLinks),
+              })),
+
+          sec.artworks?.artistGalleryLink &&
+            html.tag('p',
+              language.$('artistPage.viewArtGallery', {
+                link: sec.artworks.artistGalleryLink.slots({
+                  content: language.$('artistPage.viewArtGallery.link'),
+                }),
+              })),
+
+          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
+            html.tag('p',
+              language.$('misc.jumpTo.withLinks', {
+                links: language.formatUnitList(
+                  [
+                    sec.tracks &&
+                      html.tag('a',
+                        {href: '#tracks'},
+                        language.$('artistPage.trackList.title')),
+
+                    sec.artworks &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$('artistPage.artList.title')),
+
+                    sec.flashes &&
+                      html.tag('a',
+                        {href: '#flashes'},
+                        language.$('artistPage.flashList.title')),
+
+                    sec.commentary &&
+                      html.tag('a',
+                        {href: '#commentary'},
+                        language.$('artistPage.commentaryList.title')),
+                  ].filter(Boolean)),
+              })),
+
+          sec.tracks && [
+            sec.tracks.heading
+              .slots({
+                tag: 'h2',
+                id: 'tracks',
+                title: language.$('artistPage.trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                language.$('artistPage.contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            sec.tracks.list
+              .slots({
+                groupInfo: [
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
+
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ],
+              }),
+          ],
+
+          sec.artworks && [
+            sec.artworks.heading
+              .slots({
+                tag: 'h2',
+                id: 'art',
+                title: language.$('artistPage.artList.title'),
+              }),
+
+            sec.artworks.artistGalleryLink &&
+              html.tag('p',
+                language.$('artistPage.viewArtGallery.orBrowseList', {
+                  link: sec.artworks.artistGalleryLink.slots({
+                    content: language.$('artistPage.viewArtGallery.link'),
+                  }),
+                })),
+
+            sec.artworks.list
+              .slots({
+                groupInfo:
+                  sec.artworks.groupInfo
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.artworks'),
+                      showBothColumns: false,
+                      sort: 'count',
+                      countUnit: 'artworks',
+                    }),
+              }),
+          ],
+
+          sec.flashes && [
+            sec.flashes.heading
+              .slots({
+                tag: 'h2',
+                id: 'flashes',
+                title: language.$('artistPage.flashList.title'),
+              }),
+
+            sec.flashes.list,
+          ],
+
+          sec.commentary && [
+            sec.commentary.heading
+              .slots({
+                tag: 'h2',
+                id: 'commentary',
+                title: language.$('artistPage.commentaryList.title'),
+              }),
+
+            sec.commentary.list,
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
new file mode 100644
index 00000000..656121c6
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -0,0 +1,188 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  sortAlbumsTracksChronologically,
+  sortEntryThingPairs,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    // TODO: Add and integrate wallpaper and banner date fields (#90)
+    // This will probably only happen once all artworks follow a standard
+    // shape (#70) and get their own sorting function. Read for more info:
+    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
+
+    const entries = [
+      ...artist.albumsAsCoverArtist.map(album => ({
+        thing: album,
+        entry: {
+          type: 'albumCover',
+          album: album,
+          date: album.coverArtDate,
+          contribs: album.coverArtistContribs,
+        },
+      })),
+
+      ...artist.albumsAsWallpaperArtist.map(album => ({
+        thing: album,
+        entry: {
+          type: 'albumWallpaper',
+          album: album,
+          date: album.coverArtDate,
+          contribs: album.wallpaperArtistContribs,
+        },
+      })),
+
+      ...artist.albumsAsBannerArtist.map(album => ({
+        thing: album,
+        entry: {
+          type: 'albumBanner',
+          album: album,
+          date: album.coverArtDate,
+          contribs: album.bannerArtistContribs,
+        },
+      })),
+
+      ...artist.tracksAsCoverArtist.map(track => ({
+        thing: track,
+        entry: {
+          type: 'trackCover',
+          album: track.album,
+          date: track.coverArtDate,
+          track: track,
+          contribs: track.coverArtistContribs,
+        },
+      })),
+    ];
+
+    sortEntryThingPairs(entries,
+      things => sortAlbumsTracksChronologically(things, {
+        getDate: thing => thing.coverArtDate,
+      }));
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['album', 'date']);
+
+    return {chunks};
+  },
+
+  relations(relation, query, artist) {
+    return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      albumLinks:
+        query.chunks.map(({album}) => relation('linkAlbum', album)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      itemTrackLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
+
+      itemOtherArtistLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      chunkDates:
+        query.chunks.map(({date}) => date),
+
+      itemTypes:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({type}) => type)),
+
+      itemContributions:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) =>
+            contribs
+              .find(({who}) => who === artist)
+              .what)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+
+          items: relations.items,
+          itemTrackLinks: relations.itemTrackLinks,
+          itemOtherArtistLinks: relations.itemOtherArtistLinks,
+          itemTypes: data.itemTypes,
+          itemContributions: data.itemContributions,
+        }).map(({
+            chunk,
+            albumLink,
+            date,
+
+            items,
+            itemTrackLinks,
+            itemOtherArtistLinks,
+            itemTypes,
+            itemContributions,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: itemTrackLinks,
+                  otherArtistLinks: itemOtherArtistLinks,
+                  type: itemTypes,
+                  contribution: itemContributions,
+                }).map(({
+                    item,
+                    trackLink,
+                    otherArtistLinks,
+                    type,
+                    contribution,
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      contribution,
+
+                      content:
+                        (type === 'trackCover'
+                          ? language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })
+                          : html.tag('i',
+                              language.$('artistPage.creditList.entry.album.' + {
+                                albumWallpaper: 'wallpaperArt',
+                                albumBanner: 'bannerArt',
+                                albumCover: 'coverArt',
+                              }[type]))),
+                    })),
+            })),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
new file mode 100644
index 00000000..eb9056cb
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -0,0 +1,81 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    mode: {
+      validate: v => v.is('flash', 'album'),
+    },
+
+    albumLink: {type: 'html'},
+    flashActLink: {type: 'html'},
+
+    date: {validate: v => v.isDate},
+    dateRangeStart: {validate: v => v.isDate},
+    dateRangeEnd: {validate: v => v.isDate},
+
+    duration: {validate: v => v.isDuration},
+    durationApproximate: {type: 'boolean'},
+
+    items: {type: 'html'},
+  },
+
+  generate(slots, {html, language}) {
+    let accentedLink;
+
+    accent: {
+      switch (slots.mode) {
+        case 'album': {
+          accentedLink = slots.albumLink;
+
+          const options = {album: accentedLink};
+          const parts = ['artistPage.creditList.album'];
+
+          if (slots.date) {
+            parts.push('withDate');
+            options.date = language.formatDate(slots.date);
+          }
+
+          if (slots.duration) {
+            parts.push('withDuration');
+            options.duration =
+              language.formatDuration(slots.duration, {
+                approximate: slots.durationApproximate,
+              });
+          }
+
+          accentedLink = language.formatString(parts.join('.'), options);
+          break;
+        }
+
+        case 'flash': {
+          accentedLink = slots.flashActLink;
+
+          const options = {act: accentedLink};
+          const parts = ['artistPage.creditList.flashAct'];
+
+          if (
+            slots.dateRangeStart &&
+            slots.dateRangeEnd &&
+            slots.dateRangeStart !== slots.dateRangeEnd
+          ) {
+            parts.push('withDateRange');
+            options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd);
+          } else if (slots.dateRangeStart || slots.date) {
+            parts.push('withDate');
+            options.date = language.formatDate(slots.dateFirst);
+          }
+
+          accentedLink = language.formatString(parts.join('.'), options);
+          break;
+        }
+      }
+    }
+
+    return html.tags([
+      html.tag('dt', accentedLink),
+      html.tag('dd',
+        html.tag('ul',
+          slots.items)),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
new file mode 100644
index 00000000..9004f18a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -0,0 +1,50 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    content: {type: 'html'},
+
+    otherArtistLinks: {validate: v => v.arrayOf(v.isHTML)},
+    contribution: {type: 'string'},
+    rerelease: {type: 'boolean'},
+  },
+
+  generate(slots, {html, language}) {
+    let accentedContent = slots.content;
+
+    accent: {
+      if (slots.rerelease) {
+        accentedContent =
+          language.$('artistPage.creditList.entry.rerelease', {
+            entry: accentedContent,
+          });
+
+        break accent;
+      }
+
+      const parts = ['artistPage.creditList.entry'];
+      const options = {entry: accentedContent};
+
+      if (slots.otherArtistLinks) {
+        parts.push('withArtists');
+        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
+      }
+
+      if (slots.contribution) {
+        parts.push('withContribution');
+        options.contribution = slots.contribution;
+      }
+
+      if (parts.length === 1) {
+        break accent;
+      }
+
+      accentedContent = language.formatString(parts.join('.'), options);
+    }
+
+    return (
+      html.tag('li',
+        {class: slots.rerelease && 'rerelease'},
+        accentedContent));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
new file mode 100644
index 00000000..a0334cbc
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -0,0 +1,16 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    groupInfo: {type: 'html'},
+    chunks: {type: 'html'},
+  },
+
+  generate(slots, {html}) {
+    return (
+      html.tag('dl', [
+        slots.groupInfo,
+        slots.chunks,
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
new file mode 100644
index 00000000..b96d6813
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -0,0 +1,111 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  sortAlbumsTracksChronologically,
+  sortEntryThingPairs,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    // TODO: Add and integrate wallpaper and banner date fields (#90)
+    // This will probably only happen once all artworks follow a standard
+    // shape (#70) and get their own sorting function. Read for more info:
+    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
+
+    const entries = [
+      ...artist.albumsAsCommentator.map(album => ({
+        thing: album,
+        entry: {
+          type: 'album',
+          album,
+        },
+      })),
+
+      ...artist.tracksAsCommentator.map(track => ({
+        thing: track,
+        entry: {
+          type: 'track',
+          album: track.album,
+          track,
+        },
+      })),
+    ];
+
+    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['album']);
+
+    return {chunks};
+  },
+
+  relations(relation, query) {
+    return {
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      albumLinks:
+        query.chunks.map(({album}) => relation('linkAlbum', album)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      itemTrackLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
+    };
+  },
+
+  data(query) {
+    return {
+      itemTypes:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({type}) => type)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tag('dl',
+      stitchArrays({
+        chunk: relations.chunks,
+        albumLink: relations.albumLinks,
+
+        items: relations.items,
+        itemTrackLinks: relations.itemTrackLinks,
+        itemTypes: data.itemTypes,
+      }).map(({chunk, albumLink, items, itemTrackLinks, itemTypes}) =>
+          chunk.slots({
+            mode: 'album',
+            albumLink,
+            items:
+              stitchArrays({
+                item: items,
+                trackLink: itemTrackLinks,
+                type: itemTypes,
+              }).map(({item, trackLink, type}) =>
+                item.slots({
+                  content:
+                    (type === 'album'
+                      ? html.tag('i',
+                          language.$('artistPage.creditList.entry.album.commentary'))
+                      : language.$('artistPage.creditList.entry.track', {
+                          track: trackLink,
+                        })),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
new file mode 100644
index 00000000..2f64483a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -0,0 +1,134 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  sortEntryThingPairs,
+  sortFlashesChronologically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    const entries = [
+      ...artist.flashesAsContributor.map(flash => ({
+        thing: flash,
+        entry: {
+          flash,
+          act: flash.act,
+          contribs: flash.contributorContribs,
+        },
+      })),
+    ];
+
+    sortEntryThingPairs(entries, sortFlashesChronologically);
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['act']);
+
+    return {chunks};
+  },
+
+  relations(relation, query) {
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    return {
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      actLinks:
+        query.chunks.map(({chunk}) =>
+          relation('linkFlash', chunk[0].flash)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      itemFlashLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({flash}) => relation('linkFlash', flash))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      actNames:
+        query.chunks.map(({act}) => act.name),
+
+      firstDates:
+        query.chunks.map(({chunk}) => chunk[0].flash.date ?? null),
+
+      lastDates:
+        query.chunks.map(({chunk}) => chunk[chunk.length - 1].flash.date ?? null),
+
+      itemContributions:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) =>
+            contribs
+              .find(({who}) => who === artist)
+              .what)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tag('dl',
+      stitchArrays({
+        chunk: relations.chunks,
+        actLink: relations.actLinks,
+        actName: data.actNames,
+        firstDate: data.firstDates,
+        lastDate: data.lastDates,
+
+        items: relations.items,
+        itemFlashLinks: relations.itemFlashLinks,
+        itemContributions: data.itemContributions,
+      }).map(({
+          chunk,
+          actLink,
+          actName,
+          firstDate,
+          lastDate,
+
+          items,
+          itemFlashLinks,
+          itemContributions,
+        }) =>
+          chunk.slots({
+            mode: 'flash',
+            flashActLink: actLink.slot('content', actName),
+            dateRangeStart: firstDate,
+            dateRangeEnd: lastDate,
+
+            items:
+              stitchArrays({
+                item: items,
+                flashLink: itemFlashLinks,
+                contribution: itemContributions,
+              }).map(({
+                  item,
+                  flashLink,
+                  contribution,
+                }) =>
+                  item.slots({
+                    contribution,
+
+                    content:
+                      language.$('artistPage.creditList.entry.flash', {
+                        flash: flashLink,
+                      }),
+                  })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
new file mode 100644
index 00000000..7667dea7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -0,0 +1,23 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['linkArtist'],
+
+  relations(relation, contribs, artist) {
+    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+
+    if (empty(otherArtistContribs)) {
+      return {};
+    }
+
+    const otherArtistLinks =
+      otherArtistContribs
+        .map(({who}) => relation('linkArtist', who));
+
+    return {otherArtistLinks};
+  },
+
+  generate(relations) {
+    return relations.otherArtistLinks ?? null;
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
new file mode 100644
index 00000000..d6ae9ae8
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -0,0 +1,185 @@
+import {accumulateSum, stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  sortAlbumsTracksChronologically,
+  sortEntryThingPairs,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['language'],
+
+  query(artist) {
+    const entries = [
+      ...artist.tracksAsArtist.map(track => ({
+        thing: track,
+        entry: {
+          track,
+          album: track.album,
+          date: track.date,
+          contribs: track.artistContribs,
+        },
+      })),
+
+      ...artist.tracksAsContributor.map(track => ({
+        thing: track,
+        entry: {
+          track,
+          date: track.date,
+          album: track.album,
+          contribs: track.contributorContribs,
+        },
+      })),
+    ];
+
+    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['album', 'date']);
+
+    return {chunks};
+  },
+
+  relations(relation, query, artist) {
+    return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      albumLinks:
+        query.chunks.map(({album}) => relation('linkAlbum', album)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      trackLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => relation('linkTrack', track))),
+
+      trackOtherArtistLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      chunkDates:
+        query.chunks.map(({date}) => date),
+
+      chunkDurations:
+        query.chunks.map(({chunk}) =>
+          accumulateSum(
+            chunk
+              .filter(({track}) => track.duration && track.originalReleaseTrack === null)
+              .map(({track}) => track.duration))),
+
+      chunkDurationsApproximate:
+        query.chunks.map(({chunk}) =>
+          chunk
+            .filter(({track}) => track.duration && track.originalReleaseTrack === null)
+            .length > 1),
+
+      trackDurations:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track.duration)),
+
+      trackContributions:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) =>
+            contribs
+              .find(({who}) => who === artist)
+              .what)),
+
+      trackRereleases:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track.originalReleaseTrack !== null)),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+          duration: data.chunkDurations,
+          durationApproximate: data.chunkDurationsApproximate,
+
+          items: relations.items,
+          trackLinks: relations.trackLinks,
+          trackOtherArtistLinks: relations.trackOtherArtistLinks,
+          trackDurations: data.trackDurations,
+          trackContributions: data.trackContributions,
+          trackRereleases: data.trackRereleases,
+        }).map(({
+            chunk,
+            albumLink,
+            date,
+            duration,
+            durationApproximate,
+
+            items,
+            trackLinks,
+            trackOtherArtistLinks,
+            trackDurations,
+            trackContributions,
+            trackRereleases,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+              duration,
+              durationApproximate,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: trackLinks,
+                  otherArtistLinks: trackOtherArtistLinks,
+                  duration: trackDurations,
+                  contribution: trackContributions,
+                  rerelease: trackRereleases,
+                }).map(({
+                    item,
+                    trackLink,
+                    otherArtistLinks,
+                    duration,
+                    contribution,
+                    rerelease,
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      contribution,
+                      rerelease,
+
+                      content:
+                        (duration
+                          ? language.$('artistPage.creditList.entry.track.withDuration', {
+                              track: trackLink,
+                              duration: language.formatDuration(duration),
+                            })
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })),
+                    })),
+            })),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
new file mode 100644
index 00000000..f78b45a1
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,100 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, artist) {
+    const relations = {};
+
+    relations.artistMainLink =
+      relation('linkArtist', artist);
+
+    relations.artistInfoLink =
+      relation('linkArtist', artist);
+
+    if (
+      !empty(artist.albumsAsCoverArtist) ||
+      !empty(artist.tracksAsCoverArtist)
+    ) {
+      relations.artistGalleryLink =
+        relation('linkArtistGallery', artist);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const infoLink =
+      relations.artistInfoLink?.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          relations.artistGalleryLink?.slots({
+            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+            content: language.$('misc.nav.gallery'),
+          }),
+        ]};
+
+    const mostAccentLinks = [
+      ...extraLinks,
+    ].filter(Boolean);
+
+    // Don't show the info accent link all on its own.
+    const allAccentLinks =
+      (empty(mostAccentLinks)
+        ? []
+        : [infoLink, ...mostAccentLinks]);
+
+    const accent =
+      (empty(allAccentLinks)
+        ? html.blank()
+        : `(${language.formatUnitList(allAccentLinks)})`);
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        accent,
+        html:
+          language.$('artistPage.nav.artist', {
+            artist: relations.artistMainLink,
+          }),
+      },
+    ];
+  },
+};
diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js
new file mode 100644
index 00000000..835140a8
--- /dev/null
+++ b/src/content/dependencies/generateBanner.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html', 'to'],
+
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
+    alt: {
+      type: 'string',
+    },
+  },
+
+  generate(slots, {html, to}) {
+    return (
+      html.tag('div', {id: 'banner'},
+        html.tag('img', {
+          src: to(...slots.path),
+          alt: slots.alt,
+          width: slots.dimensions?.[0] ?? 1100,
+          height: slots.dimensions?.[1] ?? 200,
+        })));
+  },
+};
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
new file mode 100644
index 00000000..15c0898c
--- /dev/null
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -0,0 +1,82 @@
+import {accumulateSum, empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    chronologyInfoSets: {
+      validate: v =>
+        v.arrayOf(
+          v.validateProperties({
+            headingString: v.isString,
+            contributions: v.arrayOf(v.validateProperties({
+              index: v.isCountingNumber,
+              artistLink: v.isHTML,
+              previousLink: v.isHTML,
+              nextLink: v.isHTML,
+            })),
+          })),
+    }
+  },
+
+  generate(slots, {html, language}) {
+    if (empty(slots.chronologyInfoSets)) {
+      return html.blank();
+    }
+
+    const totalContributionCount =
+      accumulateSum(
+        slots.chronologyInfoSets,
+        ({contributions}) => contributions.length);
+
+    if (totalContributionCount === 0) {
+      return html.blank();
+    }
+
+    if (totalContributionCount > 8) {
+      return html.tag('div', {class: 'chronology'},
+        language.$('misc.chronology.seeArtistPages'));
+    }
+
+    return html.tags(
+      slots.chronologyInfoSets.map(({
+        headingString,
+        contributions,
+      }) =>
+        contributions.map(({
+          index,
+          artistLink,
+          previousLink,
+          nextLink,
+        }) => {
+          const heading =
+            html.tag('span', {class: 'heading'},
+              language.$(headingString, {
+                index: language.formatIndex(index),
+                artist: artistLink,
+              }));
+
+          const navigation =
+            (previousLink || nextLink) &&
+              html.tag('span', {class: 'buttons'},
+                language.formatUnitList([
+                  previousLink?.slots({
+                    tooltip: true,
+                    color: false,
+                    content: language.$('misc.nav.previous'),
+                  }),
+
+                  nextLink?.slots({
+                    tooltip: true,
+                    color: false,
+                    content: language.$('misc.nav.next'),
+                  }),
+                ].filter(Boolean)));
+
+          return html.tag('div', {class: 'chronology'},
+            (navigation
+              ? language.$('misc.chronology.withNavigation', {heading, navigation})
+              : heading));
+        })));
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
new file mode 100644
index 00000000..fbc32599
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -0,0 +1,27 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+  ],
+
+  relations(relation, color) {
+    const relations = {};
+
+    if (color) {
+      relations.variables =
+        relation('generateColorStyleVariables', color);
+    }
+
+    return relations;
+  },
+
+  generate(relations) {
+    if (!relations.variables) return '';
+
+    return [
+      `:root {`,
+      // This is pretty hilariously hacky.
+      ...relations.variables.split(';').map(line => line + ';'),
+      `}`,
+    ].join('\n');
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
new file mode 100644
index 00000000..90346d8d
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -0,0 +1,33 @@
+export default {
+  extraDependencies: [
+    'getColors',
+  ],
+
+  data(color) {
+    return {color};
+  },
+
+  generate(data, {getColors}) {
+    if (!data.color) return [];
+
+    const {
+      primary,
+      dark,
+      dim,
+      dimGhost,
+      bg,
+      bgBlack,
+      shadow,
+    } = getColors(data.color);
+
+    return [
+      `--primary-color: ${primary}`,
+      `--dark-color: ${dark}`,
+      `--dim-color: ${dim}`,
+      `--dim-ghost-color: ${dimGhost}`,
+      `--bg-color: ${bg}`,
+      `--bg-black-color: ${bgBlack}`,
+      `--shadow-color: ${shadow}`,
+    ].join('; ');
+  },
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
new file mode 100644
index 00000000..ccaf1076
--- /dev/null
+++ b/src/content/dependencies/generateContentHeading.js
@@ -0,0 +1,19 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    title: {type: 'html'},
+    id: {type: 'string'},
+    tag: {type: 'string', default: 'p'},
+  },
+
+  generate(slots, {html}) {
+    return html.tag(slots.tag,
+      {
+        class: 'content-heading',
+        id: slots.id,
+        tabindex: '0',
+      },
+      slots.title);
+  }
+}
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
new file mode 100644
index 00000000..503bd120
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -0,0 +1,77 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['image', 'linkArtTag'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, artTags) {
+    const relations = {};
+
+    relations.image =
+      relation('image', artTags);
+
+    if (artTags) {
+      relations.tagLinks =
+        artTags
+          .filter(tag => !tag.isContentWarning)
+          .map(tag => relation('linkArtTag', tag));
+    } else {
+      relations.tagLinks = null;
+    }
+
+    return relations;
+  },
+
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    alt: {
+      type: 'string',
+    },
+
+    mode: {
+      validate: v => v.is('primary', 'thumbnail'),
+      default: 'primary',
+    },
+  },
+
+  generate(relations, slots, {html, language}) {
+    switch (slots.mode) {
+      case 'primary':
+        return html.tag('div', {id: 'cover-art-container'}, [
+          relations.image
+            .slots({
+              path: slots.path,
+              alt: slots.alt,
+              thumb: 'medium',
+              id: 'cover-art',
+              reveal: true,
+              link: true,
+              square: true,
+            }),
+
+          !empty(relations.tagLinks) &&
+            html.tag('p',
+              language.$('releaseInfo.artTags.inline', {
+                tags: language.formatUnitList(relations.tagLinks),
+              })),
+          ]);
+
+      case 'thumbnail':
+        return relations.image
+          .slots({
+            path: slots.path,
+            alt: slots.alt,
+            thumb: 'small',
+            reveal: false,
+            link: false,
+            square: true,
+          });
+
+      default:
+        return html.blank();
+    }
+  },
+};
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
new file mode 100644
index 00000000..2a2503ac
--- /dev/null
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -0,0 +1,54 @@
+import {empty, repeat, stitchArrays} from '../../util/sugar.js';
+import {getCarouselLayoutForNumberOfItems} from '../../util/wiki-data.js';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    images: {validate: v => v.arrayOf(v.isHTML)},
+    links: {validate: v => v.arrayOf(v.isHTML)},
+
+    lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)},
+  },
+
+  generate(slots, {html}) {
+    const stitched =
+      stitchArrays({
+        image: slots.images,
+        link: slots.links,
+      });
+
+    if (empty(stitched)) {
+      return;
+    }
+
+    const layout = getCarouselLayoutForNumberOfItems(stitched.length);
+
+    return html.tag('div',
+      {
+        class: 'carousel-container',
+        'data-carousel-rows': layout.rows,
+        'data-carousel-columns': layout.columns,
+      },
+      repeat(3, [
+        html.tag('div',
+          {class: 'carousel-grid', 'aria-hidden': 'true'},
+          stitched.map(({image, link}, index) =>
+            html.tag('div', {class: 'carousel-item'},
+              link.slots({
+                attributes: {tabindex: '-1'},
+                content:
+                  image.slots({
+                    thumb: 'small',
+                    square: true,
+                    lazy:
+                      (typeof slots.lazy === 'number'
+                        ? index >= slots.lazy
+                     : typeof slots.lazy === 'boolean'
+                        ? slots.lazy
+                        : false),
+                  }),
+              })))),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
new file mode 100644
index 00000000..20130c5e
--- /dev/null
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -0,0 +1,42 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    images: {validate: v => v.arrayOf(v.isHTML)},
+    links: {validate: v => v.arrayOf(v.isHTML)},
+    names: {validate: v => v.arrayOf(v.isHTML)},
+    info: {validate: v => v.arrayOf(v.isHTML)},
+
+    lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)},
+  },
+
+  generate(slots, {html}) {
+    return (
+      html.tag('div', {class: 'grid-listing'},
+        stitchArrays({
+          image: slots.images,
+          link: slots.links,
+          name: slots.names,
+          info: slots.info,
+        }).map(({image, link, name, info}, index) =>
+            link.slots({
+              attributes: {class: ['grid-item', 'box']},
+              content: [
+                image.slots({
+                  thumb: 'medium',
+                  square: true,
+                  lazy:
+                    (typeof slots.lazy === 'number'
+                      ? index >= slots.lazy
+                   : typeof slots.lazy === 'boolean'
+                      ? slots.lazy
+                      : false),
+                }),
+                html.tag('span', {[html.onlyIfContent]: true}, name),
+                html.tag('span', {[html.onlyIfContent]: true}, info),
+              ],
+            }))));
+  },
+};
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
new file mode 100644
index 00000000..b4970b17
--- /dev/null
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -0,0 +1,44 @@
+export default {
+  extraDependencies: [
+    'defaultLanguage',
+    'html',
+    'language',
+    'languages',
+    'pagePath',
+    'to',
+  ],
+
+  generate({
+    defaultLanguage,
+    html,
+    language,
+    languages,
+    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)));
+
+    return html.tag('div', {class: 'footer-localization-links'},
+      language.$('misc.uiLanguage', {
+        languages: links.join('\n'),
+      }));
+  },
+};
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
new file mode 100644
index 00000000..7b655805
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -0,0 +1,216 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+
+import {
+  filterItemsForCarousel,
+  getTotalDuration,
+  sortChronologically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateCoverCarousel',
+    'generateCoverGrid',
+    'generateGroupNavLinks',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({listingSpec, wikiInfo}) {
+    const sprawl = {};
+    sprawl.enableGroupUI = wikiInfo.enableGroupUI;
+
+    if (wikiInfo.enableListings && wikiInfo.enableGroupUI) {
+      sprawl.groupsByCategoryListing =
+        listingSpec
+          .find(l => l.directory === 'groups/by-category');
+    }
+
+    return sprawl;
+  },
+
+  relations(relation, sprawl, group) {
+    const relations = {};
+
+    const albums =
+      sortChronologically(group.albums.slice(), {latestFirst: true});
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateGroupNavLinks', group);
+
+    if (sprawl.enableGroupUI) {
+      relations.sidebar =
+        relation('generateGroupSidebar', group);
+    }
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', group.color);
+
+    if (sprawl.groupsByCategoryListing) {
+      relations.groupListingLink =
+        relation('linkListing', sprawl.groupsByCategoryListing);
+    }
+
+    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+
+    if (!empty(carouselAlbums)) {
+      relations.coverCarousel =
+        relation('generateCoverCarousel');
+
+      relations.carouselLinks =
+        carouselAlbums
+          .map(album => relation('linkAlbum', album));
+
+      relations.carouselImages =
+        carouselAlbums
+          .map(album => relation('image', album.artTags));
+    }
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.gridLinks =
+      albums
+        .map(album => relation('linkAlbum', album));
+
+    relations.gridImages =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.artTags)
+          : relation('image')));
+
+    return relations;
+  },
+
+  data(sprawl, group) {
+    const data = {};
+
+    data.name = group.name;
+
+    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
+    const tracks = albums.flatMap((album) => album.tracks);
+
+    data.numAlbums = albums.length;
+    data.numTracks = tracks.length;
+    data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true});
+
+    data.gridNames = albums.map(album => album.name);
+    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
+    data.gridNumTracks = albums.map(album => album.tracks.length);
+
+    data.gridPaths =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+          : null));
+
+    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+
+    if (!empty(group.featuredAlbums)) {
+      data.carouselPaths =
+        carouselAlbums.map(album =>
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title: language.$('groupGalleryPage.title', {group: data.name}),
+        headingMode: 'static',
+
+        colorStyleRules: [relations.colorStyleRules],
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.coverCarousel
+            ?.slots({
+              links: relations.carouselLinks,
+              images:
+                stitchArrays({
+                  image: relations.carouselImages,
+                  path: data.carouselPaths,
+                }).map(({image, path}) =>
+                    image.slot('path', path)),
+            }),
+
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('groupGalleryPage.infoLine', {
+              tracks: html.tag('b',
+                language.countTracks(data.numTracks, {
+                  unit: true,
+                })),
+              albums: html.tag('b',
+                language.countAlbums(data.numAlbums, {
+                  unit: true,
+                })),
+              time: html.tag('b',
+                language.formatDuration(data.totalDuration, {
+                  unit: true,
+                })),
+            })),
+
+          relations.groupListingLink &&
+            html.tag('p',
+              {class: 'quick-info'},
+              language.$('groupGalleryPage.anotherGroupLine', {
+                link:
+                  relations.groupListingLink
+                    .slot('content', language.$('groupGalleryPage.anotherGroupLine.link')),
+              })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.gridLinks,
+              names: data.gridNames,
+              images:
+                stitchArrays({
+                  image: relations.gridImages,
+                  path: data.gridPaths,
+                  name: data.gridNames,
+                }).map(({image, path, name}) =>
+                    image.slots({
+                      path,
+                      missingSourceContent:
+                        language.$('misc.albumGrid.noCoverArt', {
+                          album: name,
+                        }),
+                    })),
+              info:
+                stitchArrays({
+                  numTracks: data.gridNumTracks,
+                  duration: data.gridDurations,
+                }).map(({numTracks, duration}) =>
+                    language.$('misc.albumGrid.details', {
+                      tracks: language.countTracks(numTracks, {unit: true}),
+                      time: language.formatDuration(duration),
+                    })),
+            }),
+        ],
+
+        ...
+          relations.sidebar
+            ?.slot('currentExtra', 'gallery')
+            ?.content,
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.navLinks
+            .slot('currentExtra', 'gallery')
+            .content,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
new file mode 100644
index 00000000..3cffb748
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -0,0 +1,170 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateContentHeading',
+    'generateGroupNavLinks',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkExternal',
+    'linkGroupGallery',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableGroupUI: wikiInfo.enableGroupUI,
+    };
+  },
+
+  relations(relation, sprawl, group) {
+    const relations = {};
+    const sec = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateGroupNavLinks', group);
+
+    if (sprawl.enableGroupUI) {
+      relations.sidebar =
+        relation('generateGroupSidebar', group);
+    }
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', group.color);
+
+    sec.info = {};
+
+    if (!empty(group.urls)) {
+      sec.info.visitLinks =
+        group.urls
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (group.description) {
+      sec.info.description =
+        relation('transformContent', group.description);
+    }
+
+    if (!empty(group.albums)) {
+      sec.albums = {};
+
+      sec.albums.heading =
+        relation('generateContentHeading');
+
+      sec.albums.galleryLink =
+        relation('linkGroupGallery', group);
+
+      sec.albums.entries =
+        group.albums.map(album => {
+          const links = {};
+          links.albumLink = relation('linkAlbum', album);
+
+          const otherGroup = album.groups.find(g => g !== group);
+          if (otherGroup) {
+            links.groupLink = relation('linkGroup', otherGroup);
+          }
+
+          return links;
+        });
+    }
+
+    return relations;
+  },
+
+  data(sprawl, group) {
+    const data = {};
+
+    data.name = group.name;
+
+    if (!empty(group.albums)) {
+      data.albumYears =
+        group.albums
+          .map(album => album.date?.getFullYear());
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('groupInfoPage.title', {group: data.name}),
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+
+        mainContent: [
+          sec.info.visitLinks &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links: language.formatDisjunctionList(sec.info.visitLinks),
+              })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            sec.info.description
+              ?.slot('mode', 'multiline')),
+
+          sec.albums && [
+            sec.albums.heading
+              .slots({
+                tag: 'h2',
+                title: language.$('groupInfoPage.albumList.title'),
+              }),
+
+            html.tag('p',
+              language.$('groupInfoPage.viewAlbumGallery', {
+                link:
+                  sec.albums.galleryLink
+                    .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
+              })),
+
+            html.tag('ul',
+              sec.albums.entries.map(({albumLink, groupLink}, index) => {
+                // All these strings are really jank, and should probably
+                // be implemented with the same 'const parts = [], opts = {}'
+                // form used elsewhere...
+                const year = data.albumYears[index];
+                const item =
+                  (year
+                    ? language.$('groupInfoPage.albumList.item', {
+                        year,
+                        album: albumLink,
+                      })
+                    : language.$('groupInfoPage.albumList.item.withoutYear', {
+                        album: albumLink,
+                      }));
+
+                return html.tag('li',
+                  (groupLink
+                    ? language.$('groupInfoPage.albumList.item.withAccent', {
+                        item,
+                        accent:
+                          html.tag('span', {class: 'other-group-accent'},
+                            language.$('groupInfoPage.albumList.item.otherGroupAccent', {
+                              group:
+                                groupLink.slot('color', false),
+                            })),
+                      })
+                    : item));
+              })),
+          ],
+        ],
+
+        ...relations.sidebar?.content ?? {},
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
new file mode 100644
index 00000000..0b525363
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -0,0 +1,142 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkGroup',
+    'linkGroupGallery',
+    'linkGroupExtra',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData, wikiInfo}) {
+    return {
+      groupCategoryData,
+      enableGroupUI: wikiInfo.enableGroupUI,
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, group) {
+    if (!sprawl.enableGroupUI) {
+      return {};
+    }
+
+    const relations = {};
+
+    relations.mainLink =
+      relation('linkGroup', group);
+
+    relations.previousNextLinks =
+      relation('generatePreviousNextLinks');
+
+    const groups = sprawl.groupCategoryData
+      .flatMap(category => category.groups);
+
+    const index = groups.indexOf(group);
+
+    if (index > 0) {
+      relations.previousLink =
+        relation('linkGroupExtra', groups[index - 1]);
+    }
+
+    if (index < groups.length - 1) {
+      relations.nextLink =
+        relation('linkGroupExtra', groups[index + 1]);
+    }
+
+    relations.infoLink =
+      relation('linkGroup', group);
+
+    if (!empty(group.albums)) {
+      relations.galleryLink =
+        relation('linkGroupGallery', group);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableGroupUI: sprawl.enableGroupUI,
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableGroupUI) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const previousNextLinks =
+      (relations.previousLink || relations.nextLink) &&
+        relations.previousNextLinks.slots({
+          previousLink:
+            relations.previousLink
+              ?.slot('extra', slots.currentExtra)
+              ?.content
+            ?? null,
+          nextLink:
+            relations.nextLink
+              ?.slot('extra', slots.currentExtra)
+              ?.content
+            ?? null,
+        });
+
+    const previousNextPart =
+      previousNextLinks &&
+        language.formatUnitList(
+          previousNextLinks.content.filter(Boolean));
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const extraLinks = [
+      relations.galleryLink?.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      }),
+    ];
+
+    const extrasPart =
+      (empty(extraLinks)
+        ? ''
+        : language.formatUnitList([infoLink, ...extraLinks]));
+
+    const accent =
+      `(${[extrasPart, previousNextPart].filter(Boolean).join('; ')})`;
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        accent,
+        html:
+          language.$('groupPage.nav.group', {
+            group: relations.mainLink,
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
new file mode 100644
index 00000000..6baf37f4
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -0,0 +1,35 @@
+export default {
+  contentDependencies: ['generateGroupSidebarCategoryDetails'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {groupCategoryData};
+  },
+
+  relations(relation, sprawl, group) {
+    return {
+      categoryDetails:
+        sprawl.groupCategoryData.map(category =>
+          relation('generateGroupSidebarCategoryDetails', category, group)),
+    };
+  },
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(relations, slots, {html, language}) {
+    return {
+      leftSidebarContent: [
+        html.tag('h1',
+          language.$('groupSidebar.title')),
+
+        relations.categoryDetails
+          .map(details =>
+            details.slot('currentExtra', slots.currentExtra)),
+      ],
+    };
+  },
+};
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
new file mode 100644
index 00000000..ec707e39
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -0,0 +1,77 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, category) {
+    return {
+      colorVariables: relation('generateColorStyleVariables', category.color),
+
+      // Which of these is used depends on the currentExtra slot, so all
+      // available links are included here.
+      groupLinks: category.groups.map(group => {
+        const links = {};
+        links.info = relation('linkGroup', group);
+
+        if (!empty(group.albums)) {
+          links.gallery = relation('linkGroupGallery', group);
+        }
+
+        return links;
+      }),
+    };
+  },
+
+  data(category, group) {
+    const data = {};
+
+    data.name = category.name;
+    data.isCurrentCategory = category === group.category;
+
+    if (data.isCurrentCategory) {
+      data.currentGroupIndex = category.groups.indexOf(group);
+    }
+
+    return data;
+  },
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    return html.tag('details',
+      {
+        open: data.isCurrentCategory,
+        class: data.isCurrentCategory && 'current',
+      },
+      [
+        html.tag('summary',
+          {style: relations.colorVariables},
+          html.tag('span',
+            language.$('groupSidebar.groupList.category', {
+              category:
+                html.tag('span', {class: 'group-name'},
+                  data.name),
+            }))),
+
+        html.tag('ul',
+          relations.groupLinks.map((links, index) =>
+            html.tag('li',
+              {class: index === data.currentGroupIndex && 'current'},
+              language.$('groupSidebar.groupList.item', {
+                group:
+                  links[slots.currentExtra ?? 'info'] ??
+                  links.info,
+              })))),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js
new file mode 100644
index 00000000..e4a2f5c7
--- /dev/null
+++ b/src/content/dependencies/generateListingIndexList.js
@@ -0,0 +1,130 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['generateColorStyleVariables', 'linkListing'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({listingTargetSpec, wikiInfo}) {
+    return {listingTargetSpec, wikiInfo};
+  },
+
+  query(sprawl) {
+    const query = {};
+
+    const targetListings =
+      sprawl.listingTargetSpec
+        .map(({listings}) =>
+          listings
+            .filter(listing =>
+              !listing.featureFlag ||
+              sprawl.wikiInfo[listing.featureFlag]));
+
+    query.wikiColor = sprawl.wikiInfo.color;
+
+    query.targets =
+      sprawl.listingTargetSpec
+        .filter((target, index) => !empty(targetListings[index]));
+
+    query.targetListings =
+      targetListings
+        .filter(listings => !empty(listings))
+
+    return query;
+  },
+
+  relations(relation, query) {
+    return {
+      wikiColorVariables: relation('generateColorStyleVariables', query.wikiColor),
+
+      listingLinks:
+        query.targetListings
+          .map(listings =>
+            listings.map(listing => relation('linkListing', listing))),
+    };
+  },
+
+  data(query, sprawl, currentListing) {
+    const data = {};
+
+    data.targetStringsKeys =
+      query.targets
+        .map(({stringsKey}) => stringsKey);
+
+    data.listingStringsKeys =
+      query.targetListings
+        .map(listings =>
+          listings.map(({stringsKey}) => stringsKey));
+
+    if (currentListing) {
+      data.currentTargetIndex =
+        query.targets
+          .indexOf(currentListing.target);
+
+      data.currentListingIndex =
+        query.targetListings
+          .find(listings => listings.includes(currentListing))
+          .indexOf(currentListing);
+    }
+
+    return data;
+  },
+
+  slots: {
+    mode: {validate: v => v.is('content', 'sidebar')},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const listingLinkLists =
+      stitchArrays({
+        listingLinks: relations.listingLinks,
+        listingStringsKeys: data.listingStringsKeys,
+      }).map(({listingLinks, listingStringsKeys}, targetIndex) =>
+          html.tag('ul',
+            stitchArrays({
+              listingLink: listingLinks,
+              listingStringsKey: listingStringsKeys,
+            }).map(({listingLink, listingStringsKey}, listingIndex) =>
+                html.tag('li',
+                  {class:
+                    targetIndex === data.currentTargetIndex &&
+                    listingIndex === data.currentListingIndex &&
+                      'current'},
+                  listingLink
+                    .slot('content', language.$(`listingPage.${listingStringsKey}.title.short`))))));
+
+    const targetTitles =
+      data.targetStringsKeys
+        .map(stringsKey => language.$(`listingPage.target.${stringsKey}`));
+
+    switch (slots.mode) {
+      case 'sidebar':
+        return html.tags(
+          stitchArrays({
+            targetTitle: targetTitles,
+            listingLinkList: listingLinkLists,
+          }).map(({targetTitle, listingLinkList}, targetIndex) =>
+              html.tag('details',
+                {
+                  open: targetIndex === data.currentTargetIndex,
+                  class: targetIndex === data.currentTargetIndex && 'current',
+                },
+                [
+                  html.tag('summary', {style: relations.wikiColorVariables},
+                    html.tag('span', {class: 'group-name'}, targetTitle)),
+
+                  listingLinkList,
+                ])));
+
+      case 'content':
+        return (
+          html.tag('dl',
+            stitchArrays({
+              targetTitle: targetTitles,
+              listingLinkList: listingLinkLists,
+            }).map(({targetTitle, listingLinkList}) => [
+                html.tag('dt', {class: ['content-heading']}, targetTitle),
+                html.tag('dd', listingLinkList),
+              ])));
+    }
+  },
+};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
new file mode 100644
index 00000000..cab80a7f
--- /dev/null
+++ b/src/content/dependencies/generateListingPage.js
@@ -0,0 +1,142 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListingSidebar',
+    'generatePageLayout',
+    'linkListing',
+    'linkListingIndex',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  relations(relation, listing) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', listing);
+
+    relations.listingsIndexLink =
+      relation('linkListingIndex');
+
+    relations.chunkHeading =
+      relation('generateContentHeading');
+
+    if (listing.target.listings.length > 1) {
+      relations.sameTargetListingLinks =
+        listing.target.listings
+          .map(listing => relation('linkListing', listing));
+    }
+
+    if (!empty(listing.seeAlso)) {
+      relations.seeAlsoLinks =
+        listing.seeAlso
+          .map(listing => relation('linkListing', listing));
+    }
+
+    return relations;
+  },
+
+  data(listing) {
+    return {
+      stringsKey: listing.stringsKey,
+
+      targetStringsKey: listing.target.stringsKey,
+
+      sameTargetListingStringsKeys:
+        listing.target.listings
+          .map(listing => listing.stringsKey),
+
+      sameTargetListingsCurrentIndex:
+        listing.target.listings
+          .indexOf(listing),
+    };
+  },
+
+  slots: {
+    type: {validate: v => v.is('rows', 'chunks', 'custom')},
+
+    rows: {validate: v => v.arrayOf(v.isObject)},
+
+    chunkTitles: {validate: v => v.arrayOf(v.isObject)},
+    chunkRows: {validate: v => v.arrayOf(v.isObject)},
+
+    content: {type: 'html'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    return relations.layout.slots({
+      title: language.$(`listingPage.${data.stringsKey}.title`),
+      headingMode: 'sticky',
+
+      mainContent: [
+        relations.sameTargetListingLinks &&
+          html.tag('p',
+            language.$('listingPage.listingsFor', {
+              target: language.$(`listingPage.target.${data.targetStringsKey}`),
+              listings:
+                language.formatUnitList(
+                  stitchArrays({
+                    link: relations.sameTargetListingLinks,
+                    stringsKey: data.sameTargetListingStringsKeys,
+                  }).map(({link, stringsKey}, index) =>
+                      html.tag('span',
+                        {class: index === data.sameTargetListingsCurrentIndex && 'current'},
+                        link.slots({
+                          attributes: {class: 'nowrap'},
+                          content: language.$(`listingPage.${stringsKey}.title.short`),
+                        })))),
+            })),
+
+        relations.seeAlsoLinks &&
+          html.tag('p',
+            language.$('listingPage.seeAlso', {
+              listings: language.formatUnitList(relations.seeAlsoLinks),
+            })),
+
+        slots.type === 'rows' &&
+          html.tag('ul',
+            slots.rows.map(row =>
+              html.tag('li',
+                language.$(`listingPage.${data.stringsKey}.item`, row)))),
+
+        slots.type === 'chunks' &&
+          html.tag('dl',
+            stitchArrays({
+              title: slots.chunkTitles,
+              rows: slots.chunkRows,
+            }).map(({title, rows}) => [
+                relations.chunkHeading
+                  .clone()
+                  .slots({
+                    tag: 'dt',
+                    title:
+                      language.$(`listingPage.${data.stringsKey}.chunk.title`, title),
+                  }),
+
+                html.tag('dd',
+                  html.tag('ul',
+                    rows.map(row =>
+                      html.tag('li',
+                        language.$(`listingPage.${data.stringsKey}.chunk.item`, row))))),
+              ])),
+
+        slots.type === 'custom' &&
+          slots.content,
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.listingsIndexLink},
+        {auto: 'current'},
+      ],
+
+      ...relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
new file mode 100644
index 00000000..fe2a08fa
--- /dev/null
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['generateListingIndexList', 'linkListingIndex'],
+  extraDependencies: ['html'],
+
+  relations(relation, currentListing) {
+    return {
+      listingIndexLink: relation('linkListingIndex'),
+      listingIndexList: relation('generateListingIndexList', currentListing),
+    };
+  },
+
+  generate(relations, {html}) {
+    return {
+      leftSidebarContent: [
+        html.tag('h1', relations.listingIndexLink),
+        relations.listingIndexList.slot('mode', 'sidebar'),
+      ],
+    };
+  },
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
new file mode 100644
index 00000000..794b430b
--- /dev/null
+++ b/src/content/dependencies/generatePageLayout.js
@@ -0,0 +1,546 @@
+import {empty, openAggregate} from '../../util/sugar.js';
+
+function sidebarSlots(side) {
+  return {
+    // Content is a flat HTML array. It'll generate one sidebar section
+    // if specified.
+    [side + 'Content']: {type: 'html'},
+
+    // Multiple is an array of {content: (HTML)} objects. Each of these
+    // will generate one sidebar section.
+    [side + 'Multiple']: {
+      validate: v =>
+        v.arrayOf(
+          v.validateProperties({
+            content: v.isHTML,
+          })),
+    },
+
+    // Sticky mode controls which sidebar section(s), if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'none' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    [side + 'StickyMode']: {
+      validate: v => v.is('last', 'column', 'static'),
+    },
+
+    // Collapsing sidebars disappear when the viewport is sufficiently
+    // thin. (This is the default.) Override as false to make the sidebar
+    // stay visible in thinner viewports, where the page layout will be
+    // reflowed so the sidebar is as wide as the screen and appears below
+    // nav, above the main content.
+    [side + 'Collapse']: {type: 'boolean', default: true},
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    [side + 'Wide']: {type: 'boolean', defualt: false},
+  };
+}
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateFooterLocalizationLinks',
+    'generateStickyHeadingContainer',
+    'transformContent',
+  ],
+
+  extraDependencies: [
+    'cachebust',
+    'html',
+    'language',
+    'to',
+    'wikiData',
+  ],
+
+  sprawl({wikiInfo}) {
+    return {
+      footerContent: wikiInfo.footerContent,
+      wikiColor: wikiInfo.color,
+      wikiName: wikiInfo.nameShort,
+    };
+  },
+
+  data({wikiName}) {
+    return {
+      wikiName,
+    };
+  },
+
+  relations(relation, sprawl) {
+    const relations = {};
+
+    relations.footerLocalizationLinks =
+      relation('generateFooterLocalizationLinks');
+
+    relations.stickyHeadingContainer =
+      relation('generateStickyHeadingContainer');
+
+    relations.defaultFooterContent =
+      relation('transformContent', sprawl.footerContent);
+
+    relations.defaultColorStyleRules =
+      relation('generateColorStyleRules', sprawl.wikiColor);
+
+    return relations;
+  },
+
+  slots: {
+    title: {type: 'html'},
+    showWikiNameInTitle: {type: 'boolean', default: true},
+
+    cover: {type: 'html'},
+
+    socialEmbed: {type: 'html'},
+
+    colorStyleRules: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    additionalStyleRules: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    mainClasses: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    // Main
+
+    mainContent: {type: 'html'},
+
+    headingMode: {
+      validate: v => v.is('sticky', 'static'),
+      default: 'static',
+    },
+
+    // Sidebars
+
+    ...sidebarSlots('leftSidebar'),
+    ...sidebarSlots('rightSidebar'),
+
+    // Banner
+
+    banner: {type: 'html'},
+    bannerPosition: {
+      validate: v => v.is('top', 'bottom'),
+      default: 'top',
+    },
+
+    // Nav & Footer
+
+    navContent: {type: 'html'},
+    navBottomRowContent: {type: 'html'},
+
+    navLinkStyle: {
+      validate: v => v.is('hierarchical', 'index'),
+      default: 'index',
+    },
+
+    navLinks: {
+      validate: v =>
+        v.arrayOf(object => {
+          v.isObject(object);
+
+          const aggregate = openAggregate({message: `Errors validating navigation link`});
+
+          aggregate.call(v.validateProperties({
+            auto: () => true,
+            html: () => true,
+
+            path: () => true,
+            title: () => true,
+            accent: () => true,
+          }), object);
+
+          if (object.auto || object.html) {
+            if (object.auto && object.html) {
+              aggregate.push(new TypeError(`Don't specify both auto and html`));
+            } else if (object.auto) {
+              aggregate.call(v.is('home', 'current'), object.auto);
+            } else {
+              aggregate.call(v.isHTML, object.html);
+            }
+
+            if (object.path || object.title) {
+              aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
+            }
+          } else {
+            aggregate.call(v.validateProperties({
+              path: v.arrayOf(v.isString),
+              title: v.isString,
+            }), {
+              path: object.path,
+              title: object.title,
+            });
+          }
+
+          aggregate.close();
+
+          return true;
+        })
+    },
+
+    secondaryNav: {type: 'html'},
+
+    footerContent: {type: 'html'},
+  },
+
+  generate(data, relations, slots, {
+    cachebust,
+    html,
+    language,
+    to,
+  }) {
+    let titleHTML = null;
+
+    if (!html.isBlank(slots.title)) {
+      switch (slots.headingMode) {
+        case 'sticky':
+          titleHTML =
+            relations.stickyHeadingContainer.slots({
+              title: slots.title,
+              cover: slots.cover,
+            });
+          break;
+        case 'static':
+          titleHTML = html.tag('h1', slots.title);
+          break;
+      }
+    }
+
+    let footerContent = slots.footerContent;
+
+    if (html.isBlank(footerContent)) {
+      footerContent = relations.defaultFooterContent
+        .slot('mode', 'multiline');
+    }
+
+    const mainHTML =
+      html.tag('main', {
+        id: 'content',
+        class: slots.mainClasses,
+      }, [
+        titleHTML,
+
+        slots.cover,
+
+        html.tag('div',
+          {
+            [html.onlyIfContent]: true,
+            class: 'main-content-container',
+          },
+          slots.mainContent),
+      ]);
+
+    const footerHTML =
+      html.tag('footer',
+        {[html.onlyIfContent]: true, id: 'footer'},
+        [
+          html.tag('div',
+            {
+              [html.onlyIfContent]: true,
+              class: 'footer-content',
+            },
+            footerContent),
+
+          relations.footerLocalizationLinks,
+        ]);
+
+    const navHTML = html.tag('nav',
+      {
+        [html.onlyIfContent]: true,
+        id: 'header',
+        class: [
+          !empty(slots.navLinks) && 'nav-has-main-links',
+          !html.isBlank(slots.navContent) && 'nav-has-content',
+          !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row',
+        ],
+      },
+      [
+        html.tag('div',
+          {
+            [html.onlyIfContent]: true,
+            class: [
+              'nav-main-links',
+              'nav-links-' + slots.navLinkStyle,
+            ],
+          },
+          slots.navLinks?.map((cur, i) => {
+            let content;
+
+            if (cur.html) {
+              content = cur.html;
+            } else {
+              let title;
+              let href;
+
+              switch (cur.auto) {
+                case 'home':
+                  title = data.wikiName;
+                  href = to('localized.home');
+                  break;
+                case 'current':
+                  title = slots.title;
+                  href = '';
+                  break;
+                case null:
+                case undefined:
+                  title = cur.title;
+                  href = to(...cur.path);
+                  break;
+              }
+
+              content = html.tag('a',
+                {href},
+                title);
+            }
+
+            let className;
+
+            if (cur.auto === 'current') {
+              className = 'current';
+            } else if (
+              slots.navLinkStyle === 'hierarchical' &&
+              i === slots.navLinks.length - 1
+            ) {
+              className = 'current';
+            }
+
+            return html.tag('span',
+              {class: className},
+              [
+                html.tag('span',
+                  {class: 'nav-link-content'},
+                  content),
+                html.tag('span',
+                  {[html.onlyIfContent]: true, class: 'nav-link-accent'},
+                  cur.accent),
+              ]);
+          })),
+
+        html.tag('div',
+          {[html.onlyIfContent]: true, class: 'nav-bottom-row'},
+          slots.navBottomRowContent),
+
+        html.tag('div',
+          {[html.onlyIfContent]: true, class: 'nav-content'},
+          slots.navContent),
+      ])
+
+    const generateSidebarHTML = (side, id) => {
+      const content = slots[side + 'Content'];
+      const multiple = slots[side + 'Multiple'];
+      const stickyMode = slots[side + 'StickyMode'];
+      const wide = slots[side + 'Wide'];
+      const collapse = slots[side + 'Collapse'];
+
+      let sidebarClasses = [];
+      let sidebarContent = html.blank();
+
+      if (!html.isBlank(content)) {
+        sidebarClasses = ['sidebar'];
+        sidebarContent = content;
+      } else if (multiple) {
+        sidebarClasses = ['sidebar-multiple'];
+        sidebarContent =
+          multiple
+            .filter(Boolean)
+            .map(({content}) =>
+              html.tag('div',
+                {
+                  [html.onlyIfContent]: true,
+                  class: 'sidebar',
+                },
+                content));
+      }
+
+      return html.tag('div',
+        {
+          [html.onlyIfContent]: true,
+          id,
+          class: [
+            'sidebar-column',
+            wide && 'wide',
+            !collapse && 'no-hide',
+            stickyMode !== 'static' && `sticky-${stickyMode}`,
+            ...sidebarClasses,
+          ],
+        },
+        sidebarContent);
+    }
+
+    const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
+    const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
+    const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+
+    const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('a', {id: 'image-overlay-image-container'}, [
+          html.tag('img', {id: 'image-overlay-image'}),
+          html.tag('img', {id: 'image-overlay-image-thumb'}),
+        ]),
+        html.tag('div', {id: 'image-overlay-action-container'}, [
+          html.tag('div', {id: 'image-overlay-action-content-without-size'},
+            language.$('releaseInfo.viewOriginalFile', {
+              link: html.tag('a', {class: 'image-overlay-view-original'},
+                language.$('releaseInfo.viewOriginalFile.link')),
+            })),
+
+          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+            language.$('releaseInfo.viewOriginalFile.withSize', {
+              link: html.tag('a', {class: 'image-overlay-view-original'},
+                language.$('releaseInfo.viewOriginalFile.link')),
+              size: html.tag('span',
+                {[html.joinChildren]: ''},
+                [
+                  html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                    language.$('count.fileSize.kilobytes', {
+                      kilobytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
+                    })),
+                  html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                    language.$('count.fileSize.megabytes', {
+                      megabytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
+                    })),
+                ]),
+            }),
+
+            html.tag('span', {id: 'image-overlay-file-size-warning'},
+              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
+          ]),
+        ]),
+      ]));
+
+    const layoutHTML = [
+      navHTML,
+      slots.bannerPosition === 'top' && slots.banner,
+      slots.secondaryNav,
+      html.tag('div',
+        {
+          class: [
+            'layout-columns',
+            !collapseSidebars && 'vertical-when-thin',
+            (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
+            (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
+            !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
+            sidebarLeftHTML && 'has-sidebar-left',
+            sidebarRightHTML && 'has-sidebar-right',
+          ],
+        },
+        [
+          sidebarLeftHTML,
+          mainHTML,
+          sidebarRightHTML,
+        ]),
+      slots.bannerPosition === 'bottom' && slots.banner,
+      footerHTML,
+    ].filter(Boolean).join('\n');
+
+    return html.tags([
+      `<!DOCTYPE html>`,
+      html.tag('html',
+        {
+          lang: language.intlCode,
+          'data-language-code': language.code,
+
+          /*
+          'data-url-key': 'localized.' + pagePath[0],
+          ...Object.fromEntries(
+            pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
+          */
+
+          'data-rebase-localized': to('localized.root'),
+          'data-rebase-shared': to('shared.root'),
+          'data-rebase-media': to('media.root'),
+          'data-rebase-data': to('data.root'),
+        },
+        [
+          // developersComment,
+
+          html.tag('head', [
+            html.tag('title',
+              (slots.showWikiNameInTitle
+                ? language.formatString('misc.pageTitle.withWikiName', {
+                    title: slots.title,
+                    wikiName: data.wikiName,
+                  })
+                : language.formatString('misc.pageTitle', {
+                    title: slots.title,
+                  }))),
+
+            html.tag('meta', {charset: 'utf-8'}),
+            html.tag('meta', {
+              name: 'viewport',
+              content: 'width=device-width, initial-scale=1',
+            }),
+
+            /*
+            ...(
+              Object.entries(meta)
+                .filter(([key, value]) => value)
+                .map(([key, value]) => html.tag('meta', {[key]: value}))),
+
+            canonical &&
+              html.tag('link', {
+                rel: 'canonical',
+                href: canonical,
+              }),
+
+            ...(
+              localizedCanonical
+                .map(({lang, href}) => html.tag('link', {
+                  rel: 'alternate',
+                  hreflang: lang,
+                  href,
+                }))),
+
+            */
+
+            // slots.socialEmbed,
+
+            html.tag('link', {
+              rel: 'stylesheet',
+              href: to('shared.staticFile', 'site4.css', cachebust),
+            }),
+
+            html.tag('style', [
+              (empty(slots.colorStyleRules)
+                ? relations.defaultColorStyleRules
+                : slots.colorStyleRules),
+              slots.additionalStyleRules,
+            ]),
+
+            html.tag('script', {
+              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+            }),
+          ]),
+
+          html.tag('body',
+            // {style: body.style || ''},
+            [
+              html.tag('div', {id: 'page-container'}, [
+                // mainHTML && skippersHTML,
+                layoutHTML,
+              ]),
+
+              // infoCardHTML,
+              imageOverlayHTML,
+
+              html.tag('script', {
+                type: 'module',
+                src: to('shared.staticFile', 'client.js', cachebust),
+              }),
+            ]),
+        ])
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
new file mode 100644
index 00000000..6cffcef4
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLinks.js
@@ -0,0 +1,32 @@
+export default {
+  // Returns an array with the slotted previous and next links, prepared
+  // for inclusion in a page's navigation bar. Include with other links
+  // in the nav bar and then join them all as a unit list, for example.
+
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    previousLink: {type: 'html'},
+    nextLink: {type: 'html'},
+  },
+
+  generate(slots, {html, language}) {
+    return [
+      !html.isBlank(slots.previousLink) &&
+        slots.previousLink.slots({
+          tooltip: true,
+          color: false,
+          attributes: {id: 'previous-button'},
+          content: language.$('misc.nav.previous'),
+        }),
+
+      !html.isBlank(slots.nextLink) &&
+        slots.nextLink?.slots({
+          tooltip: true,
+          color: false,
+          attributes: {id: 'next-button'},
+          content: language.$('misc.nav.next'),
+        }),
+    ];
+  },
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
new file mode 100644
index 00000000..5a97e651
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -0,0 +1,42 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['linkContribution'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, contributions) {
+    if (empty(contributions)) {
+      return {};
+    }
+
+    return {
+      contributionLinks:
+        contributions
+          .slice(0, 4)
+          .map(contrib => relation('linkContribution', contrib)),
+    };
+  },
+
+  slots: {
+    stringKey: {type: 'string'},
+
+    showContribution: {type: 'boolean', default: true},
+    showIcons: {type: 'boolean', default: true},
+  },
+
+  generate(relations, slots, {html, language}) {
+    if (!relations.contributionLinks) {
+      return html.blank();
+    }
+
+    return language.$(slots.stringKey, {
+      artists:
+        language.formatConjunctionList(
+          relations.contributionLinks.map(link =>
+            link.slots({
+              showContribution: slots.showContribution,
+              showIcons: slots.showIcons,
+            }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
new file mode 100644
index 00000000..6fdfd428
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -0,0 +1,19 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {type: 'html'},
+
+    class: {
+      validate: v => v.oneOf(v.isString, v.arrayOf(v.isString)),
+    },
+  },
+
+  generate(slots, {html}) {
+    return html.tag('nav', {
+      [html.onlyIfContent]: true,
+      id: 'secondary-nav',
+      class: slots.class,
+    }, slots.content);
+  },
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
new file mode 100644
index 00000000..cbd477e0
--- /dev/null
+++ b/src/content/dependencies/generateStaticPage.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generatePageLayout', 'transformContent'],
+
+  relations(relation, staticPage) {
+    return {
+      layout: relation('generatePageLayout'),
+      content: relation('transformContent', staticPage.content),
+    };
+  },
+
+  data(staticPage) {
+    return {
+      name: staticPage.name,
+      stylesheet: staticPage.stylesheet,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        additionalStyleRules:
+          (data.stylesheet
+            ? [data.stylesheet]
+            : []),
+
+        mainClasses: ['long-content'],
+        mainContent: relations.content,
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
new file mode 100644
index 00000000..5ea10765
--- /dev/null
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -0,0 +1,33 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    title: {type: 'html'},
+    cover: {type: 'html'},
+  },
+
+  generate(slots, {html}) {
+    const hasCover = !html.isBlank(slots.cover);
+
+    return html.tag('div',
+      {
+        class: [
+          'content-sticky-heading-container',
+          hasCover && 'has-cover',
+        ],
+      },
+      [
+        html.tag('div', {class: 'content-sticky-heading-row'}, [
+          html.tag('h1', slots.title),
+
+          hasCover &&
+            html.tag('div', {class: 'content-sticky-heading-cover-container'},
+              html.tag('div', {class: 'content-sticky-heading-cover'},
+                slots.cover.slot('mode', 'thumbnail'))),
+        ]),
+
+        html.tag('div', {class: 'content-sticky-subheading-row'},
+          html.tag('h2', {class: 'content-sticky-subheading'})),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
new file mode 100644
index 00000000..757ad2d6
--- /dev/null
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations(relation, track) {
+    return {
+      coverArtwork:
+        relation('generateCoverArtwork',
+          (track.hasUniqueCoverArt
+            ? track.artTags
+            : track.album.artTags)),
+    };
+  },
+
+  data(track) {
+    return {
+      path:
+        (track.hasUniqueCoverArt
+          ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+          : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
+    };
+  },
+
+  generate(data, relations) {
+    return relations.coverArtwork
+      .slots({
+        path: data.path,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 00000000..c4596f14
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,662 @@
+import getChronologyRelations from '../util/getChronologyRelations.js';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
+} from '../../util/wiki-data.js';
+
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumNavAccent',
+    'generateAlbumSidebar',
+    'generateAlbumStyleRules',
+    'generateChronologyLinks',
+    'generateColorStyleRules',
+    'generateContentHeading',
+    'generatePageLayout',
+    'generateTrackCoverArtwork',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
+    'generateTrackReleaseInfo',
+    'linkAlbum',
+    'linkArtist',
+    'linkContribution',
+    'linkFlash',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  relations(relation, sprawl, track) {
+    const relations = {};
+    const sections = relations.sections = {};
+    const {album} = track;
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', track.album);
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules', track.color);
+
+    relations.artistChronologyContributions =
+      getChronologyRelations(track, {
+        contributions: [...track.artistContribs, ...track.contributorContribs],
+
+        linkArtist: artist => relation('linkArtist', artist),
+        linkThing: track => relation('linkTrack', track),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.tracksAsArtist,
+            ...artist.tracksAsContributor,
+          ]),
+      });
+
+    relations.coverArtistChronologyContributions =
+      getChronologyRelations(track, {
+        contributions: track.coverArtistContribs,
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ], {
+            getDate: albumOrTrack => albumOrTrack.coverArtDate,
+          }),
+      }),
+
+    relations.albumLink =
+      relation('linkAlbum', track.album);
+
+    relations.trackLink =
+      relation('linkTrack', track);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', track.album, track);
+
+    relations.chronologyLinks =
+      relation('generateChronologyLinks');
+
+    relations.sidebar =
+      relation('generateAlbumSidebar', track.album, track);
+
+    const additionalFilesSection = additionalFiles => ({
+      heading: relation('generateContentHeading'),
+      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
+    });
+
+    if (track.hasUniqueCoverArt || album.hasCoverArt) {
+      relations.cover =
+        relation('generateTrackCoverArtwork', track);
+    }
+
+    // Section: Release info
+
+    relations.releaseInfo =
+      relation('generateTrackReleaseInfo', track);
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (!empty(track.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', track.additionalFiles);
+    }
+
+    // Section: Other releases
+
+    if (!empty(track.otherReleases)) {
+      const otherReleases = sections.otherReleases = {};
+
+      otherReleases.heading =
+        relation('generateContentHeading');
+
+      otherReleases.items =
+        track.otherReleases.map(track => ({
+          trackLink: relation('linkTrack', track),
+          albumLink: relation('linkAlbum', track.album),
+        }));
+    }
+
+    // Section: Contributors
+
+    if (!empty(track.contributorContribs)) {
+      const contributors = sections.contributors = {};
+
+      contributors.heading =
+        relation('generateContentHeading');
+
+      contributors.contributionLinks =
+        track.contributorContribs
+          .map(contrib => relation('linkContribution', contrib));
+    }
+
+    // Section: Referenced tracks
+
+    if (!empty(track.referencedTracks)) {
+      const references = sections.references = {};
+
+      references.heading =
+        relation('generateContentHeading');
+
+      references.list =
+        relation('generateTrackList', track.referencedTracks);
+    }
+
+    // Section: Tracks that reference
+
+    if (!empty(track.referencedByTracks)) {
+      const referencedBy = sections.referencedBy = {};
+
+      referencedBy.heading =
+        relation('generateContentHeading');
+
+      referencedBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.referencedByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Sampled tracks
+
+    if (!empty(track.sampledTracks)) {
+      const samples = sections.samples = {};
+
+      samples.heading =
+        relation('generateContentHeading');
+
+      samples.list =
+        relation('generateTrackList', track.sampledTracks);
+    }
+
+    // Section: Tracks that sample
+
+    if (!empty(track.sampledByTracks)) {
+      const sampledBy = sections.sampledBy = {};
+
+      sampledBy.heading =
+        relation('generateContentHeading');
+
+      sampledBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.sampledByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Flashes that feature
+
+    if (sprawl.enableFlashesAndGames) {
+      const sortedFeatures =
+        sortFlashesChronologically(
+          [track, ...track.otherReleases].flatMap(track =>
+            track.featuredInFlashes.map(flash => ({
+              // These aren't going to be exposed directly, they're processed
+              // into the appropriate relations after this sort.
+              flash, track,
+
+              // These properties are only used for the sort.
+              act: flash.act,
+              date: flash.date,
+            }))));
+
+      if (!empty(sortedFeatures)) {
+        const flashesThatFeature = sections.flashesThatFeature = {};
+
+        flashesThatFeature.heading =
+          relation('generateContentHeading');
+
+        flashesThatFeature.entries =
+          sortedFeatures.map(({flash, track: directlyFeaturedTrack}) =>
+            (directlyFeaturedTrack === track
+              ? {
+                  flashLink: relation('linkFlash', flash),
+                }
+              : {
+                  flashLink: relation('linkFlash', flash),
+                  trackLink: relation('linkTrack', directlyFeaturedTrack),
+                }));
+      }
+    }
+
+    // Section: Lyrics
+
+    if (track.lyrics) {
+      const lyrics = sections.lyrics = {};
+
+      lyrics.heading =
+        relation('generateContentHeading');
+
+      lyrics.content =
+        relation('transformContent', track.lyrics);
+    }
+
+    // Sections: Sheet music files, MIDI/proejct files, additional files
+
+    if (!empty(track.sheetMusicFiles)) {
+      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
+    }
+
+    if (!empty(track.midiProjectFiles)) {
+      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
+    }
+
+    if (!empty(track.additionalFiles)) {
+      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (track.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', track.commentary);
+    }
+
+    return relations;
+  },
+
+  data(sprawl, track) {
+    return {
+      name: track.name,
+
+      hasTrackNumbers: track.album.hasTrackNumbers,
+      trackNumber: track.album.tracks.indexOf(track) + 1,
+
+      numAdditionalFiles: track.additionalFiles.length,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('trackPage.title', {track: data.name}),
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.trackCover'),
+              })
+            : null),
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {
+              [html.onlyIfContent]: true,
+              [html.joinChildren]: '<br>',
+            },
+            [
+              sec.sheetMusicFiles &&
+                language.$('releaseInfo.sheetMusicFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#sheet-music-files'},
+                    language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+                }),
+
+              sec.midiProjectFiles &&
+                language.$('releaseInfo.midiProjectFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+                }),
+
+              sec.additionalFiles &&
+                sec.extra.additionalFilesShortcut,
+
+              sec.artistCommentary &&
+                language.$('releaseInfo.readCommentary', {
+                  link: html.tag('a',
+                    {href: '#artist-commentary'},
+                    language.$('releaseInfo.readCommentary.link')),
+                }),
+            ]),
+
+          sec.otherReleases && [
+            sec.otherReleases.heading
+              .slots({
+                id: 'also-released-as',
+                title: language.$('releaseInfo.alsoReleasedAs'),
+              }),
+
+            html.tag('ul',
+              sec.otherReleases.items.map(({trackLink, albumLink}) =>
+                html.tag('li',
+                  language.$('releaseInfo.alsoReleasedAs.item', {
+                    track: trackLink,
+                    album: albumLink,
+                  })))),
+          ],
+
+          sec.contributors && [
+            sec.contributors.heading
+              .slots({
+                id: 'contributors',
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            html.tag('ul',
+              sec.contributors.contributionLinks.map(contributionLink =>
+                html.tag('li',
+                  contributionLink
+                    .slots({
+                      showIcons: true,
+                      showContribution: true,
+                    })))),
+          ],
+
+          sec.references && [
+            sec.references.heading
+              .slots({
+                id: 'references',
+                title:
+                  language.$('releaseInfo.tracksReferenced', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.references.list,
+          ],
+
+          sec.referencedBy && [
+            sec.referencedBy.heading
+              .slots({
+                id: 'referenced-by',
+                title:
+                  language.$('releaseInfo.tracksThatReference', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.referencedBy.list,
+          ],
+
+          sec.samples && [
+            sec.samples.heading
+              .slots({
+                id: 'samples',
+                title:
+                  language.$('releaseInfo.tracksSampled', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.samples.list,
+          ],
+
+          sec.sampledBy && [
+            sec.sampledBy.heading
+              .slots({
+                id: 'referenced-by',
+                title:
+                  language.$('releaseInfo.tracksThatSample', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.sampledBy.list,
+          ],
+
+          sec.flashesThatFeature && [
+            sec.flashesThatFeature.heading
+              .slots({
+                id: 'featured-in',
+                title:
+                  language.$('releaseInfo.flashesThatFeature', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
+              (trackLink
+                ? html.tag('li', {class: 'rerelease'},
+                    language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
+                      flash: flashLink,
+                      track: trackLink,
+                    }))
+                : html.tag('li',
+                    language.$('releaseInfo.flashesThatFeature.item', {
+                      flash: flashLink,
+                    }))))),
+          ],
+
+          sec.lyrics && [
+            sec.lyrics.heading
+              .slots({
+                id: 'lyrics',
+                title: language.$('releaseInfo.lyrics'),
+              }),
+
+            html.tag('blockquote',
+              sec.lyrics.content
+                .slot('mode', 'lyrics')),
+          ],
+
+          sec.sheetMusicFiles && [
+            sec.sheetMusicFiles.heading
+              .slots({
+                id: 'sheet-music-files',
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+              }),
+
+            sec.sheetMusicFiles.list,
+          ],
+
+          sec.midiProjectFiles && [
+            sec.midiProjectFiles.heading
+              .slots({
+                id: 'midi-project-files',
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
+              }),
+
+            sec.midiProjectFiles.list,
+          ],
+
+          sec.additionalFiles && [
+            sec.additionalFiles.heading
+              .slots({
+                id: 'additional-files',
+                title:
+                  language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles:
+                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                  }),
+              }),
+
+            sec.additionalFiles.list,
+          ],
+
+          sec.artistCommentary && [
+            sec.artistCommentary.heading
+              .slots({
+                id: 'artist-commentary',
+                title: language.$('releaseInfo.artistCommentary')
+              }),
+
+            html.tag('blockquote',
+              sec.artistCommentary.content
+                .slot('mode', 'multiline')),
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.albumLink},
+          {
+            html:
+              (data.hasTrackNumbers
+                ? language.$('trackPage.nav.track.withNumber', {
+                    number: data.trackNumber,
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })
+                : language.$('trackPage.nav.track', {
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })),
+          },
+        ],
+
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.track',
+                contributions: relations.artistChronologyContributions,
+              },
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        ...relations.sidebar,
+      });
+  },
+};
+
+/*
+  const data = {
+    type: 'data',
+    path: ['track', track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
+
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasArtists = !empty(track.artistContribs);
+    const hasCoverArtists = !empty(track.coverArtistContribs);
+    const getArtistString = (contribs) =>
+      _getArtistString(contribs, {
+        // We don't want to put actual HTML tags in social embeds (sadly
+        // they don't get parsed and displayed, generally speaking), so
+        // override the link argument so that artist "links" just show
+        // their names.
+        link: {artist: (artist) => artist.name},
+      });
+    if (!hasArtists && !hasCoverArtists) return '';
+    return language.formatString(
+      'trackPage.socialEmbed.body' +
+        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
+          .filter(Boolean)
+          .join(''),
+      Object.fromEntries(
+        [
+          hasArtists && ['artists', getArtistString(track.artistContribs)],
+          hasCoverArtists && [
+            'coverArtists',
+            getArtistString(track.coverArtistContribs),
+          ],
+        ].filter(Boolean)
+      )
+    );
+  };
+
+  const page = {
+    page: () => {
+      return {
+        title: language.$('trackPage.title', {track: track.name}),
+        stylesheet: getAlbumStylesheet(album, {to}),
+
+        themeColor: track.color,
+        theme:
+          getThemeString(track.color, {
+            additionalVariables: [
+              `--album-directory: ${album.directory}`,
+              `--track-directory: ${track.directory}`,
+            ]
+          }),
+
+        socialEmbed: {
+          heading: language.$('trackPage.socialEmbed.heading', {
+            album: track.album.name,
+          }),
+          headingLink: absoluteTo('localized.album', album.directory),
+          title: language.$('trackPage.socialEmbed.title', {
+            track: track.name,
+          }),
+          description: getSocialEmbedDescription({getArtistString, language}),
+          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
+          color: track.color,
+        },
+
+        secondaryNav: generateAlbumSecondaryNav(album, track, {
+          getLinkThemeString,
+          html,
+          language,
+          link,
+        }),
+      };
+    },
+  };
+*/
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
new file mode 100644
index 00000000..d0f14618
--- /dev/null
+++ b/src/content/dependencies/generateTrackList.js
@@ -0,0 +1,49 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['linkTrack', 'linkContribution'],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    return {
+      items: tracks.map(track => ({
+        trackLink:
+          relation('linkTrack', track),
+
+        contributionLinks:
+          track.artistContribs
+            .map(contrib => relation('linkContribution', contrib)),
+      })),
+    };
+  },
+
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+  },
+
+  generate(relations, slots, {html, language}) {
+    return html.tag('ul',
+      relations.items.map(({trackLink, contributionLinks}) =>
+        html.tag('li',
+          language.$('trackList.item.withArtists', {
+            track: trackLink,
+            by:
+              html.tag('span', {class: 'by'},
+                language.$('trackList.item.withArtists.by', {
+                  artists:
+                    language.formatConjunctionList(
+                      contributionLinks.map(link =>
+                        link.slots({
+                          showContribution: slots.showContribution,
+                          showIcons: slots.showIcons,
+                        }))),
+                })),
+          }))));
+  },
+};
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
new file mode 100644
index 00000000..1f1ebef8
--- /dev/null
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -0,0 +1,53 @@
+import {empty} from '../../util/sugar.js';
+
+import groupTracksByGroup from '../util/groupTracksByGroup.js';
+
+export default {
+  contentDependencies: ['generateTrackList', 'linkGroup'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks, groups) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    if (empty(groups)) {
+      return {
+        flatList:
+          relation('generateTrackList', tracks),
+      };
+    }
+
+    const lists = groupTracksByGroup(tracks, groups);
+
+    return {
+      groupedLists:
+        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
+          ...(groupOrOther === 'other'
+                ? {other: true}
+                : {groupLink: relation('linkGroup', groupOrOther)}),
+
+          list:
+            relation('generateTrackList', tracks),
+        })),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    if (relations.flatList) {
+      return relations.flatList;
+    }
+
+    return html.tag('dl',
+      relations.groupedLists.map(({other, groupLink, list}) => [
+        html.tag('dt',
+          (other
+            ? language.$('trackList.group.fromOther')
+            : language.$('trackList.group', {
+                group: groupLink
+              }))),
+
+        html.tag('dd', list),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
new file mode 100644
index 00000000..2ac20388
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -0,0 +1,87 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.artistContributionLinks =
+      relation('generateReleaseInfoContributionsLine', track.artistContribs);
+
+    if (track.hasUniqueCoverArt) {
+      relations.coverArtistContributionsLine =
+        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
+    }
+
+    if (!empty(track.urls)) {
+      relations.externalLinks =
+        track.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    return relations;
+  },
+
+  data(track) {
+    const data = {};
+
+    data.name = track.name;
+    data.date = track.date;
+    data.duration = track.duration;
+
+    if (
+      track.hasUniqueCoverArt &&
+      track.coverArtDate &&
+      +track.coverArtDate !== +track.date
+    ) {
+      data.coverArtDate = track.coverArtDate;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tags([
+      html.tag('p', {
+        [html.onlyIfContent]: true,
+        [html.joinChildren]: html.tag('br'),
+      }, [
+        relations.artistContributionLinks
+          .slots({stringKey: 'releaseInfo.by'}),
+
+        relations.coverArtistContributionsLine
+          ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
+
+        data.date &&
+          language.$('releaseInfo.released', {
+            date: language.formatDate(data.date),
+          }),
+
+        data.coverArtDate &&
+          language.$('releaseInfo.artReleased', {
+            date: language.formatDate(data.coverArtDate),
+          }),
+
+        data.duration &&
+          language.$('releaseInfo.duration', {
+            duration: language.formatDuration(data.duration),
+          }),
+      ]),
+
+      html.tag('p',
+        (relations.externalLinks
+          ? language.$('releaseInfo.listenOn', {
+              links: language.formatDisjunctionList(relations.externalLinks),
+            })
+          : language.$('releaseInfo.listenOn.noLinks', {
+              name: html.tag('i', data.name),
+            }))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
new file mode 100644
index 00000000..2fbe1188
--- /dev/null
+++ b/src/content/dependencies/image.js
@@ -0,0 +1,204 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'getSizeOfImageFile',
+    'html',
+    'language',
+    'thumb',
+    'to',
+  ],
+
+  data(artTags) {
+    const data = {};
+
+    if (artTags) {
+      data.contentWarnings =
+        artTags
+          .filter(tag => tag.isContentWarning)
+          .map(tag => tag.name);
+    } else {
+      data.contentWarnings = null;
+    }
+
+    return data;
+  },
+
+  slots: {
+    src: {type: 'string'},
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    thumb: {type: 'string'},
+
+    reveal: {type: 'boolean', default: true},
+    link: {type: 'boolean', default: false},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
+    id: {type: 'string'},
+    class: {type: 'string'},
+    alt: {type: 'string'},
+    width: {type: 'number'},
+    height: {type: 'number'},
+
+    missingSourceContent: {type: 'html'},
+  },
+
+  generate(data, slots, {
+    getSizeOfImageFile,
+    html,
+    language,
+    thumb,
+    to,
+  }) {
+    let originalSrc;
+
+    if (slots.src) {
+      originalSrc = slots.src;
+    } else if (!empty(slots.path)) {
+      originalSrc = to(...slots.path);
+    } else {
+      originalSrc = '';
+    }
+
+    const thumbSrc =
+      originalSrc &&
+        (slots.thumb
+          ? thumb[slots.thumb](originalSrc)
+          : originalSrc);
+
+    const willLink = typeof slots.link === 'string' || slots.link;
+
+    const willReveal =
+      slots.reveal &&
+      originalSrc &&
+      !empty(data.contentWarnings);
+
+    const willSquare = slots.square;
+
+    const idOnImg = willLink ? null : slots.id;
+    const idOnLink = willLink ? slots.id : null;
+    const classOnImg = willLink ? null : slots.class;
+    const classOnLink = willLink ? slots.class : null;
+
+    if (!originalSrc) {
+      return prepare(
+        html.tag('div', {class: 'image-text-area'},
+          slots.missingSourceContent));
+    }
+
+    let fileSize = null;
+    if (willLink) {
+      const mediaRoot = to('media.root');
+      if (originalSrc.startsWith(mediaRoot)) {
+        fileSize =
+          getSizeOfImageFile(
+            originalSrc
+              .slice(mediaRoot.length)
+              .replace(/^\//, ''));
+      }
+    }
+
+    let reveal = null;
+    if (willReveal) {
+      reveal = [
+        language.$('misc.contentWarnings', {
+          warnings: language.formatUnitList(data.contentWarnings),
+        }),
+        html.tag('br'),
+        html.tag('span', {class: 'reveal-interaction'},
+          language.$('misc.contentWarnings.reveal')),
+      ];
+    }
+
+    const imgAttributes = {
+      id: idOnImg,
+      class: classOnImg,
+      alt: slots.alt,
+      width: slots.width,
+      height: slots.height,
+      'data-original-size': fileSize,
+    };
+
+    const nonlazyHTML =
+      originalSrc &&
+        prepare(
+          html.tag('img', {
+            ...imgAttributes,
+            src: thumbSrc,
+          }));
+
+    if (slots.lazy) {
+      return html.tags([
+        html.tag('noscript', nonlazyHTML),
+        prepare(
+          html.tag('img',
+            {
+              ...imgAttributes,
+              class: 'lazy',
+              'data-original': thumbSrc,
+            }),
+          true),
+      ]);
+    }
+
+    return nonlazyHTML;
+
+    function prepare(content, hide = false) {
+      let wrapped = content;
+
+      wrapped =
+        html.tag('div', {class: 'image-container'},
+          html.tag('div', {class: 'image-inner-area'},
+            wrapped));
+
+      if (willReveal) {
+        wrapped =
+          html.tag('div', {class: 'reveal'}, [
+            wrapped,
+            html.tag('span', {class: 'reveal-text-container'},
+              html.tag('span', {class: 'reveal-text'},
+                reveal)),
+          ]);
+      }
+
+      if (willSquare) {
+        wrapped =
+          html.tag('div',
+            {
+              class: [
+                'square',
+                hide && !willLink && 'js-hide'
+              ],
+            },
+
+            html.tag('div', {class: 'square-content'},
+              wrapped));
+      }
+
+      if (willLink) {
+        wrapped = html.tag('a',
+          {
+            id: idOnLink,
+            class: [
+              'box',
+              'image-link',
+              hide && 'js-hide',
+              classOnLink,
+            ],
+
+            href:
+              (typeof slots.link === 'string'
+                ? slots.link
+                : originalSrc),
+          },
+          wrapped);
+      }
+
+      return wrapped;
+    }
+  },
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
new file mode 100644
index 00000000..36cd27fc
--- /dev/null
+++ b/src/content/dependencies/index.js
@@ -0,0 +1,255 @@
+import chokidar from 'chokidar';
+import {ESLint} from 'eslint';
+
+import EventEmitter from 'node:events';
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import contentFunction, {ContentFunctionSpecError} from '../../content-function.js';
+import {color, logWarn} from '../../util/cli.js';
+import {annotateFunction} from '../../util/sugar.js';
+
+function cachebust(filePath) {
+  if (filePath in cachebust.cache) {
+    cachebust.cache[filePath] += 1;
+    return `${filePath}?cachebust${cachebust.cache[filePath]}`;
+  } else {
+    cachebust.cache[filePath] = 0;
+    return filePath;
+  }
+}
+
+cachebust.cache = Object.create(null);
+
+export function watchContentDependencies({
+  mock = null,
+  logging = true,
+} = {}) {
+  const events = new EventEmitter();
+  const contentDependencies = {};
+
+  let emittedReady = false;
+  let allDependenciesFulfilled = false;
+  let closed = false;
+
+  let _close = () => {};
+
+  Object.assign(events, {
+    contentDependencies,
+    close,
+  });
+
+  const eslint = new ESLint();
+
+  const metaPath = fileURLToPath(import.meta.url);
+  const metaDirname = path.dirname(metaPath);
+  const watchPath = metaDirname;
+
+  const mockKeys = new Set();
+  if (mock) {
+    const errors = [];
+
+    for (const [functionName, spec] of Object.entries(mock)) {
+      mockKeys.add(functionName);
+      try {
+        const fn = processFunctionSpec(functionName, spec);
+        contentDependencies[functionName] = fn;
+      } catch (error) {
+        error.message = `(${functionName}) ${error.message}`;
+        errors.push(error);
+      }
+    }
+
+    if (errors.length) {
+      throw new AggregateError(errors, `Errors processing mocked content functions`);
+    }
+  }
+
+  // Chokidar's 'ready' event is supposed to only fire once an 'add' event
+  // has been fired for everything in the watched directory, but it's not
+  // totally reliable. https://github.com/paulmillr/chokidar/issues/1011
+  //
+  // Workaround here is to readdir for the names of all dependencies ourselves,
+  // and enter null for each into the contentDependencies object. We'll emit
+  // 'ready' ourselves only once no nulls remain. And we won't actually start
+  // watching until the readdir is done and nulls are entered (so we don't
+  // prematurely find out there aren't any nulls - before the nulls have
+  // been entered at all!).
+
+  readdir(metaDirname).then(files => {
+    if (closed) {
+      return;
+    }
+
+    const filePaths = files.map(file => path.join(metaDirname, file));
+    for (const filePath of filePaths) {
+      if (filePath === metaPath) continue;
+      const functionName = getFunctionName(filePath);
+      if (!isMocked(functionName)) {
+        contentDependencies[functionName] = null;
+      }
+    }
+
+    const watcher = chokidar.watch(metaDirname);
+
+    watcher.on('all', (event, filePath) => {
+      if (!['add', 'change'].includes(event)) return;
+      if (filePath === metaPath) return;
+      handlePathUpdated(filePath);
+
+    });
+
+    watcher.on('unlink', (filePath) => {
+      if (filePath === metaPath) {
+        console.error(`Yeowzers content dependencies just got nuked.`);
+        return;
+      }
+
+      handlePathRemoved(filePath);
+    });
+
+    _close = () => watcher.close();
+  });
+
+  return events;
+
+  async function close() {
+    closed = true;
+    return _close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (Object.values(contentDependencies).includes(null)) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  function getFunctionName(filePath) {
+    const shortPath = path.basename(filePath);
+    const functionName = shortPath.slice(0, -path.extname(shortPath).length);
+    return functionName;
+  }
+
+  function isMocked(functionName) {
+    return mockKeys.has(functionName);
+  }
+
+  async function handlePathRemoved(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    delete contentDependencies[functionName];
+  }
+
+  async function handlePathUpdated(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    let error = null;
+
+    main: {
+      const eslintResults = await eslint.lintFiles([filePath]);
+      const eslintFormatter = await eslint.loadFormatter('stylish');
+      const eslintResultText = eslintFormatter.format(eslintResults);
+      if (eslintResultText.trim().length) {
+        console.log(eslintResultText);
+      }
+
+      let spec;
+      try {
+        spec = (await import(cachebust(filePath))).default;
+      } catch (caughtError) {
+        error = caughtError;
+        error.message = `Error importing: ${error.message}`;
+        break main;
+      }
+
+      // Just skip newly created files. They'll be processed again when
+      // written.
+      if (spec === undefined) {
+        contentDependencies[functionName] = null;
+        return;
+      }
+
+      let fn;
+      try {
+        fn = processFunctionSpec(functionName, spec);
+      } catch (caughtError) {
+        error = caughtError;
+        break main;
+      }
+
+      if (logging && emittedReady) {
+        const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+        console.log(color.green(`[${timestamp}] Updated ${functionName}`));
+      }
+
+      contentDependencies[functionName] = fn;
+
+      events.emit('update', functionName);
+      checkReadyConditions();
+    }
+
+    if (!error) {
+      return true;
+    }
+
+    if (!(functionName in contentDependencies)) {
+      contentDependencies[functionName] = null;
+    }
+
+    events.emit('error', functionName, error);
+
+    if (logging) {
+      if (contentDependencies[functionName]) {
+        logWarn`Failed to import ${functionName} - using existing version`;
+      } else {
+        logWarn`Failed to import ${functionName} - no prior version loaded`;
+      }
+
+      if (typeof error === 'string') {
+        console.error(color.yellow(error));
+      } else if (error instanceof ContentFunctionSpecError) {
+        console.error(color.yellow(error.message));
+      } else {
+        console.error(error);
+      }
+    }
+
+    return false;
+  }
+
+  function processFunctionSpec(functionName, spec) {
+    if (typeof spec?.data === 'function') {
+      annotateFunction(spec.data, {name: functionName, description: 'data'});
+    }
+
+    if (typeof spec?.generate === 'function') {
+      annotateFunction(spec.generate, {name: functionName});
+    }
+
+    return contentFunction(spec);
+  }
+}
+
+export function quickLoadContentDependencies(opts) {
+  return new Promise((resolve, reject) => {
+    const watcher = watchContentDependencies(opts);
+
+    watcher.on('error', (name, error) => {
+      watcher.close().then(() => {
+        error.message = `Error loading dependency ${name}: ${error}`;
+        reject(error);
+      });
+    });
+
+    watcher.on('ready', () => {
+      watcher.close().then(() => {
+        resolve(watcher.contentDependencies);
+      });
+    });
+  });
+}
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
new file mode 100644
index 00000000..36b0d13a
--- /dev/null
+++ b/src/content/dependencies/linkAlbum.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.album', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
new file mode 100644
index 00000000..39e7111e
--- /dev/null
+++ b/src/content/dependencies/linkAlbumAdditionalFile.js
@@ -0,0 +1,24 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(album, file) {
+    return {
+      albumDirectory: album.directory,
+      file,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.linkTemplate
+      .slots({
+        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
+        content: data.file,
+      });
+  },
+};
diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js
new file mode 100644
index 00000000..ab519fd6
--- /dev/null
+++ b/src/content/dependencies/linkAlbumCommentary.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumCommentary', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js
new file mode 100644
index 00000000..e3f30a29
--- /dev/null
+++ b/src/content/dependencies/linkAlbumGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumGallery', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js
new file mode 100644
index 00000000..7ddb7786
--- /dev/null
+++ b/src/content/dependencies/linkArtTag.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.tag', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js
new file mode 100644
index 00000000..718ee6fa
--- /dev/null
+++ b/src/content/dependencies/linkArtist.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artist', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js
new file mode 100644
index 00000000..66dc172d
--- /dev/null
+++ b/src/content/dependencies/linkArtistGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artistGallery', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
new file mode 100644
index 00000000..f4c05388
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,72 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkExternalAsIcon',
+  ],
+
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  relations(relation, contribution) {
+    const relations = {};
+
+    relations.artistLink =
+      relation('linkArtist', contribution.who);
+
+    if (!empty(contribution.who.urls)) {
+      relations.artistIcons =
+        contribution.who.urls
+          .slice(0, 4)
+          .map(url => relation('linkExternalAsIcon', url));
+    }
+
+    return relations;
+  },
+
+  data(contribution) {
+    return {
+      what: contribution.what,
+    };
+  },
+
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const hasContributionPart = !!(slots.showContribution && data.what);
+    const hasExternalPart = !!(slots.showIcons && relations.artistIcons);
+
+    const externalLinks = hasExternalPart &&
+      html.tag('span',
+        {[html.noEdgeWhitespace]: true, class: 'icons'},
+        language.formatUnitList(relations.artistIcons));
+
+    const parts = ['misc.artistLink'];
+    const options = {artist: relations.artistLink};
+
+    if (hasContributionPart) {
+      parts.push('withContribution');
+      options.contrib = data.what;
+    }
+
+    if (hasExternalPart) {
+      parts.push('withExternalLinks');
+      options.links = externalLinks;
+    }
+
+    const content = language.formatString(parts.join('.'), options);
+
+    return (
+      (parts.length > 1
+        ? html.tag('span',
+            {[html.noEdgeWhitespace]: true, class: 'nowrap'},
+            content)
+        : content));
+    },
+};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
new file mode 100644
index 00000000..7c3d86a8
--- /dev/null
+++ b/src/content/dependencies/linkExternal.js
@@ -0,0 +1,90 @@
+// TODO: Define these as extra dependencies and pass them somewhere
+const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
+const MASTODON_DOMAINS = ['types.pl'];
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(url) {
+    return {url};
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('generic', 'album'),
+      default: 'generic',
+    },
+  },
+
+  generate(data, slots, {html, language}) {
+    let isLocal;
+    let domain;
+    try {
+      domain = new URL(data.url).hostname;
+    } catch (error) {
+      // 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;
+    }
+
+    const a = html.tag('a',
+      {
+        href: data.url,
+        class: 'nowrap',
+      },
+
+      // truly unhinged indentation here
+      isLocal
+        ? language.$('misc.external.local')
+
+    : domain.includes('bandcamp.com')
+        ? language.$('misc.external.bandcamp')
+
+    : BANDCAMP_DOMAINS.includes(domain)
+        ? language.$('misc.external.bandcamp.domain', {domain})
+
+    : MASTODON_DOMAINS.includes(domain)
+        ? language.$('misc.external.mastodon.domain', {domain})
+
+    : domain.includes('youtu')
+        ? slots.mode === 'album'
+          ? data.url.includes('list=')
+            ? language.$('misc.external.youtube.playlist')
+            : language.$('misc.external.youtube.fullAlbum')
+          : language.$('misc.external.youtube')
+
+    : domain.includes('soundcloud')
+        ? language.$('misc.external.soundcloud')
+
+    : domain.includes('tumblr.com')
+        ? language.$('misc.external.tumblr')
+
+    : domain.includes('twitter.com')
+        ? language.$('misc.external.twitter')
+
+    : domain.includes('deviantart.com')
+        ? language.$('misc.external.deviantart')
+
+    : domain.includes('wikipedia.org')
+        ? language.$('misc.external.wikipedia')
+
+    : domain.includes('poetryfoundation.org')
+        ? language.$('misc.external.poetryFoundation')
+
+    : domain.includes('instagram.com')
+        ? language.$('misc.external.instagram')
+
+    : domain.includes('patreon.com')
+        ? language.$('misc.external.patreon')
+
+    : domain.includes('spotify.com')
+        ? language.$('misc.external.spotify')
+
+    : domain.includes('newgrounds.com')
+        ? language.$('misc.external.newgrounds')
+
+        : domain);
+
+    return a;
+  }
+};
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
new file mode 100644
index 00000000..cd168992
--- /dev/null
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -0,0 +1,46 @@
+// TODO: Define these as extra dependencies and pass them somewhere
+const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
+const MASTODON_DOMAINS = ['types.pl'];
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data(url) {
+    return {url};
+  },
+
+  generate(data, {html, language, to}) {
+    const domain = new URL(data.url).hostname;
+    const [id, msg] = (
+      domain.includes('bandcamp.com')
+        ? ['bandcamp', language.$('misc.external.bandcamp')]
+      : BANDCAMP_DOMAINS.includes(domain)
+        ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
+      : MASTODON_DOMAINS.includes(domain)
+        ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
+      : domain.includes('youtu')
+        ? ['youtube', language.$('misc.external.youtube')]
+      : domain.includes('soundcloud')
+        ? ['soundcloud', language.$('misc.external.soundcloud')]
+      : domain.includes('tumblr.com')
+        ? ['tumblr', language.$('misc.external.tumblr')]
+      : domain.includes('twitter.com')
+        ? ['twitter', language.$('misc.external.twitter')]
+      : domain.includes('deviantart.com')
+        ? ['deviantart', language.$('misc.external.deviantart')]
+      : domain.includes('instagram.com')
+        ? ['instagram', language.$('misc.external.bandcamp')]
+      : domain.includes('newgrounds.com')
+        ? ['newgrounds', language.$('misc.external.newgrounds')]
+        : ['globe', language.$('misc.external.domain', {domain})]);
+
+    return html.tag('a',
+      {href: data.url, class: 'icon'},
+      html.tag('svg', [
+        html.tag('title', msg),
+        html.tag('use', {
+          href: to('shared.staticIcon', id),
+        }),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js
new file mode 100644
index 00000000..65158ff8
--- /dev/null
+++ b/src/content/dependencies/linkExternalFlash.js
@@ -0,0 +1,41 @@
+// Note: This function is seriously hard-coded for HSMusic, with custom
+// presentation of links to Homestuck flashes hosted various places.
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, url) {
+    return {
+      link: relation('linkExternal', url),
+    };
+  },
+
+  data(url, flash) {
+    return {
+      url,
+      page: flash.page,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const {link} = relations;
+    const {url, page} = data;
+
+    return html.tag('span',
+      {class: 'nowrap'},
+
+      url.includes('homestuck.com')
+        ? isNaN(Number(page))
+          ? language.$('misc.external.flash.homestuck.secret', {link})
+          : language.$('misc.external.flash.homestuck.page', {link, page})
+
+    : url.includes('bgreco.net')
+        ? language.$('misc.external.flash.bgreco', {link})
+
+    : url.includes('youtu')
+        ? language.$('misc.external.flash.youtube', {link})
+
+        : link);
+  },
+};
diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js
new file mode 100644
index 00000000..93dd5a28
--- /dev/null
+++ b/src/content/dependencies/linkFlash.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, flash) =>
+    ({link: relation('linkThing', 'localized.flash', flash)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js
new file mode 100644
index 00000000..ebab1b5b
--- /dev/null
+++ b/src/content/dependencies/linkGroup.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupInfo', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js
new file mode 100644
index 00000000..ee6a3b1d
--- /dev/null
+++ b/src/content/dependencies/linkGroupExtra.js
@@ -0,0 +1,34 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations(relation, group) {
+    const relations = {};
+
+    relations.info =
+      relation('linkGroup', group);
+
+    if (!empty(group.albums)) {
+      relations.gallery =
+        relation('linkGroupGallery', group);
+    }
+
+    return relations;
+  },
+
+  slots: {
+    extra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(relations, slots) {
+    return relations[slots.extra ?? 'info'] ?? relations.info;
+  },
+};
diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js
new file mode 100644
index 00000000..86c4a0f3
--- /dev/null
+++ b/src/content/dependencies/linkGroupGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupGallery', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js
new file mode 100644
index 00000000..2fc516bc
--- /dev/null
+++ b/src/content/dependencies/linkListing.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkThing'],
+  extraDependencies: ['language'],
+
+  relations: (relation, listing) =>
+    ({link: relation('linkThing', 'localized.listing', listing)}),
+
+  data: (listing) =>
+    ({stringsKey: listing.stringsKey}),
+
+  generate: (data, relations, {language}) =>
+    relations.link
+      .slot('content', language.$(`listingPage.${data.stringsKey}.title`)),
+};
diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js
new file mode 100644
index 00000000..1bfaf46e
--- /dev/null
+++ b/src/content/dependencies/linkListingIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.listingIndex',
+          'listingIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js
new file mode 100644
index 00000000..1fb32dd9
--- /dev/null
+++ b/src/content/dependencies/linkNewsEntry.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, newsEntry) =>
+    ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js
new file mode 100644
index 00000000..032af6c9
--- /dev/null
+++ b/src/content/dependencies/linkStaticPage.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, staticPage) =>
+    ({link: relation('linkThing', 'localized.staticPage', staticPage)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js
new file mode 100644
index 00000000..d5506e60
--- /dev/null
+++ b/src/content/dependencies/linkStationaryIndex.js
@@ -0,0 +1,24 @@
+// Not to be confused with "html.Stationery".
+
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['language'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(pathKey, stringKey) {
+    return {pathKey, stringKey};
+  },
+
+  generate(data, relations, {language}) {
+    return relations.linkTemplate
+      .slots({
+        path: [data.pathKey],
+        content: language.formatString(data.stringKey),
+      });
+  }
+}
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
new file mode 100644
index 00000000..98e2c8b9
--- /dev/null
+++ b/src/content/dependencies/linkTemplate.js
@@ -0,0 +1,67 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'appendIndexHTML',
+    'getColors',
+    'html',
+    'to',
+  ],
+
+  slots: {
+    href: {type: 'string'},
+    path: {validate: v => v.validateArrayItems(v.isString)},
+    hash: {type: 'string'},
+
+    tooltip: {validate: v => v.isString},
+    attributes: {validate: v => v.isAttributes},
+    color: {validate: v => v.isColor},
+    content: {type: 'html'},
+  },
+
+  generate(slots, {
+    appendIndexHTML,
+    getColors,
+    html,
+    to,
+  }) {
+    let href = slots.href;
+    let style;
+    let title;
+
+    if (!href && !empty(slots.path)) {
+      href = to(...slots.path);
+    }
+
+    if (appendIndexHTML) {
+      if (
+        /^(?!https?:\/\/).+\/$/.test(href) &&
+        href.endsWith('/')
+      ) {
+        href += 'index.html';
+      }
+    }
+
+    if (slots.hash) {
+      href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+    }
+
+    if (slots.color) {
+      const {primary, dim} = getColors(slots.color);
+      style = `--primary-color: ${primary}; --dim-color: ${dim}`;
+    }
+
+    if (slots.tooltip) {
+      title = slots.tooltip;
+    }
+
+    return html.tag('a',
+      {
+        ...slots.attributes ?? {},
+        href,
+        style,
+        title,
+      },
+      slots.content);
+  },
+}
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
new file mode 100644
index 00000000..4ebf4d76
--- /dev/null
+++ b/src/content/dependencies/linkThing.js
@@ -0,0 +1,84 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['html'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(pathKey, thing) {
+    return {
+      pathKey,
+
+      color: thing.color,
+      directory: thing.directory,
+
+      name: thing.name,
+      nameShort: thing.nameShort,
+    };
+  },
+
+  slots: {
+    content: {type: 'html'},
+
+    preferShortName: {type: 'boolean', default: false},
+
+    tooltip: {
+      validate: v => v.oneOf(v.isBoolean, v.isString),
+      default: false,
+    },
+
+    color: {
+      validate: v => v.oneOf(v.isBoolean, v.isColor),
+      default: true,
+    },
+
+    anchor: {type: 'boolean', default: false},
+
+    attributes: {validate: v => v.isAttributes},
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html}) {
+    const path = [data.pathKey, data.directory];
+
+    let content = slots.content;
+
+    const name =
+      (slots.preferShortName
+        ? data.nameShort ?? data.name
+        : data.name);
+
+    if (html.isBlank(content)) {
+      content = name;
+    }
+
+    let color = null;
+    if (slots.color === true) {
+      color = data.color ?? null;
+    } else if (typeof slots.color === 'string') {
+      color = slots.color;
+    }
+
+    let tooltip = null;
+    if (slots.tooltip === true) {
+      tooltip = name;
+    } else if (typeof slots.tooltip === 'string') {
+      tooltip = slots.tooltip;
+    }
+
+    return relations.linkTemplate
+      .slots({
+        path: slots.anchor ? [] : path,
+        href: slots.anchor ? '' : null,
+        content,
+        color,
+        tooltip,
+
+        attributes: slots.attributes,
+        hash: slots.hash,
+      });
+  },
+}
diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js
new file mode 100644
index 00000000..d5d96726
--- /dev/null
+++ b/src/content/dependencies/linkTrack.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.track', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js
new file mode 100644
index 00000000..1c584282
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDate.js
@@ -0,0 +1,52 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {sortChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      albums:
+        sortChronologically(albumData.filter(album => album.date)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums
+          .map(album => album.date),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          date: data.dates,
+        }).map(({link, date}) => ({
+            album: link,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js
new file mode 100644
index 00000000..e2ff8461
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDateAdded.js
@@ -0,0 +1,59 @@
+import {chunkByProperties, sortAlphabetically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      chunks:
+        chunkByProperties(
+          sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
+            .sort((a, b) => {
+              if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
+              if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
+            }),
+          ['dateAddedToWiki']),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.chunks.map(({dateAddedToWiki}) => dateAddedToWiki),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        data.dates.map(date => ({
+          date: language.formatDate(date),
+        })),
+
+      chunkRows:
+        relations.albumLinks.map(albumLinks =>
+          albumLinks.map(link => ({
+            album: link,
+          }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js
new file mode 100644
index 00000000..650a5d1e
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDuration.js
@@ -0,0 +1,51 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = albumData.slice();
+    const durations = albums.map(album => getTotalDuration(album.tracks));
+
+    filterByCount(albums, durations);
+    sortByCount(albums, durations, {greatestFirst: true});
+
+    return {spec, albums, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            album: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js
new file mode 100644
index 00000000..c302a9cb
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByName.js
@@ -0,0 +1,50 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {sortAlphabetically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: sortAlphabetically(albumData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.albums
+          .map(album => album.tracks.length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js
new file mode 100644
index 00000000..c31609bd
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByTracks.js
@@ -0,0 +1,51 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = albumData.slice();
+    const counts = albums.map(album => album.tracks.length);
+
+    filterByCount(albums, counts);
+    sortByCount(albums, counts, {greatestFirst: true});
+
+    return {spec, albums, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js
new file mode 100644
index 00000000..eae6dd6e
--- /dev/null
+++ b/src/content/dependencies/listArtistsByCommentaryEntries.js
@@ -0,0 +1,55 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists = artistData.slice();
+    const counts =
+      artists.map(artist =>
+        artist.tracksAsCommentator.length +
+        artist.albumsAsCommentator.length);
+
+    filterByCount(artists, counts);
+    sortByCount(artists, counts, {greatestFirst: true});
+
+    return {artists, counts, spec};
+  },
+
+  relations(relation, query) {
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            artist: link,
+            entries: language.countCommentaryEntries(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
new file mode 100644
index 00000000..442b8329
--- /dev/null
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -0,0 +1,163 @@
+import {stitchArrays, unique} from '../../util/sugar.js';
+import {filterByCount, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {
+      artistData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  query(sprawl, spec) {
+    const query = {
+      spec,
+      enableFlashesAndGames: sprawl.enableFlashesAndGames,
+    };
+
+    const queryContributionInfo = (artistsKey, countsKey, fn) => {
+      const artists = sprawl.artistData.slice();
+      const counts = artists.map(artist => fn(artist));
+
+      filterByCount(artists, counts);
+      sortByCount(artists, counts, {greatestFirst: true});
+
+      query[artistsKey] = artists;
+      query[countsKey] = counts;
+    };
+
+    queryContributionInfo(
+      'artistsByTrackContributions',
+      'countsByTrackContributions',
+      artist =>
+        unique([
+          ...artist.tracksAsContributor,
+          ...artist.tracksAsArtist,
+        ]).length);
+
+    queryContributionInfo(
+      'artistsByArtworkContributions',
+      'countsByArtworkContributions',
+      artist =>
+        artist.tracksAsCoverArtist.length +
+        artist.albumsAsCoverArtist.length +
+        artist.albumsAsWallpaperArtist.length +
+        artist.albumsAsBannerArtist.length);
+
+    if (sprawl.enableFlashesAndGames) {
+      queryContributionInfo(
+        'artistsByFlashContributions',
+        'countsByFlashContributions',
+        artist =>
+          artist.flashesAsContributor.length);
+    }
+
+    return query;
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    relations.artistLinksByTrackContributions =
+      query.artistsByTrackContributions
+        .map(artist => relation('linkArtist', artist));
+
+    relations.artistLinksByArtworkContributions =
+      query.artistsByArtworkContributions
+        .map(artist => relation('linkArtist', artist));
+
+    if (query.enableFlashesAndGames) {
+      relations.artistLinksByFlashContributions =
+        query.artistsByFlashContributions
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    data.enableFlashesAndGames = query.enableFlashesAndGames;
+
+    data.countsByTrackContributions = query.countsByTrackContributions;
+    data.countsByArtworkContributions = query.countsByArtworkContributions;
+
+    if (query.enableFlashesAndGames) {
+      data.countsByFlashContributions = query.countsByFlashContributions;
+    }
+
+    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}),
+                  })))),
+        ]));
+
+    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,
+            ],
+          ]),
+        ]),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
new file mode 100644
index 00000000..478e99bb
--- /dev/null
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -0,0 +1,55 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists = artistData.slice();
+    const durations = artists.map(artist =>
+      getTotalDuration([
+        ...(artist.tracksAsArtist ?? []),
+        ...(artist.tracksAsContributor ?? []),
+      ], {originalReleasesOnly: true}));
+
+    filterByCount(artists, durations);
+    sortByCount(artists, durations, {greatestFirst: true});
+
+    return {spec, artists, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            artist: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
new file mode 100644
index 00000000..3b9b3a51
--- /dev/null
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -0,0 +1,367 @@
+import {transposeArrays, empty, stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkMultipleArrays,
+  compareCaseLessSensitive,
+  compareDates,
+  filterMultipleArrays,
+  reduceMultipleArrays,
+  sortAlphabetically,
+  sortMultipleArrays,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkArtist',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {
+      artistData,
+      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);
+      }
+
+      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);
+
+      query[chunkThingsKey] =
+        chunks.map(([artists, dates, chunkThings]) => chunkThings[0]);
+
+      query[datesKey] =
+        chunks.map(([artists, dates, chunkThings]) => dates[0]);
+
+      query[artistsKey] =
+        chunks.map(([artists, dates, chunkThings]) => artists);
+
+      query[datelessArtistsKey] = datelessArtists;
+    };
+
+    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);
+
+        return [albums, dates];
+      });
+
+    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),
+          ...artist.albumsAsCoverArtist.map(album => album.coverArtDate),
+          ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate),
+          ...artist.albumsAsBannerArtist.map(album => album.coverArtDate),
+        ],
+      ]);
+
+    if (sprawl.enableFlashesAndGames) {
+      queryContributionInfo(
+        'artistsByFlashContributions',
+        'flashesByFlashContributions',
+        'datesByFlashContributions',
+        'datelessArtistsByFlashContributions',
+        artist => [
+          [
+            ...artist.flashesAsContributor,
+          ],
+          [
+            ...artist.flashesAsContributor.map(flash => flash.date),
+          ],
+        ]);
+    }
+
+    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)));
+
+    relations.datelessArtistLinksByArtworkContributions =
+      query.datelessArtistsByArtworkContributions
+        .map(artist => relation('linkArtist', artist));
+
+    // Flash contributors
+
+    if (query.enableFlashesAndGames) {
+      relations.flashLinksByFlashContributions =
+        query.flashesByFlashContributions
+          .map(flash => relation('linkFlash', flash));
+
+      relations.artistLinksByFlashContributions =
+        query.artistsByFlashContributions
+          .map(artists =>
+            artists.map(artist => relation('linkArtist', artist)));
+
+      relations.datelessArtistLinksByFlashContributions =
+        query.datelessArtistsByFlashContributions
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    data.enableFlashesAndGames = query.enableFlashesAndGames;
+
+    data.datesByTrackContributions = query.datesByTrackContributions;
+    data.datesByArtworkContributions = query.datesByArtworkContributions;
+
+    if (query.enableFlashesAndGames) {
+      data.datesByFlashContributions = query.datesByFlashContributions;
+    }
+
+    return data;
+  },
+
+  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,
+                    })))),
+            ],
+          ]),
+        ]));
+
+    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,
+            ],
+          ]),
+        ]),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
new file mode 100644
index 00000000..1b93eca8
--- /dev/null
+++ b/src/content/dependencies/listArtistsByName.js
@@ -0,0 +1,55 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  getArtistNumContributions,
+  sortAlphabetically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    return {
+      spec,
+
+      artists: sortAlphabetically(artistData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(album => relation('linkArtist', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.artists
+          .map(artist => getArtistNumContributions(artist)),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return 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/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js
new file mode 100644
index 00000000..2235c0dd
--- /dev/null
+++ b/src/content/dependencies/listGroupsByAlbums.js
@@ -0,0 +1,51 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = groupData.slice();
+    const counts = groups.map(group => group.albums.length);
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            albums: language.countAlbums(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js
new file mode 100644
index 00000000..84a895f6
--- /dev/null
+++ b/src/content/dependencies/listGroupsByCategory.js
@@ -0,0 +1,76 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {groupCategoryData};
+  },
+
+  query({groupCategoryData}, spec) {
+    return {
+      spec,
+      groupCategories: groupCategoryData,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      categoryLinks:
+        query.groupCategories
+          .map(category => relation('linkGroup', category.groups[0])),
+
+      infoLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroup', group))),
+
+      galleryLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroupGallery', group)))
+    };
+  },
+
+  data(query) {
+    return {
+      categoryNames:
+        query.groupCategories
+          .map(category => category.name),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          link: relations.categoryLinks,
+          name: data.categoryNames,
+        }).map(({link, name}) => ({
+            category: link.slot('content', name),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          infoLinks: relations.infoLinks,
+          galleryLinks: relations.galleryLinks,
+        }).map(({infoLinks, galleryLinks}) =>
+            stitchArrays({
+              infoLink: infoLinks,
+              galleryLink: galleryLinks,
+            }).map(({infoLink, galleryLink}) => ({
+                group: infoLink,
+                gallery:
+                  galleryLink
+                    .slot('content', language.$('listingPage.listGroups.byCategory.chunk.item.gallery')),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
new file mode 100644
index 00000000..cf24a472
--- /dev/null
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -0,0 +1,55 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, getTotalDuration, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = groupData.slice();
+    const durations =
+      groups.map(group =>
+        getTotalDuration(
+          group.albums.flatMap(album => album.tracks),
+          {originalReleasesOnly: true}));
+
+    filterByCount(groups, durations);
+    sortByCount(groups, durations, {greatestFirst: true});
+
+    return {spec, groups, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            group: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js
new file mode 100644
index 00000000..0d2ee5c2
--- /dev/null
+++ b/src/content/dependencies/listGroupsByLatestAlbum.js
@@ -0,0 +1,78 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  compareDates,
+  filterMultipleArrays,
+  sortChronologically,
+  sortMultipleArrays,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortChronologically(groupData.slice());
+
+    const albums =
+      groups
+        .map(group =>
+          sortChronologically(
+            group.albums.filter(album => album.date),
+            {latestFirst: true}))
+        .map(albums => albums[0]);
+
+    filterMultipleArrays(groups, albums, (group, album) => album);
+
+    const dates = albums.map(album => album.date);
+
+    // Note: After this sort, the groups/dates arrays are misaligned with
+    // albums. That's OK only because we aren't doing anything further with
+    // the albums array.
+    sortMultipleArrays(groups, dates,
+      (groupA, groupB, dateA, dateB) =>
+        compareDates(dateA, dateB, {latestFirst: true}));
+
+    return {spec, groups, dates};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates: query.dates,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          groupLink: relations.groupLinks,
+          date: data.dates,
+        }).map(({groupLink, date}) => ({
+            group: groupLink,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js
new file mode 100644
index 00000000..df35937b
--- /dev/null
+++ b/src/content/dependencies/listGroupsByName.js
@@ -0,0 +1,49 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {sortAlphabetically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    return {
+      spec,
+
+      groups: sortAlphabetically(groupData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      infoLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+
+      galleryLinks:
+        query.groups
+          .map(group => relation('linkGroupGallery', group)),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          infoLink: relations.infoLinks,
+          galleryLink: relations.galleryLinks,
+        }).map(({infoLink, galleryLink}) => ({
+            group: infoLink,
+            gallery:
+              galleryLink
+                .slot('content', language.$('listingPage.listGroups.byName.item.gallery')),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js
new file mode 100644
index 00000000..35ce153d
--- /dev/null
+++ b/src/content/dependencies/listGroupsByTracks.js
@@ -0,0 +1,55 @@
+import {accumulateSum, stitchArrays} from '../../util/sugar.js';
+import {filterByCount, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = groupData.slice();
+    const counts =
+      groups.map(group =>
+        accumulateSum(
+          group.albums,
+          ({tracks}) => tracks.length));
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
new file mode 100644
index 00000000..7010e9de
--- /dev/null
+++ b/src/content/dependencies/transformContent.js
@@ -0,0 +1,322 @@
+import {marked} from 'marked';
+
+import {bindFind} from '../../util/find.js';
+import {parseInput} from '../../util/replacer.js';
+import {replacerSpec} from '../../util/transform-content.js';
+
+const linkThingRelationMap = {
+  album: 'linkAlbum',
+  albumCommentary: 'linkAlbumCommentary',
+  albumGallery: 'linkAlbumGallery',
+  artist: 'linkArtist',
+  artistGallery: 'linkArtistGallery',
+  flash: 'linkFlash',
+  groupInfo: 'linkGroup',
+  groupGallery: 'linkGroupGallery',
+  listing: 'linkListing',
+  newsEntry: 'linkNewsEntry',
+  staticPage: 'linkStaticPage',
+  tag: 'linkArtTag',
+  track: 'linkTrack',
+};
+
+const linkValueRelationMap = {
+  // media: 'linkPathFromMedia',
+  // root: 'linkPathFromRoot',
+  // site: 'linkPathFromSite',
+};
+
+const linkIndexRelationMap = {
+  // commentaryIndex: 'linkCommentaryIndex',
+  // flashIndex: 'linkFlashIndex',
+  // home: 'linkHome',
+  // listingIndex: 'linkListingIndex',
+  // newsIndex: 'linkNewsIndex',
+};
+
+function getPlaceholder(node, content) {
+  return {type: 'text', data: content.slice(node.i, node.iEnd)};
+}
+
+export default {
+  contentDependencies: [
+    ...Object.values(linkThingRelationMap),
+    ...Object.values(linkValueRelationMap),
+    ...Object.values(linkIndexRelationMap),
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl(wikiData, content) {
+    const find = bindFind(wikiData);
+
+    const parsedNodes = parseInput(content);
+
+    return {
+      nodes: parsedNodes
+        .map(node => {
+          if (node.type !== 'tag') {
+            return node;
+          }
+
+          const placeholder = getPlaceholder(node, content);
+
+          const replacerKeyImplied = !node.data.replacerKey;
+          const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+
+          // TODO: We don't support recursive nodes like before, at the moment. Sorry!
+          // const replacerValue = transformNodes(node.data.replacerValue, opts);
+          const replacerValue = node.data.replacerValue[0].data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return placeholder;
+          }
+
+          if (spec.link) {
+            let data = {key: spec.link};
+
+            determineData: {
+              // No value at all: this is an index link.
+              if (!replacerValue) {
+                break determineData;
+              }
+
+              // Nothing to find: the link operates on a path or string, not a data object.
+              if (!spec.find) {
+                data.value = replacerValue;
+                break determineData;
+              }
+
+              const thing =
+                find[spec.find](
+                  (replacerKeyImplied
+                    ? replacerValue
+                    : replacerKey + `:` + replacerValue),
+                  wikiData);
+
+              // Nothing was found: this is unexpected, so return placeholder.
+              if (!thing) {
+                return placeholder;
+              }
+
+              // Something was found: the link operates on that thing.
+              data.thing = thing;
+            }
+
+            const {transformName} = spec;
+
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+            const enteredHash = node.data.hash?.data;
+
+            data.label =
+              enteredLabel ??
+                (transformName && data.thing.name
+                  ? transformName(data.thing.name, node, content)
+                  : null);
+
+            data.hash = enteredHash ?? null;
+
+            return {i: node.i, iEnd: node.iEnd, type: 'link', data};
+          }
+
+          // This will be another {type: 'tag'} node which gets processed in
+          // generate.
+          return node;
+        }),
+    };
+  },
+
+  data(sprawl, content) {
+    return {
+      content,
+
+      nodes:
+        sprawl.nodes
+          .map(node => {
+            // Replace link nodes with a stub. It'll be replaced (by position)
+            // with an item from relations.
+            if (node.type === 'link') {
+              return {type: 'link'};
+            }
+
+            // Other nodes will get processed in generate.
+            return node;
+          }),
+    };
+  },
+
+  relations(relation, sprawl, content) {
+    const {nodes} = sprawl;
+
+    const relationOrPlaceholder =
+      (node, name, arg) =>
+        (name
+          ? {
+              link: relation(name, arg),
+              label: node.data.label,
+              hash: node.data.hash,
+            }
+          : getPlaceholder(node, content));
+
+    return {
+      links:
+        nodes
+          .filter(({type}) => type === 'link')
+          .map(node => {
+            const {key, thing, value} = node.data;
+
+            if (thing) {
+              return relationOrPlaceholder(node, linkThingRelationMap[key], thing);
+            } else if (value) {
+              return relationOrPlaceholder(node, linkValueRelationMap[key], value);
+            } else {
+              return relationOrPlaceholder(node, linkIndexRelationMap[key]);
+            }
+          }),
+    };
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('inline', 'multiline', 'lyrics'),
+      default: 'multiline',
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    let linkIndex = 0;
+
+    // This array contains only straight text and link nodes, which are directly
+    // representable in html (so no further processing is needed on the level of
+    // individual nodes).
+    const contentFromNodes =
+      data.nodes.map(node => {
+        if (node.type === 'text') {
+          return {type: 'text', data: node.data};
+        }
+
+        if (node.type === 'link') {
+          const linkNode = relations.links[linkIndex++];
+          if (linkNode.type === 'text') {
+            return {type: 'text', data: linkNode.data};
+          }
+
+          const {link, label, hash} = linkNode;
+
+          return {
+            type: 'link',
+            data: link.slots({content: label, hash}),
+          };
+        }
+
+        if (node.type === 'tag') {
+          const {replacerKey, replacerValue} = node.data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return getPlaceholder(node, data.content);
+          }
+
+          const {value: valueFn, html: htmlFn} = spec;
+
+          const value =
+            (valueFn
+              ? valueFn(replacerValue)
+              : replacerValue);
+
+          const contents =
+            (htmlFn
+              ? htmlFn(value, {html, language})
+              : value);
+
+          return {type: 'text', data: contents};
+        }
+
+        return getPlaceholder(node, data.content);
+      });
+
+    // In inline mode, no further processing is needed!
+
+    if (slots.mode === 'inline') {
+      return html.tags(contentFromNodes.map(node => node.data));
+    }
+
+    // Multiline mode has a secondary processing stage where it's passed...
+    // through marked! Rolling your own Markdown only gets you so far :D
+
+    const markedOptions = {
+      headerIds: false,
+      mangle: false,
+    };
+
+    // This is separated into its own function just since we're gonna reuse
+    // it in a minute if everything goes to heck in lyrics mode.
+    const transformMultiline = () => {
+      const markedInput =
+        contentFromNodes
+          .map(node => {
+            if (node.type === 'text') {
+              return node.data;
+            } else {
+              return node.data.toString();
+            }
+          })
+          .join('')
+
+          // Compress multiple line breaks into single line breaks.
+          .replace(/\n{2,}/g, '\n')
+          // Expand line breaks which don't follow a list, quote,
+          // or <br> / "  ".
+          .replace(/(?<!^ *-.*|^>.*|  $|<br>$)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          // Expand line breaks which are at the end of a list.
+          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
+          // Expand line breaks which are at the end of a quote.
+          .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
+
+      return marked.parse(markedInput, markedOptions);
+    }
+
+    if (slots.mode === 'multiline') {
+      // Unfortunately, we kind of have to be super evil here and stringify
+      // the links, or else parse marked's output into html tags, which is
+      // very out of scope at the moment.
+      return transformMultiline();
+    }
+
+    // Lyrics mode goes through marked too, but line breaks are processed
+    // differently. Instead of having each line get its own paragraph,
+    // "adjacent" lines are joined together (with blank lines separating
+    // each verse/paragraph).
+
+    if (slots.mode === 'lyrics') {
+      // If it looks like old data, using <br> instead of bunched together
+      // lines... then oh god... just use transformMultiline. Perishes.
+      if (
+        contentFromNodes.some(node =>
+          node.type === 'text' &&
+          node.data.includes('<br'))
+      ) {
+        return transformMultiline();
+      }
+
+      // Lyrics mode is also evil for the same stringifying reasons as
+      // multiline.
+      return marked.parse(
+        contentFromNodes
+          .map(node => {
+            if (node.type === 'text') {
+              return node.data.replace(/\b\n\b/g, '<br>\n');
+            } else {
+              return node.data.toString();
+            }
+          })
+          .join(''),
+        markedOptions);
+    }
+  },
+}
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
new file mode 100644
index 00000000..11281e75
--- /dev/null
+++ b/src/content/util/getChronologyRelations.js
@@ -0,0 +1,42 @@
+export default function getChronologyRelations(thing, {
+  contributions,
+  linkArtist,
+  linkThing,
+  getThings,
+}) {
+  // One call to getChronologyRelations is considered "lumping" together all
+  // contributions as carrying equivalent meaning (for example, "artist"
+  // contributions and "contributor" contributions are bunched together in
+  // one call to getChronologyRelations, while "cover artist" contributions
+  // are a separate call). getChronologyRelations prevents duplicates that
+  // carry the same meaning by only using the first instance of each artist
+  // in the contributions array passed to it. It's expected that the string
+  // identifying which kind of contribution ("track" or "cover art") is
+  // shared and applied to all contributions, as providing them together
+  // in one call to getChronologyRelations implies they carry the same
+  // meaning.
+
+  const artistsSoFar = new Set();
+
+  contributions = contributions.filter(({who}) => {
+    if (artistsSoFar.has(who)) {
+      return false;
+    } else {
+      artistsSoFar.add(who);
+      return true;
+    }
+  });
+
+  return contributions.map(({who}) => {
+    const things = Array.from(new Set(getThings(who)));
+    const index = things.indexOf(thing);
+    const previous = things[index - 1];
+    const next = things[index + 1];
+    return {
+      index: index + 1,
+      artistLink: linkArtist(who),
+      previousLink: previous ? linkThing(previous) : null,
+      nextLink: next ? linkThing(next) : null,
+    };
+  });
+}
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
new file mode 100644
index 00000000..559967bc
--- /dev/null
+++ b/src/content/util/groupTracksByGroup.js
@@ -0,0 +1,23 @@
+import {empty} from '../../util/sugar.js';
+
+export default function groupTracksByGroup(tracks, groups) {
+  const lists = new Map(groups.map(group => [group, []]));
+  lists.set('other', []);
+
+  for (const track of tracks) {
+    const group = groups.find(group => group.albums.includes(track.album));
+    if (group) {
+      lists.get(group).push(track);
+    } else {
+      lists.get('other').push(track);
+    }
+  }
+
+  for (const [key, tracks] of lists.entries()) {
+    if (empty(tracks)) {
+      lists.delete(key);
+    }
+  }
+
+  return lists;
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 2a188f2d..d371f51f 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,5 +1,6 @@
 import Thing from './thing.js';
 
+import {empty} from '../../util/sugar.js';
 import find from '../../util/find.js';
 
 export class Album extends Thing {
@@ -34,12 +35,12 @@ export class Album extends Thing {
       update: {validate: isDate},
 
       expose: {
-        dependencies: ['date', 'hasCoverArt'],
+        dependencies: ['date', 'coverArtistContribsByRef'],
         transform: (coverArtDate, {
+          coverArtistContribsByRef,
           date,
-          hasCoverArt,
         }) =>
-          (hasCoverArt
+          (!empty(coverArtistContribsByRef)
             ? coverArtDate ?? date ?? null
             : null),
       },
@@ -103,7 +104,6 @@ export class Album extends Thing {
       update: {validate: isDimensions},
     },
 
-    hasCoverArt: Thing.common.flag(true),
     hasTrackArt: Thing.common.flag(true),
     hasTrackNumbers: Thing.common.flag(true),
     isListedOnHomepage: Thing.common.flag(true),
@@ -123,18 +123,16 @@ export class Album extends Thing {
 
     artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
     coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs(
-      'trackCoverArtistContribsByRef'
-    ),
-    wallpaperArtistContribs: Thing.common.dynamicContribs(
-      'wallpaperArtistContribsByRef'
-    ),
-    bannerArtistContribs: Thing.common.dynamicContribs(
-      'bannerArtistContribsByRef'
-    ),
+    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
+    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
+    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
 
     commentatorArtists: Thing.common.commentatorArtists(),
 
+    hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'),
+    hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'),
+    hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'),
+
     tracks: {
       flags: {expose: true},
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 303f33f3..f144b21f 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -27,9 +27,8 @@ export class Artist extends Thing {
 
     aliasNames: {
       flags: {update: true, expose: true},
-      update: {
-        validate: validateArrayItems(isName),
-      },
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
     },
 
     isAlias: Thing.common.flag(),
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index c18e8110..a79dd77a 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -68,10 +68,10 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
     Group,
 
     validators: {
+      is,
       isCountingNumber,
       isString,
       validateArrayItems,
-      validateFromConstants,
     },
   } = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
@@ -95,7 +95,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       flags: {update: true, expose: true},
 
       update: {
-        validate: validateFromConstants('grid', 'carousel'),
+        validate: is('grid', 'carousel'),
       },
 
       expose: {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 3086ad2e..7755c505 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -101,7 +101,7 @@ export class Language extends Thing {
         dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
         compute({strings, inheritedStrings, escapeHTML}) {
           if (!(strings || inheritedStrings) || !escapeHTML) return null;
-          const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})};
+          const allStrings = {...inheritedStrings, ...strings};
           return Object.fromEntries(
             Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
           );
@@ -252,19 +252,19 @@ export class Language extends Thing {
   // Conjunction list: A, B, and C
   formatConjunctionList(array) {
     this.assertIntlAvailable('intl_listConjunction');
-    return this.intl_listConjunction.format(array);
+    return this.intl_listConjunction.format(array.map(arr => arr.toString()));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array);
+    return this.intl_listDisjunction.format(array.map(arr => arr.toString()));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array);
+    return this.intl_listUnit.format(array.map(arr => arr.toString()));
   }
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
@@ -311,6 +311,8 @@ const countHelper = (stringKey, argName = stringKey) =>
 Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
+  countArtworks: countHelper('artworks'),
+  countFlashes: countHelper('flashes'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countCoverArts: countHelper('coverArts'),
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 9c59436e..5004f4e6 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -23,6 +23,7 @@ import {
 
 import {inspect} from 'util';
 import {color} from '../../util/cli.js';
+import {empty} from '../../util/sugar.js';
 import {getKebabCase} from '../../util/wiki-data.js';
 
 import find from '../../util/find.js';
@@ -63,6 +64,7 @@ export default class Thing extends CacheableObject {
     urls: () => ({
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isURL)},
+      expose: {transform: (value) => value ?? []},
     }),
 
     // A file extension! Or the default, if provided when calling this.
@@ -312,6 +314,20 @@ export default class Thing extends CacheableObject {
       },
     }),
 
+    // Nice 'n simple shorthand for an exposed-only flag which is true when any
+    // contributions are present in the specified property.
+    contribsPresent: (contribsByRefProperty) => ({
+      flags: {expose: true},
+      expose: {
+        dependencies: [contribsByRefProperty],
+        compute({
+          [contribsByRefProperty]: contribsByRef,
+        }) {
+          return !empty(contribsByRef);
+        },
+      }
+    }),
+
     // Neat little shortcut for "reversing" the reference lists stored on other
     // things - for example, tracks specify a "referenced tracks" property, and
     // you would use this to compute a corresponding "referenced *by* tracks"
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index b116120a..14092102 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -112,7 +112,12 @@ export function isInstance(value, constructor) {
 }
 
 export function isDate(value) {
-  return isInstance(value, Date);
+  isInstance(value, Date);
+
+  if (isNaN(value))
+    throw new TypeError(`Expected valid date`);
+
+  return true;
 }
 
 export function isObject(value) {
@@ -133,6 +138,34 @@ export function isArray(value) {
   return true;
 }
 
+// This one's shaped a bit different from other "is" functions.
+// More like validate functions, it returns a function.
+export function is(...values) {
+  if (Array.isArray(values)) {
+    values = new Set(values);
+  }
+
+  if (values.size === 1) {
+    const expected = Array.from(values)[0];
+
+    return (value) => {
+      if (value !== expected) {
+        throw new TypeError(`Expected ${expected}, got ${value}`);
+      }
+
+      return true;
+    };
+  }
+
+  return (value) => {
+    if (!values.has(value)) {
+      throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`);
+    }
+
+    return true;
+  };
+}
+
 function validateArrayItemsHelper(itemValidator) {
   return (item, index) => {
     try {
@@ -162,18 +195,12 @@ export function validateArrayItems(itemValidator) {
   };
 }
 
-export function validateInstanceOf(constructor) {
-  return (object) => isInstance(object, constructor);
+export function arrayOf(itemValidator) {
+  return validateArrayItems(itemValidator);
 }
 
-export function validateFromConstants(...values) {
-  return (value) => {
-    if (!values.includes(value)) {
-      throw new TypeError(`Expected one of ${values.join(', ')}`);
-    }
-
-    return true;
-  };
+export function validateInstanceOf(constructor) {
+  return (object) => isInstance(object, constructor);
 }
 
 // Wiki data (primitives & non-primitives)
diff --git a/src/data/yaml.js b/src/data/yaml.js
index de0b506b..73450f17 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -192,7 +192,6 @@ export const processAlbumDocument = makeProcessDocument(T.Album, {
     color: 'Color',
     urls: 'URLs',
 
-    hasCoverArt: 'Has Cover Art',
     hasTrackArt: 'Has Track Art',
     hasTrackNumbers: 'Has Track Numbers',
     isListedOnHomepage: 'Listed on Homepage',
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 36637ee0..4853f812 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,8 +1,9 @@
 import {OFFICIAL_GROUP_DIRECTORY} from './util/magic-constants.js';
 
 import {
-  empty,
   accumulateSum,
+  empty,
+  showAggregate,
 } from './util/sugar.js';
 
 import {
@@ -20,565 +21,111 @@ const listingSpec = [];
 listingSpec.push({
   directory: 'albums/by-name',
   stringsKey: 'listAlbums.byName',
+  contentFunction: 'listAlbumsByName',
 
   seeAlso: [
     'tracks/by-album',
   ],
-
-  data: ({wikiData: {albumData}}) =>
-    sortAlphabetically(albumData.slice()),
-
-  row: (album, {language, link}) =>
-    language.$('listingPage.listAlbums.byName.item', {
-      album: link.album(album),
-      tracks: language.countTracks(album.tracks.length, {unit: true}),
-    }),
 });
 
 listingSpec.push({
   directory: 'albums/by-tracks',
   stringsKey: 'listAlbums.byTracks',
-
-  data: ({wikiData: {albumData}}) =>
-    albumData.slice()
-      .sort((a, b) => b.tracks.length - a.tracks.length),
-
-  row: (album, {language, link}) =>
-    language.$('listingPage.listAlbums.byTracks.item', {
-      album: link.album(album),
-      tracks: language.countTracks(album.tracks.length, {unit: true}),
-    }),
+  contentFunction: 'listAlbumsByTracks',
 });
 
 listingSpec.push({
   directory: 'albums/by-duration',
   stringsKey: 'listAlbums.byDuration',
-
-  data: ({wikiData: {albumData}}) =>
-    albumData
-      .map(album => ({
-        album,
-        duration: getTotalDuration(album.tracks),
-      }))
-      .filter(({duration}) => duration > 0)
-      .sort((a, b) => b.duration - a.duration),
-
-  row: ({album, duration}, {language, link}) =>
-    language.$('listingPage.listAlbums.byDuration.item', {
-      album: link.album(album),
-      duration: language.formatDuration(duration),
-    }),
+  contentFunction: 'listAlbumsByDuration',
 });
 
 listingSpec.push({
   directory: 'albums/by-date',
   stringsKey: 'listAlbums.byDate',
+  contentFunction: 'listAlbumsByDate',
 
   seeAlso: [
     'tracks/by-date',
   ],
-
-  data: ({wikiData: {albumData}}) =>
-    sortChronologically(
-      albumData
-        .filter(album => album.date)),
-
-  row: (album, {language, link}) =>
-    language.$('listingPage.listAlbums.byDate.item', {
-      album: link.album(album),
-      date: language.formatDate(album.date),
-    }),
 });
 
 listingSpec.push({
   directory: 'albums/by-date-added',
   stringsKey: 'listAlbums.byDateAdded',
-
-  data: ({wikiData: {albumData}}) =>
-    chunkByProperties(
-      sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
-        .sort((a, b) => {
-          if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
-          if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
-        }),
-      ['dateAddedToWiki']),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({dateAddedToWiki, chunk: albums}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listAlbums.byDateAdded.date', {
-            date: language.formatDate(dateAddedToWiki),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            albums.map((album) =>
-              html.tag('li',
-                language.$('listingPage.listAlbums.byDateAdded.album', {
-                  album: link.album(album),
-                }))))),
-      ])),
+  contentFunction: 'listAlbumsByDateAdded',
 });
 
 listingSpec.push({
   directory: 'artists/by-name',
   stringsKey: 'listArtists.byName',
-
-  data: ({wikiData: {artistData}}) =>
-    sortAlphabetically(artistData.slice())
-      .map(artist => ({
-        artist,
-        contributions: getArtistNumContributions(artist),
-      })),
-
-  row: ({artist, contributions}, {language, link}) =>
-    language.$('listingPage.listArtists.byName.item', {
-      artist: link.artist(artist),
-      contributions: language.countContributions(contributions, {
-        unit: true,
-      }),
-    }),
+  contentFunction: 'listArtistsByName',
 });
 
 listingSpec.push({
   directory: 'artists/by-contribs',
   stringsKey: 'listArtists.byContribs',
-
-  data: ({wikiData: {artistData, wikiInfo}}) => ({
-    toTracks: artistData
-      .map(artist => ({
-        artist,
-        contributions:
-          artist.tracksAsContributor.length +
-          artist.tracksAsArtist.length,
-      }))
-      .sort((a, b) => b.contributions - a.contributions)
-      .filter(({contributions}) => contributions),
-
-    toArtAndFlashes: artistData
-      .map(artist => ({
-        artist,
-        contributions:
-          artist.tracksAsCoverArtist.length +
-          artist.albumsAsCoverArtist.length +
-          artist.albumsAsWallpaperArtist.length +
-          artist.albumsAsBannerArtist.length +
-          (wikiInfo.enableFlashesAndGames
-            ? artist.flashesAsContributor.length
-            : 0),
-      }))
-      .sort((a, b) => b.contributions - a.contributions)
-      .filter(({contributions}) => contributions),
-
-    // This is a kinda naughty hack, 8ut like, it's the only place
-    // we'd 8e passing wikiData to html() otherwise, so like....
-    // (Ok we do do this again once later.)
-    showAsFlashes: wikiInfo.enableFlashesAndGames,
-  }),
-
-  html: (
-    {toTracks, toArtAndFlashes, showAsFlashes},
-    {html, language, link}
-  ) =>
-    html.tag('div', {class: 'content-columns'}, [
-      html.tag('div', {class: 'column'}, [
-        html.tag('h2',
-          language.$('listingPage.misc.trackContributors')),
-
-        html.tag('ul',
-          toTracks.map(({artist, contributions}) =>
-            html.tag('li',
-              language.$('listingPage.listArtists.byContribs.item', {
-                artist: link.artist(artist),
-                contributions: language.countContributions(contributions, {
-                  unit: true,
-                }),
-              })))),
-      ]),
-
-      html.tag('div', {class: 'column'}, [
-        html.tag('h2',
-          language.$(
-            'listingPage.misc' +
-              (showAsFlashes
-                ? '.artAndFlashContributors'
-                : '.artContributors'))),
-
-        html.tag('ul',
-          toArtAndFlashes.map(({artist, contributions}) =>
-            html.tag('li',
-              language.$('listingPage.listArtists.byContribs.item', {
-                artist: link.artist(artist),
-                contributions:
-                  language.countContributions(contributions, {unit: true}),
-              })))),
-      ]),
-  ]),
+  contentFunction: 'listArtistsByContributions',
 });
 
 listingSpec.push({
   directory: 'artists/by-commentary',
   stringsKey: 'listArtists.byCommentary',
-
-  data: ({wikiData: {artistData}}) =>
-    artistData
-      .map(artist => ({
-        artist,
-        entries:
-          artist.tracksAsCommentator.length +
-          artist.albumsAsCommentator.length,
-      }))
-      .filter(({entries}) => entries)
-      .sort((a, b) => b.entries - a.entries),
-
-  row: ({artist, entries}, {language, link}) =>
-    language.$('listingPage.listArtists.byCommentary.item', {
-      artist: link.artist(artist),
-      entries: language.countCommentaryEntries(entries, {unit: true}),
-    }),
+  contentFunction: 'listArtistsByCommentaryEntries',
 });
 
 listingSpec.push({
   directory: 'artists/by-duration',
   stringsKey: 'listArtists.byDuration',
-
-  data: ({wikiData: {artistData}}) =>
-    artistData
-      .map((artist) => ({
-        artist,
-        duration: getTotalDuration([
-          ...(artist.tracksAsArtist ?? []),
-          ...(artist.tracksAsContributor ?? []),
-        ], {originalReleasesOnly: true}),
-      }))
-      .filter(({duration}) => duration > 0)
-      .sort((a, b) => b.duration - a.duration),
-
-  row: ({artist, duration}, {language, link}) =>
-    language.$('listingPage.listArtists.byDuration.item', {
-      artist: link.artist(artist),
-      duration: language.formatDuration(duration),
-    }),
+  contentFunction: 'listArtistsByDuration',
 });
 
 listingSpec.push({
   directory: 'artists/by-latest',
   stringsKey: 'listArtists.byLatest',
-
-  data({wikiData: {
-    albumData,
-    flashData,
-    trackData,
-    wikiInfo,
-  }}) {
-    const processContribs = values => {
-      const filteredValues = values
-        .filter(value => value.date && !empty(value.contribs));
-
-      const datedArtistLists = sortByDate(filteredValues)
-        .map(({
-          contribs,
-          date,
-        }) => ({
-          artists: contribs.map(({who}) => who),
-          date,
-        }));
-
-      const remainingArtists = new Set(datedArtistLists.flatMap(({artists}) => artists));
-      const artistEntries = [];
-
-      for (let i = datedArtistLists.length - 1; i >= 0; i--) {
-        const {artists, date} = datedArtistLists[i];
-        for (const artist of artists) {
-          if (!remainingArtists.has(artist))
-            continue;
-
-          remainingArtists.delete(artist);
-          artistEntries.push({
-            artist,
-            date,
-
-            // For sortChronologically!
-            directory: artist.directory,
-            name: artist.name,
-          });
-        }
-
-        // Early exit: If we've gotten every artist, there's no need to keep
-        // going.
-        if (remainingArtists.size === 0)
-          break;
-      }
-
-      return sortChronologically(artistEntries, {latestFirst: true});
-    };
-
-    // Tracks are super easy to sort because they only have one pertinent
-    // date: the date the track was released on.
-
-    const toTracks = processContribs(
-      trackData.map(({
-        artistContribs,
-        date,
-      }) => ({
-        contribs: artistContribs,
-        date,
-      })));
-
-    // Artworks are a bit more involved because there are multiple dates
-    // involved - cover artists correspond to one date, wallpaper artists to
-    // another, etc.
-
-    const toArtAndFlashes = processContribs([
-      ...trackData.map(({
-        coverArtistContribs,
-        coverArtDate,
-      }) => ({
-        contribs: coverArtistContribs,
-        date: coverArtDate,
-      })),
-
-      ...flashData
-        ? flashData.map(({
-            contributorContribs,
-            date,
-          }) => ({
-            contribs: contributorContribs,
-            date,
-          }))
-        : [],
-
-      ...albumData.flatMap(({
-        bannerArtistContribs,
-        coverArtistContribs,
-        coverArtDate,
-        date,
-        wallpaperArtistContribs,
-      }) => [
-        {
-          contribs: coverArtistContribs,
-          date: coverArtDate,
-        },
-        {
-          contribs: bannerArtistContribs,
-          date, // TODO: bannerArtDate (see issue #90)
-        },
-        {
-          contribs: wallpaperArtistContribs,
-          date, // TODO: wallpaperArtDate (see issue #90)
-        },
-      ]),
-    ]);
-
-    return {
-      toArtAndFlashes,
-      toTracks,
-
-      // (Ok we did it again.)
-      // This is a kinda naughty hack, 8ut like, it's the only place
-      // we'd 8e passing wikiData to html() otherwise, so like....
-      showAsFlashes: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  html: (
-    {toTracks, toArtAndFlashes, showAsFlashes},
-    {html, language, link}
-  ) =>
-    html.tag('div', {class: 'content-columns'}, [
-      html.tag('div', {class: 'column'}, [
-        html.tag('h2',
-          language.$('listingPage.misc.trackContributors')),
-
-        html.tag('ul',
-          toTracks.map(({artist, date}) =>
-            html.tag('li',
-              language.$('listingPage.listArtists.byLatest.item', {
-                artist: link.artist(artist),
-                date: language.formatDate(date),
-              })))),
-      ]),
-
-      html.tag('div', {class: 'column'}, [
-        html.tag('h2',
-          language.$(
-            'listingPage.misc' +
-              (showAsFlashes
-                ? '.artAndFlashContributors'
-                : '.artContributors'))),
-
-        html.tag('ul',
-          toArtAndFlashes.map(({artist, date}) =>
-            html.tag('li',
-              language.$('listingPage.listArtists.byLatest.item', {
-                artist: link.artist(artist),
-                date: language.formatDate(date),
-              })))),
-      ]),
-    ]),
+  contentFunction: 'listArtistsByLatestContribution',
 });
 
 listingSpec.push({
   directory: 'groups/by-name',
   stringsKey: 'listGroups.byName',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupData}}) =>
-    sortAlphabetically(groupData.slice()),
-
-  row: (group, {language, link}) =>
-    language.$('listingPage.listGroups.byCategory.group', {
-      group: link.groupInfo(group),
-      gallery: link.groupGallery(group, {
-        text: language.$('listingPage.listGroups.byCategory.group.gallery'),
-      }),
-    }),
+  contentFunction: 'listGroupsByName',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
   directory: 'groups/by-category',
   stringsKey: 'listGroups.byCategory',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupCategoryData}}) =>
-    groupCategoryData
-      .map(category => ({
-        category,
-        groups: category.groups,
-      })),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({category, groups}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listGroups.byCategory.category', {
-            category: empty(groups)
-              ? category.name
-              : link.groupInfo(groups[0], {
-                  text: category.name,
-                }),
-          })),
-
-        html.tag('dd',
-          empty(groups)
-            ? null // todo: #85
-            : html.tag('ul',
-                category.groups.map(group =>
-                  html.tag('li',
-                    language.$('listingPage.listGroups.byCategory.group', {
-                      group: link.groupInfo(group),
-                      gallery: link.groupGallery(group, {
-                        text: language.$('listingPage.listGroups.byCategory.group.gallery'),
-                      }),
-                    }))))),
-      ])),
+  contentFunction: 'listGroupsByCategory',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
   directory: 'groups/by-albums',
   stringsKey: 'listGroups.byAlbums',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupData}}) =>
-    groupData
-      .map(group => ({
-        group,
-        albums: group.albums.length
-      }))
-      .sort((a, b) => b.albums - a.albums),
-
-  row: ({group, albums}, {language, link}) =>
-    language.$('listingPage.listGroups.byAlbums.item', {
-      group: link.groupInfo(group),
-      albums: language.countAlbums(albums, {unit: true}),
-    }),
+  contentFunction: 'listGroupsByAlbums',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
   directory: 'groups/by-tracks',
   stringsKey: 'listGroups.byTracks',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupData}}) =>
-    groupData
-      .map((group) => ({
-        group,
-        tracks: accumulateSum(
-          group.albums,
-          ({tracks}) => tracks.length),
-      }))
-      .sort((a, b) => b.tracks - a.tracks),
-
-  row: ({group, tracks}, {language, link}) =>
-    language.$('listingPage.listGroups.byTracks.item', {
-      group: link.groupInfo(group),
-      tracks: language.countTracks(tracks, {unit: true}),
-    }),
+  contentFunction: 'listGroupsByTracks',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
   directory: 'groups/by-duration',
   stringsKey: 'listGroups.byDuration',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupData}}) =>
-    groupData
-      .map(group => ({
-        group,
-        duration: getTotalDuration(
-          group.albums.flatMap(album => album.tracks),
-          {originalReleasesOnly: true}),
-      }))
-      .filter(({duration}) => duration > 0)
-      .sort((a, b) => b.duration - a.duration),
-
-  row: ({group, duration}, {language, link}) =>
-    language.$('listingPage.listGroups.byDuration.item', {
-      group: link.groupInfo(group),
-      duration: language.formatDuration(duration),
-    }),
+  contentFunction: 'listGroupsByDuration',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
   directory: 'groups/by-latest-album',
   stringsKey: 'listGroups.byLatest',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableGroupUI,
-
-  data: ({wikiData: {groupData}}) =>
-    sortChronologically(
-      groupData
-        .map(group => {
-          const albums = group.albums.filter(a => a.date);
-          return !empty(albums) && {
-            group,
-            directory: group.directory,
-            name: group.name,
-            date: albums[albums.length - 1].date,
-          };
-        })
-        .filter(Boolean),
-      {latestFirst: true}),
-
-  row: ({group, date}, {language, link}) =>
-    language.$('listingPage.listGroups.byLatest.item', {
-      group: link.groupInfo(group),
-      date: language.formatDate(date),
-    }),
+  contentFunction: 'listGroupsByLatestAlbum',
+  featureFlag: 'enableGroupUI',
 });
 
 listingSpec.push({
@@ -737,9 +284,7 @@ listingSpec.push({
 listingSpec.push({
   directory: 'tracks/in-flashes/by-album',
   stringsKey: 'listTracks.inFlashes.byAlbum',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableFlashesAndGames,
+  featureFlag: 'enableFlashesAndGames',
 
   data: ({wikiData: {trackData}}) =>
     chunkByProperties(
@@ -771,9 +316,7 @@ listingSpec.push({
 listingSpec.push({
   directory: 'tracks/in-flashes/by-flash',
   stringsKey: 'listTracks.inFlashes.byFlash',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableFlashesAndGames,
+  featureFlag: 'enableFlashesAndGames',
 
   data: ({wikiData: {flashData}}) =>
     sortFlashesChronologically(flashData.slice())
@@ -872,9 +415,7 @@ listingSpec.push(listTracksWithProperty('midiProjectFiles', {
 listingSpec.push({
   directory: 'tags/by-name',
   stringsKey: 'listTags.byName',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableArtTagUI,
+  featureFlag: 'enableArtTagUI',
 
   data: ({wikiData: {artTagData}}) =>
     sortAlphabetically(
@@ -899,9 +440,7 @@ listingSpec.push({
 listingSpec.push({
   directory: 'tags/by-uses',
   stringsKey: 'listTags.byUses',
-
-  condition: ({wikiData: {wikiInfo}}) =>
-    wikiInfo.enableArtTagUI,
+  featureFlag: 'enableArtTagUI',
 
   data: ({wikiData: {artTagData}}) =>
     artTagData
@@ -1086,34 +625,75 @@ listingSpec.push({
     ]),
 });
 
+{
+  const errors = [];
+
+  for (const listing of listingSpec) {
+    if (listing.seeAlso) {
+      const suberrors = [];
+
+      for (let i = 0; i < listing.seeAlso.length; i++) {
+        const directory = listing.seeAlso[i];
+        const match = listingSpec.find(listing => listing.directory === directory);
+
+        if (match) {
+          listing.seeAlso[i] = match;
+        } else {
+          listing.seeAlso[i] = null;
+          suberrors.push(new Error(`(index: ${i}) Didn't find a listing matching ${directory}`))
+        }
+      }
+
+      listing.seeAlso = listing.seeAlso.filter(Boolean);
+
+      if (!empty(suberrors)) {
+        errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`));
+      }
+    } else {
+      listing.seeAlso = null;
+    }
+  }
+
+  if (!empty(errors)) {
+    const aggregate = new AggregateError(errors, `Errors validating listings`);
+    showAggregate(aggregate, {showTraces: false});
+  }
+}
+
 const filterListings = (directoryPrefix) =>
   listingSpec.filter(l => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
   {
-    title: ({language}) => language.$('listingPage.target.album'),
+    stringsKey: 'album',
     listings: filterListings('album'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.artist'),
+    stringsKey: 'artist',
     listings: filterListings('artist'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.group'),
+    stringsKey: 'group',
     listings: filterListings('group'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.track'),
+    stringsKey: 'track',
     listings: filterListings('track'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.tag'),
+    stringsKey: 'tag',
     listings: filterListings('tag'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.other'),
+    stringsKey: 'other',
     listings: listingSpec.filter(l => l.groupUnderOther),
   },
 ];
 
+for (const target of listingTargetSpec) {
+  for (const listing of target.listings) {
+    listing.target = target;
+  }
+}
+
 export {listingSpec, listingTargetSpec};
diff --git a/src/misc-templates.js b/src/misc-templates.js
index a34771c7..ba1a60f1 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -16,547 +16,8 @@ import {
   getTotalDuration,
   sortAlbumsTracksChronologically,
   sortChronologically,
-  sortFlashesChronologically,
 } from './util/wiki-data.js';
 
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-
-const MASTODON_DOMAINS = ['types.pl'];
-
-// "Additional Files" listing
-
-function unbound_generateAdditionalFilesShortcut(additionalFiles, {
-  html,
-  language,
-}) {
-  if (empty(additionalFiles)) return '';
-
-  return language.$('releaseInfo.additionalFiles.shortcut', {
-    anchorLink:
-      html.tag('a',
-        {href: '#additional-files'},
-        language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-    titles: language.formatUnitList(
-      additionalFiles.map(g => g.title)),
-  });
-}
-
-function unbound_generateAdditionalFilesList(additionalFiles, {
-  html,
-  language,
-
-  getFileSize,
-  linkFile,
-}) {
-  if (empty(additionalFiles)) return [];
-
-  return html.tag('dl',
-    additionalFiles.flatMap(({title, description, files}) => [
-      html.tag('dt',
-        (description
-          ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-              title,
-              description,
-            })
-          : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-      html.tag('dd',
-        html.tag('ul',
-          files.map((file) => {
-            const size = (getFileSize && getFileSize(file));
-            return html.tag('li',
-              (size
-                ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                    file: linkFile(file),
-                    size: language.formatFileSize(size),
-                  })
-                : language.$('releaseInfo.additionalFiles.file', {
-                    file: linkFile(file),
-                  })))
-          }))),
-    ]));
-}
-
-// Artist strings
-
-function unbound_getArtistString(artists, {
-  html,
-  language,
-  link,
-
-  iconifyURL,
-
-  showIcons = false,
-  showContrib = false,
-}) {
-  return language.formatConjunctionList(
-    artists.map(({who, what}) => {
-      const {urls} = who;
-
-      const hasContribPart = !!(showContrib && what);
-      const hasExternalPart = !!(showIcons && !empty(urls));
-
-      const artistLink = link.artist(who);
-
-      const externalLinks = hasExternalPart &&
-        html.tag('span',
-          {
-            [html.noEdgeWhitespace]: true,
-            class: 'icons'
-          },
-          language.formatUnitList(
-            urls.slice(0, 4).map(url => iconifyURL(url, {language}))));
-
-      return html.tag('span', {class: 'nowrap'},
-        (hasContribPart
-          ? (hasExternalPart
-              ? language.$('misc.artistLink.withContribution.withExternalLinks', {
-                  artist: artistLink,
-                  contrib: what,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink.withContribution', {
-                  artist: artistLink,
-                  contrib: what,
-                }))
-          : (hasExternalPart
-              ? language.$('misc.artistLink.withExternalLinks', {
-                  artist: artistLink,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink', {
-                  artist: artistLink,
-                }))));
-    }));
-}
-
-// Chronology links
-
-function unbound_generateChronologyLinks(currentThing, {
-  html,
-  language,
-  link,
-
-  generateNavigationLinks,
-
-  dateKey = 'date',
-  contribKey,
-  getThings,
-  headingString,
-}) {
-  const contributions = currentThing[contribKey];
-
-  if (empty(contributions)) {
-    return [];
-  }
-
-  if (contributions.length > 8) {
-    return html.tag('div', {class: 'chronology'},
-      language.$('misc.chronology.seeArtistPages'));
-  }
-
-  return contributions
-    .map(({who: artist}) => {
-      const thingsUnsorted = unique(getThings(artist))
-        .filter((t) => t[dateKey]);
-
-      // Kinda a hack, but we automatically detect which is (probably) the
-      // right function to use here.
-      const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}];
-      const things = (
-        thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track)
-          ? sortAlbumsTracksChronologically(...args)
-      : thingsUnsorted.every(t => t instanceof T.Flash)
-          ? sortFlashesChronologically(...args)
-          : sortChronologically(...args));
-
-      if (things.length === 0) return '';
-
-      const index = things.indexOf(currentThing);
-
-      if (index === -1) return '';
-
-      const heading = (
-        html.tag('span', {class: 'heading'},
-          language.$(headingString, {
-            index: language.formatIndex(index + 1, {language}),
-            artist: link.artist(artist),
-          })));
-
-      const navigation = things.length > 1 &&
-        html.tag('span',
-          {
-            [html.onlyIfContent]: true,
-            class: 'buttons',
-          },
-          generateNavigationLinks(currentThing, {
-            data: things,
-            isMain: false,
-          }));
-
-      return (
-        html.tag('div', {class: 'chronology'},
-          (navigation
-            ? language.$('misc.chronology.withNavigation', {
-                heading,
-                navigation,
-              })
-            : heading)));
-    });
-}
-
-// Content warning tags
-
-function unbound_getRevealStringFromContentWarningMessage(warnings, {
-  html,
-  language,
-}) {
-  return (
-    language.$('misc.contentWarnings', {warnings}) +
-    html.tag('br') +
-    html.tag('span', {class: 'reveal-interaction'},
-      language.$('misc.contentWarnings.reveal'))
-  );
-}
-
-function unbound_getRevealStringFromArtTags(tags, {
-  getRevealStringFromContentWarningMessage,
-  language,
-}) {
-  return (
-    tags?.some(tag => tag.isContentWarning) &&
-      getRevealStringFromContentWarningMessage(
-        language.formatUnitList(
-          tags
-            .filter(tag => tag.isContentWarning)
-            .map(tag => tag.name)))
-  );
-}
-
-// Cover art links
-
-function unbound_generateCoverLink({
-  html,
-  img,
-  language,
-  link,
-
-  getRevealStringFromArtTags,
-
-  alt,
-  path,
-  src,
-  tags = [],
-  to,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
-
-  if (!src && path) {
-    src = to(...path);
-  }
-
-  if (!src) {
-    throw new Error(`Expected src or path`);
-  }
-
-  const linkedTags = tags.filter(tag => !tag.isContentWarning);
-
-  return html.tag('div', {id: 'cover-art-container'}, [
-    img({
-      src,
-      alt,
-      thumb: 'medium',
-      id: 'cover-art',
-      link: true,
-      square: true,
-      reveal: getRevealStringFromArtTags(tags),
-    }),
-
-    wikiInfo.enableArtTagUI &&
-    linkedTags.length &&
-      html.tag('p', {class: 'tags'},
-        language.$('releaseInfo.artTags.inline', {
-          tags: language.formatUnitList(
-            linkedTags.map(tag => link.tag(tag))),
-        })),
-  ]);
-}
-
-// CSS & color shenanigans
-
-function unbound_getThemeString(color, {
-  getColors,
-
-  additionalVariables = [],
-} = {}) {
-  if (!color) return '';
-
-  const {
-    primary,
-    dark,
-    dim,
-    dimGhost,
-    bg,
-    bgBlack,
-    shadow,
-  } = getColors(color);
-
-  const variables = [
-    `--primary-color: ${primary}`,
-    `--dark-color: ${dark}`,
-    `--dim-color: ${dim}`,
-    `--dim-ghost-color: ${dimGhost}`,
-    `--bg-color: ${bg}`,
-    `--bg-black-color: ${bgBlack}`,
-    `--shadow-color: ${shadow}`,
-    ...additionalVariables,
-  ].filter(Boolean);
-
-  if (!variables.length) return '';
-
-  return [
-    `:root {`,
-    ...variables.map((line) => `    ${line};`),
-    `}`
-  ].join('\n');
-}
-
-function unbound_getAlbumStylesheet(album, {
-  to,
-}) {
-  const hasWallpaper = album.wallpaperArtistContribs.length >= 1;
-  const hasWallpaperStyle = !!album.wallpaperStyle;
-  const hasBannerStyle = !!album.bannerStyle;
-
-  const wallpaperSource =
-    (hasWallpaper &&
-      to(
-        'media.albumWallpaper',
-        album.directory,
-        album.wallpaperFileExtension));
-
-  const wallpaperPart =
-    (hasWallpaper
-      ? [
-          `body::before {`,
-          `    background-image: url("${wallpaperSource}");`,
-          ...(hasWallpaperStyle
-            ? album.wallpaperStyle
-                .split('\n')
-                .map(line => `    ${line}`)
-            : []),
-          `}`,
-        ]
-      : []);
-
-  const bannerPart =
-    (hasBannerStyle
-      ? [
-          `#banner img {`,
-          ...album.bannerStyle
-            .split('\n')
-            .map(line => `    ${line}`),
-          `}`,
-        ]
-      : []);
-
-  return [
-    ...wallpaperPart,
-    ...bannerPart,
-  ]
-    .filter(Boolean)
-    .join('\n');
-}
-
-// Divided track lists
-
-function unbound_generateTrackListDividedByGroups(tracks, {
-  html,
-  language,
-
-  getTrackItem,
-  wikiData,
-}) {
-  const {divideTrackListsByGroups: groups} = wikiData.wikiInfo;
-
-  if (empty(groups)) {
-    return html.tag('ul',
-      tracks.map(t => getTrackItem(t)));
-  }
-
-  const lists = Object.fromEntries(
-    groups.map((group) => [
-      group.directory,
-      {group, tracks: []}
-    ]));
-
-  const other = [];
-
-  for (const track of tracks) {
-    const {album} = track;
-    const group = groups.find((g) => g.albums.includes(album));
-    if (group) {
-      lists[group.directory].tracks.push(track);
-    } else {
-      other.push(track);
-    }
-  }
-
-  const dt = name =>
-    html.tag('dt',
-      language.$('trackList.group', {
-        group: name,
-      }));
-
-  const ddul = tracks =>
-    html.tag('dd',
-      html.tag('ul',
-        tracks.map(t => getTrackItem(t))));
-
-  return html.tag('dl', [
-    ...Object.values(lists)
-      .filter(({tracks}) => tracks.length)
-      .flatMap(({group, tracks}) => [
-        dt(group.name),
-        ddul(tracks),
-      ]),
-
-    ...html.fragment(
-      other.length && [
-        dt(language.$('trackList.group.other')),
-        ddul(other),
-      ]),
-  ]);
-}
-
-// Fancy lookin' links
-
-function unbound_fancifyURL(url, {
-  html,
-  language,
-
-  album = false,
-} = {}) {
-  let local = Symbol();
-  let domain;
-  try {
-    domain = new URL(url).hostname;
-  } catch (error) {
-    // No support for relative local URLs yet, sorry! (I.e, local URLs must
-    // be absolute relative to the domain name in order to work.)
-    domain = local;
-  }
-
-  return html.tag('a',
-    {
-      href: url,
-      class: 'nowrap',
-    },
-
-    // truly unhinged indentation here
-    domain === local
-      ? language.$('misc.external.local')
-  : domain.includes('bandcamp.com')
-    ? language.$('misc.external.bandcamp')
-  : BANDCAMP_DOMAINS.includes(domain)
-    ? language.$('misc.external.bandcamp.domain', {domain})
-  : MASTODON_DOMAINS.includes(domain)
-    ? language.$('misc.external.mastodon.domain', {domain})
-  : domain.includes('youtu')
-    ? album
-      ? url.includes('list=')
-        ? language.$('misc.external.youtube.playlist')
-        : language.$('misc.external.youtube.fullAlbum')
-      : language.$('misc.external.youtube')
-  : domain.includes('soundcloud')
-    ? language.$('misc.external.soundcloud')
-  : domain.includes('tumblr.com')
-    ? language.$('misc.external.tumblr')
-  : domain.includes('twitter.com')
-    ? language.$('misc.external.twitter')
-  : domain.includes('deviantart.com')
-    ? language.$('misc.external.deviantart')
-  : domain.includes('wikipedia.org')
-    ? language.$('misc.external.wikipedia')
-  : domain.includes('poetryfoundation.org')
-    ? language.$('misc.external.poetryFoundation')
-  : domain.includes('instagram.com')
-    ? language.$('misc.external.instagram')
-  : domain.includes('patreon.com')
-    ? language.$('misc.external.patreon')
-  : domain.includes('spotify.com')
-    ? language.$('misc.external.spotify')
-  : domain.includes('newgrounds.com')
-    ? language.$('misc.external.newgrounds')
-    : domain);
-}
-
-function unbound_fancifyFlashURL(url, flash, {
-  html,
-  language,
-
-  fancifyURL,
-}) {
-  const link = fancifyURL(url);
-  return html.tag('span',
-    {class: 'nowrap'},
-    url.includes('homestuck.com')
-      ? isNaN(Number(flash.page))
-        ? language.$('misc.external.flash.homestuck.secret', {link})
-        : language.$('misc.external.flash.homestuck.page', {
-            link,
-            page: flash.page,
-          })
-  : url.includes('bgreco.net')
-    ? language.$('misc.external.flash.bgreco', {link})
-  : url.includes('youtu')
-    ? language.$('misc.external.flash.youtube', {link})
-    : link);
-}
-
-function unbound_iconifyURL(url, {
-  html,
-  language,
-  to,
-}) {
-  const domain = new URL(url).hostname;
-  const [id, msg] = (
-    domain.includes('bandcamp.com')
-      ? ['bandcamp', language.$('misc.external.bandcamp')]
-    : BANDCAMP_DOMAINS.includes(domain)
-      ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
-    : MASTODON_DOMAINS.includes(domain)
-      ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
-    : domain.includes('youtu')
-      ? ['youtube', language.$('misc.external.youtube')]
-    : domain.includes('soundcloud')
-      ? ['soundcloud', language.$('misc.external.soundcloud')]
-    : domain.includes('tumblr.com')
-      ? ['tumblr', language.$('misc.external.tumblr')]
-    : domain.includes('twitter.com')
-      ? ['twitter', language.$('misc.external.twitter')]
-    : domain.includes('deviantart.com')
-      ? ['deviantart', language.$('misc.external.deviantart')]
-    : domain.includes('instagram.com')
-      ? ['instagram', language.$('misc.external.bandcamp')]
-    : domain.includes('newgrounds.com')
-      ? ['newgrounds', language.$('misc.external.newgrounds')]
-      : ['globe', language.$('misc.external.domain', {domain})]);
-
-  return html.tag('a',
-    {
-      href: url,
-      class: 'icon',
-    },
-    html.tag('svg', [
-      html.tag('title', msg),
-      html.tag('use', {
-        href: to('shared.staticIcon', id),
-      }),
-    ]));
-}
-
 // Grids
 
 function unbound_getGridHTML({
@@ -636,167 +97,8 @@ function unbound_getFlashGridHTML({
   });
 }
 
-// Images
-
-function unbound_img({
-  getSizeOfImageFile,
-  html,
-  to,
-
-  src,
-  alt,
-  noSrcText = '',
-  thumb: thumbKey,
-  reveal,
-  id,
-  class: className,
-  width,
-  height,
-  link = false,
-  lazy = false,
-  square = false,
-}) {
-  const willSquare = square;
-  const willLink = typeof link === 'string' || link;
-
-  const originalSrc = src;
-  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-  const href =
-    (willLink
-      ? (typeof link === 'string'
-          ? link
-          : originalSrc)
-      : null);
-
-  let fileSize = null;
-  const mediaRoot = to('media.root');
-  if (href?.startsWith(mediaRoot)) {
-    fileSize = getSizeOfImageFile(href.slice(mediaRoot.length).replace(/^\//, ''));
-  }
-
-  const imgAttributes = {
-    id: link ? '' : id,
-    class: className,
-    alt,
-    width,
-    height,
-    'data-original-size': fileSize,
-  };
-
-  const noSrcHTML =
-    !src &&
-      wrap(
-        html.tag('div',
-          {class: 'image-text-area'},
-          noSrcText));
-
-  const nonlazyHTML =
-    src &&
-      wrap(
-        html.tag('img', {
-          ...imgAttributes,
-          src: thumbSrc,
-        }));
-
-  const lazyHTML =
-    src &&
-    lazy &&
-      wrap(
-        html.tag('img',
-          {
-            ...imgAttributes,
-            class: [className, 'lazy'],
-            'data-original': thumbSrc,
-          }),
-        true);
-
-  if (!src) {
-    return noSrcHTML;
-  } else if (lazy) {
-    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
-  } else {
-    return nonlazyHTML;
-  }
-
-  function wrap(input, hide = false) {
-    let wrapped = input;
-
-    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
-
-    if (reveal) {
-      wrapped = html.tag('div', {class: 'reveal'}, [
-        wrapped,
-        html.tag('span', {class: 'reveal-text-container'},
-          html.tag('span', {class: 'reveal-text'}, reveal)),
-      ]);
-    }
-
-    if (willSquare) {
-      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-      wrapped = html.tag('div',
-        {class: ['square', hide && !willLink && 'js-hide']},
-        wrapped);
-    }
-
-    if (willLink) {
-      wrapped = html.tag('a',
-        {
-          id,
-          class: ['box', hide && 'js-hide', 'image-link'],
-          href,
-        },
-        wrapped);
-    }
-
-    return wrapped;
-  }
-}
-
 // Carousel reels
 
-// Layout constants:
-//
-// Carousels support fitting 4-18 items, with a few "dead" zones to watch out
-// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles.
-//
-// Carousels are limited to 1-3 rows and 4-6 columns.
-// Lower edge case: 1-3 items are treated as 4 items (with blank space).
-// Upper edge case: all items past 18 are dropped (treated as 18 items).
-//
-// This is all done through JS instead of CSS because it's just... ANNOYING...
-// to write a mapping like this in CSS lol.
-const carouselLayoutMap = [
-  // 0-3
-  null, null, null, null,
-
-  // 4-6
-  {rows: 1, columns: 4}, //  4: 1x4, drop 0
-  {rows: 1, columns: 5}, //  5: 1x5, drop 0
-  {rows: 1, columns: 6}, //  6: 1x6, drop 0
-
-  // 7-12
-  {rows: 1, columns: 6}, //  7: 1x6, drop 1
-  {rows: 2, columns: 4}, //  8: 2x4, drop 0
-  {rows: 2, columns: 4}, //  9: 2x4, drop 1
-  {rows: 2, columns: 5}, // 10: 2x5, drop 0
-  {rows: 2, columns: 5}, // 11: 2x5, drop 1
-  {rows: 2, columns: 6}, // 12: 2x6, drop 0
-
-  // 13-18
-  {rows: 2, columns: 6}, // 13: 2x6, drop 1
-  {rows: 2, columns: 6}, // 14: 2x6, drop 2
-  {rows: 3, columns: 5}, // 15: 3x5, drop 0
-  {rows: 3, columns: 5}, // 16: 3x5, drop 1
-  {rows: 3, columns: 5}, // 17: 3x5, drop 2
-  {rows: 3, columns: 6}, // 18: 3x6, drop 0
-];
-
-const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null);
-const maxCarouselLayoutItems = carouselLayoutMap.length - 1;
-const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems];
-const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems];
-
 function unbound_getCarouselHTML({
   html,
   img,
@@ -808,271 +110,13 @@ function unbound_getCarouselHTML({
   linkFn = (x, {text}) => text,
   srcFn,
 }) {
-  if (empty(items)) {
-    return;
-  }
-
-  const {rows, columns} = (
-    items.length < minCarouselLayoutItems ? shortestCarouselLayout :
-    items.length > maxCarouselLayoutItems ? longestCarouselLayout :
-    carouselLayoutMap[items.length]);
-
-  items = items.slice(0, maxCarouselLayoutItems + 1);
-
-  return html.tag('div',
-    {
-      class: 'carousel-container',
-      'data-carousel-rows': rows,
-      'data-carousel-columns': columns,
-    },
-    repeat(3,
-      html.tag('div',
-        {
-          class: 'carousel-grid',
-          'aria-hidden': 'true',
-        },
-        items
-          .filter(item => srcFn(item))
-          .filter(item => item.artTags.every(tag => !tag.isContentWarning))
-          .map((item, i) =>
-            html.tag('div', {class: 'carousel-item'},
-              linkFn(item, {
-                attributes: {
-                  tabindex: '-1',
-                },
-                text:
-                  img({
-                    src: srcFn(item),
-                    alt: altFn(item),
-                    thumb: 'small',
-                    lazy: typeof lazy === 'number' ? i >= lazy : lazy,
-                    square: true,
-                  }),
-              }))))));
-}
-
-// Nav-bar links
-
-function unbound_generateInfoGalleryLinks(currentThing, isGallery, {
-  link,
-  language,
-
-  linkKeyGallery,
-  linkKeyInfo,
-}) {
-  return [
-    link[linkKeyInfo](currentThing, {
-      class: isGallery ? '' : 'current',
-      text: language.$('misc.nav.info'),
-    }),
-    link[linkKeyGallery](currentThing, {
-      class: isGallery ? 'current' : '',
-      text: language.$('misc.nav.gallery'),
-    }),
-  ].join(', ');
-}
-
-// Generate "previous" and "next" links relative to a given current thing and a
-// data set (array of things) which includes it, optionally including additional
-// provided links like "random". This is for use in navigation bars and other
-// inline areas.
-//
-// By default, generated links include ID attributes which enable client-side
-// keyboard shortcuts. Provide isMain: false to disable this (if the generated
-// links aren't the for the page's primary navigation).
-function unbound_generateNavigationLinks(current, {
-  language,
-  link,
-
-  additionalLinks = [],
-  data,
-  isMain = true,
-  linkKey = 'anything',
-  returnAsArray = false,
-}) {
-  let previousLink, nextLink;
-
-  if (current) {
-    const linkFn = link[linkKey].bind(link);
-
-    const index = data.indexOf(current);
-    const previousThing = data[index - 1];
-    const nextThing = data[index + 1];
-
-    previousLink = previousThing &&
-      linkFn(previousThing, {
-        attributes: {
-          id: isMain && 'previous-button',
-          title: previousThing.name,
-        },
-        text: language.$('misc.nav.previous'),
-        color: false,
-      });
-
-    nextLink = nextThing &&
-      linkFn(nextThing, {
-        attributes: {
-          id: isMain && 'next-button',
-          title: nextThing.name,
-        },
-        text: language.$('misc.nav.next'),
-        color: false,
-      });
-  }
-
-  const links = [
-    previousLink,
-    nextLink,
-    ...additionalLinks,
-  ].filter(Boolean);
-
-  if (returnAsArray) {
-    return links;
-  } else if (empty(links)) {
-    return '';
-  } else {
-    return language.formatUnitList(links);
-  }
-}
-
-// Sticky heading, ooooo
-
-function unbound_generateContentHeading({
-  html,
-
-  id,
-  title,
-}) {
-  return html.tag('p',
-    {
-      class: 'content-heading',
-      id,
-      tabindex: '0',
-    },
-    title);
-}
-
-function unbound_generateStickyHeadingContainer({
-  html,
-  img,
-
-  class: classes,
-  coverSrc,
-  coverAlt,
-  coverArtTags,
-  title,
-}) {
-  return html.tag('div',
-    {class: [
-      'content-sticky-heading-container',
-      coverSrc && 'has-cover',
-    ].concat(classes)},
-    [
-      html.tag('div', {class: 'content-sticky-heading-row'}, [
-        html.tag('h1', title),
-
-        // Cover art in the sticky heading never uses the 'reveal' setting
-        // because it's too small to effectively display content warnings.
-        // Instead, if art has content warnings, it's hidden from the sticky
-        // heading by default, and will be enabled once the main cover art
-        // is revealed.
-        coverSrc &&
-          html.tag('div', {class: 'content-sticky-heading-cover-container'},
-            html.tag('div',
-              {
-                class: [
-                  'content-sticky-heading-cover',
-                  coverArtTags.some(tag => tag.isContentWarning) &&
-                    'content-sticky-heading-cover-needs-reveal',
-                ],
-              },
-              img({
-                src: coverSrc,
-                alt: coverAlt,
-                thumb: 'small',
-                link: false,
-                square: true,
-              }))),
-      ]),
-
-      html.tag('div', {class: 'content-sticky-subheading-row'},
-        html.tag('h2', {class: 'content-sticky-subheading'})),
-    ]);
-}
-
-// Footer stuff
-
-function unbound_getFooterLocalizationLinks({
-  html,
-  defaultLanguage,
-  language,
-  languages,
-  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)));
-
-  return html.tag('div', {class: 'footer-localization-links'},
-    language.$('misc.uiLanguage', {
-      languages: links.join('\n'),
-    }));
 }
 
 // Exports
 
 export {
-  unbound_generateAdditionalFilesList as generateAdditionalFilesList,
-  unbound_generateAdditionalFilesShortcut as generateAdditionalFilesShortcut,
-
-  unbound_getArtistString as getArtistString,
-
-  unbound_generateChronologyLinks as generateChronologyLinks,
-
-  unbound_getRevealStringFromContentWarningMessage as getRevealStringFromContentWarningMessage,
-  unbound_getRevealStringFromArtTags as getRevealStringFromArtTags,
-
-  unbound_generateCoverLink as generateCoverLink,
-
-  unbound_getThemeString as getThemeString,
-  unbound_getAlbumStylesheet as getAlbumStylesheet,
-
-  unbound_generateTrackListDividedByGroups as generateTrackListDividedByGroups,
-
-  unbound_fancifyURL as fancifyURL,
-  unbound_fancifyFlashURL as fancifyFlashURL,
-  unbound_iconifyURL as iconifyURL,
-
   unbound_getGridHTML as getGridHTML,
   unbound_getAlbumGridHTML as getAlbumGridHTML,
   unbound_getFlashGridHTML as getFlashGridHTML,
-
   unbound_getCarouselHTML as getCarouselHTML,
-
-  unbound_img as img,
-
-  unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
-  unbound_generateNavigationLinks as generateNavigationLinks,
-
-  unbound_generateContentHeading as generateContentHeading,
-  unbound_generateStickyHeadingContainer as generateStickyHeadingContainer,
-
-  unbound_getFooterLocalizationLinks as getFooterLocalizationLinks,
 }
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
deleted file mode 100644
index eb462d9a..00000000
--- a/src/page/album-commentary.js
+++ /dev/null
@@ -1,137 +0,0 @@
-// Album commentary page and index specifications.
-
-import {generateAlbumExtrasPageNav} from './album.js';
-import {accumulateSum} from '../util/sugar.js';
-import {filterAlbumsByCommentary} from '../util/wiki-data.js';
-
-export const description = `per-album artist commentary pages & index`
-
-export function condition({wikiData}) {
-  return filterAlbumsByCommentary(wikiData.albumData).length;
-}
-
-export function targets({wikiData}) {
-  return filterAlbumsByCommentary(wikiData.albumData);
-}
-
-export function write(album) {
-  const entries = [album, ...album.tracks]
-    .filter((x) => x.commentary)
-    .map((x) => x.commentary);
-  const words = entries.join(' ').split(' ').length;
-
-  const page = {
-    type: 'page',
-    path: ['albumCommentary', album.directory],
-    page: ({
-      getAlbumStylesheet,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('albumCommentaryPage.title', {album: album.name}),
-      stylesheet: getAlbumStylesheet(album),
-      theme: getThemeString(album.color),
-
-      main: {
-        classes: ['long-content'],
-        headingMode: 'sticky',
-
-        content: [
-          html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
-              words: html.tag('b', language.formatWordCount(words, {unit: true})),
-              entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})),
-            })),
-
-          ...html.fragment(album.commentary && [
-            html.tag('h3',
-              {class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.albumCommentary')),
-
-            html.tag('blockquote',
-              transformMultiline(album.commentary)),
-          ]),
-
-          ...album.tracks.filter(t => t.commentary).flatMap(track => [
-            html.tag('h3',
-              {id: track.directory, class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                track: link.track(track),
-              })),
-
-            html.tag('blockquote',
-              {style: getLinkThemeString(track.color)},
-              transformMultiline(track.commentary)),
-          ])
-        ],
-      },
-
-      nav: generateAlbumExtrasPageNav(album, 'commentary', {
-        html,
-        language,
-        link,
-      }),
-    }),
-  };
-
-  return [page];
-}
-
-export function writeTargetless({wikiData}) {
-  const data = filterAlbumsByCommentary(wikiData.albumData)
-    .map((album) => ({
-      album,
-      entries: [album, ...album.tracks]
-        .filter((x) => x.commentary)
-        .map((x) => x.commentary),
-    }))
-    .map(({album, entries}) => ({
-      album,
-      entries,
-      words: entries.join(' ').split(' ').length,
-    }));
-
-  const totalEntries = accumulateSum(data, ({entries}) => entries.length);
-  const totalWords = accumulateSum(data, ({words}) => words);
-
-  const page = {
-    type: 'page',
-    path: ['commentaryIndex'],
-    page: ({
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('commentaryIndex.title'),
-
-      main: {
-        classes: ['long-content'],
-        headingMode: 'static',
-
-        content: [
-          html.tag('p', language.$('commentaryIndex.infoLine', {
-            words: html.tag('b', language.formatWordCount(totalWords, {unit: true})),
-            entries: html.tag('b', language.countCommentaryEntries(totalEntries, {unit: true})),
-          })),
-
-          html.tag('p', language.$('commentaryIndex.albumList.title')),
-
-          html.tag('ul', data.map(({album, entries, words}) =>
-            html.tag('li', language.$('commentaryIndex.albumList.item', {
-              album: link.albumCommentary(album),
-              words: language.formatWordCount(words, {unit: true}),
-              entries: language.countCommentaryEntries(entries.length, {unit: true}),
-            })))),
-        ],
-      },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
-}
diff --git a/src/page/album.js b/src/page/album.js
index 9ee57c09..a8e0b591 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,66 +1,62 @@
 // Album page specification.
 
-import {
-  bindOpts,
-  compareArrays,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getAlbumCover,
-  getAlbumListTag,
-  getTotalDuration,
-} from '../util/wiki-data.js';
-
-export const description = `per-album info & track artwork gallery pages`;
+export const description = `per-album info, artwork gallery & commentary pages`;
 
 export function targets({wikiData}) {
   return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-  const unbound_trackToListItem = (track, {
-    getArtistString,
-    getLinkThemeString,
-    html,
-    language,
-    link,
-  }) => {
-    const itemOpts = {
-      duration: language.formatDuration(track.duration ?? 0),
-      track: link.track(track),
-    };
+export function pathsForTarget(album) {
+  const hasGalleryPage = album.tracks.some(t => t.hasUniqueCoverArt);
+  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
 
-    return html.tag('li',
-      {style: getLinkThemeString(track.color)},
-      compareArrays(
-        track.artistContribs.map((c) => c.who),
-        album.artistContribs.map((c) => c.who),
-        {checkOrder: false}
-      )
-        ? language.$('trackList.item.withDuration', itemOpts)
-        : language.$('trackList.item.withDuration.withArtists', {
-            ...itemOpts,
-            by: html.tag('span',
-              {class: 'by'},
-              language.$('trackList.item.withArtists.by', {
-                artists: getArtistString(track.artistContribs),
-              })),
-          }));
-  };
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
 
-  const hasAdditionalFiles = !empty(album.additionalFiles);
-  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
 
-  const albumDuration = getTotalDuration(album.tracks);
+    hasGalleryPage && {
+      type: 'page',
+      path: ['albumGallery', album.directory],
 
-  const displayTrackSections =
-    album.trackSections &&
-      (album.trackSections.length > 1 ||
-        !album.trackSections[0]?.isDefaultTrackSection);
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
+
+    hasCommentaryPage && {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [album],
+      },
+    },
 
-  const listTag = getAlbumListTag(album);
+    /*
+    {
+      type: 'data',
+      path: ['album', album.directory],
 
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
+    */
+  ];
+}
+
+/*
+export function write(album, {wikiData}) {
   const getSocialEmbedDescription = ({
     getArtistString: _getArtistString,
     language,
@@ -123,297 +119,6 @@ export function write(album, {wikiData}) {
     }),
   };
 
-  const infoPage = {
-    type: 'page',
-    path: ['album', album.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateAdditionalFilesShortcut,
-      generateAdditionalFilesList,
-      generateChronologyLinks,
-      generateContentHeading,
-      generateNavigationLinks,
-      getAlbumCover,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      html,
-      link,
-      language,
-      transformMultiline,
-      urls,
-    }) => {
-      const trackToListItem = bindOpts(unbound_trackToListItem, {
-        getArtistString,
-        getLinkThemeString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('albumPage.title', {album: album.name}),
-        stylesheet: getAlbumStylesheet(album),
-
-        themeColor: album.color,
-        theme:
-          getThemeString(album.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-            ],
-          }),
-
-        socialEmbed: {
-          heading:
-            (empty(album.groups)
-              ? ''
-              : language.$('albumPage.socialEmbed.heading', {
-                  group: album.groups[0].name,
-                })),
-          headingLink:
-            (empty(album.groups)
-              ? null
-              : absoluteTo('localized.album', album.groups[0].directory)),
-          title: language.$('albumPage.socialEmbed.title', {
-            album: album.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getAlbumCover(album, {to: urls.from('shared.root').to}),
-          color: album.color,
-        },
-
-        banner: !empty(album.bannerArtistContribs) && {
-          dimensions: album.bannerDimensions,
-          path: [
-            'media.albumBanner',
-            album.directory,
-            album.bannerFileExtension,
-          ],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'top',
-        },
-
-        cover: {
-          src: getAlbumCover(album),
-          alt: language.$('misc.alt.albumCover'),
-          artTags: album.artTags,
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(album.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(album.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(album.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.wallpaperArtistContribs) &&
-                  language.$('releaseInfo.wallpaperArtBy', {
-                    artists: getArtistString(album.wallpaperArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.bannerArtistContribs) &&
-                  language.$('releaseInfo.bannerArtBy', {
-                    artists: getArtistString(album.bannerArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                album.date &&
-                  language.$('releaseInfo.released', {
-                    date: language.formatDate(album.date),
-                  }),
-
-                album.hasCoverArt &&
-                album.coverArtDate &&
-                +album.coverArtDate !== +album.date &&
-                  language.$('releaseInfo.artReleased', {
-                    date: language.formatDate(album.coverArtDate),
-                  }),
-
-                albumDuration > 0 &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(albumDuration, {
-                      approximate: album.tracks.length > 1,
-                    }),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(album.additionalFiles),
-
-                checkGalleryPage(album) &&
-                  language.$('releaseInfo.viewGallery', {
-                    link: link.albumGallery(album, {
-                      text: language.$('releaseInfo.viewGallery.link'),
-                    }),
-                  }),
-
-                checkCommentaryPage(album) &&
-                  language.$('releaseInfo.viewCommentary', {
-                    link: link.albumCommentary(album, {
-                      text: language.$('releaseInfo.viewCommentary.link'),
-                    }),
-                  }),
-              ]),
-
-            !empty(album.urls) &&
-              html.tag('p',
-                language.$('releaseInfo.listenOn', {
-                  links: language.formatDisjunctionList(
-                    album.urls.map(url => fancifyURL(url, {album: true}))
-                  ),
-                })),
-
-            displayTrackSections &&
-            !empty(album.trackSections) &&
-              html.tag('dl',
-                {class: 'album-group-list'},
-                album.trackSections.flatMap(({
-                  name,
-                  startIndex,
-                  tracks,
-                }) => [
-                  html.tag('dt',
-                    {class: ['content-heading']},
-                    language.$('trackList.section.withDuration', {
-                      duration: language.formatDuration(getTotalDuration(tracks), {
-                        approximate: tracks.length > 1,
-                      }),
-                      section: name,
-                    })),
-                  html.tag('dd',
-                    html.tag(listTag,
-                      listTag === 'ol' ? {start: startIndex + 1} : {},
-                      tracks.map(trackToListItem))),
-                ])),
-
-            !displayTrackSections &&
-            !empty(album.tracks) &&
-              html.tag(listTag,
-                album.tracks.map(trackToListItem)),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                album.dateAddedToWiki &&
-                  language.$('releaseInfo.addedToWiki', {
-                    date: language.formatDate(
-                      album.dateAddedToWiki
-                    ),
-                  })
-              ]),
-
-            ...html.fragment(
-              hasAdditionalFiles && [
-                generateContentHeading({
-                  id: 'additional-files',
-                  title: language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
-                      unit: true,
-                    }),
-                  }),
-                }),
-
-                generateAlbumAdditionalFilesList(album, album.additionalFiles, {
-                  generateAdditionalFilesList,
-                  getSizeOfAdditionalFile,
-                  link,
-                  urls,
-                }),
-              ]),
-
-            ...html.fragment(
-              album.commentary && [
-                generateContentHeading({
-                  id: 'artist-commentary',
-                  title: language.$('releaseInfo.artistCommentary'),
-                }),
-
-                html.tag('blockquote', transformMultiline(album.commentary)),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, null, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          link,
-          language,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              html: language.$('albumPage.nav.album', {
-                album: link.album(album, {class: 'current'}),
-              }),
-            },
-            {
-              divider: false,
-              html: generateAlbumNavLinks(album, null, {
-                generateNavigationLinks,
-                html,
-                language,
-                link,
-              }),
-            }
-          ],
-          content: generateAlbumChronologyLinks(album, null, {
-            generateChronologyLinks,
-            html,
-          }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, null, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
-    },
-  };
-
   // TODO: only gen if there are any tracks with art
   const galleryPage = {
     type: 'page',
@@ -494,153 +199,6 @@ export function write(album, {wikiData}) {
       }),
     }),
   };
-
-  return [
-    infoPage,
-    galleryPage,
-    data,
-  ];
-}
-
-// Utility functions
-
-export function generateAlbumSidebar(album, currentTrack, {
-  fancifyURL,
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  transformMultiline,
-}) {
-  const isAlbumPage = !currentTrack;
-  const isTrackPage = !!currentTrack;
-
-  const listTag = getAlbumListTag(album);
-
-  const {trackSections} = album;
-
-  const trackToListItem = (track) =>
-    html.tag('li',
-      {class: track === currentTrack && 'current'},
-      language.$('albumSidebar.trackList.item', {
-        track: link.track(track),
-      }));
-
-  const nameOrDefault = (isDefaultTrackSection, name) =>
-    isDefaultTrackSection
-      ? language.$('albumSidebar.trackList.fallbackSectionName')
-      : name;
-
-  const trackListPart = [
-    html.tag('h1', link.album(album)),
-    ...trackSections.map(({name, color, startIndex, tracks, isDefaultTrackSection}) => {
-      const groupName =
-        html.tag('span',
-          {class: 'group-name'},
-          nameOrDefault(
-            isDefaultTrackSection,
-            name
-          ));
-      return html.tag('details',
-        {
-          // Leave side8ar track groups collapsed on al8um homepage,
-          // since there's already a view of all the groups expanded
-          // in the main content area.
-          open: isTrackPage && tracks.includes(currentTrack),
-          class: tracks.includes(currentTrack) && 'current',
-        },
-        [
-          html.tag(
-            'summary',
-            {style: getLinkThemeString(color)},
-            html.tag('span', [
-              listTag === 'ol' &&
-                language.$('albumSidebar.trackList.group.withRange', {
-                  group: groupName,
-                  range: `${startIndex + 1}&ndash;${
-                    startIndex + tracks.length
-                  }`,
-                }),
-              listTag === 'ul' &&
-                language.$('albumSidebar.trackList.group', {
-                  group: groupName,
-                }),
-            ])),
-          html.tag(listTag,
-            listTag === 'ol' ? {start: startIndex + 1} : {},
-            tracks.map(trackToListItem)),
-        ]);
-    }),
-  ];
-
-  const {groups} = album;
-
-  const groupParts = groups
-    .map((group) => {
-      const albums = group.albums.filter((album) => album.date);
-      const index = albums.indexOf(album);
-      const next = index >= 0 && albums[index + 1];
-      const previous = index > 0 && albums[index - 1];
-      return {group, next, previous};
-    })
-    // This is a map and not a flatMap because the distinction between which
-    // group sets of elements belong to matters. That means this variable is an
-    // array of arrays, and we'll need to treat it as such later!
-    .map(({group, next, previous}) => [
-      html.tag('h1', language.$('albumSidebar.groupBox.title', {
-        group: link.groupInfo(group),
-      })),
-
-      isAlbumPage &&
-        transformMultiline(group.descriptionShort),
-
-      !empty(group.urls) &&
-        html.tag('p', language.$('releaseInfo.visitOn', {
-          links: language.formatDisjunctionList(
-            group.urls.map((url) => fancifyURL(url))
-          ),
-        })),
-
-      ...html.fragment(
-        isAlbumPage && [
-          next &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.next', {
-                album: link.album(next),
-              })),
-
-          previous &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.previous', {
-                album: link.album(previous),
-              })),
-        ]),
-    ]);
-
-  if (empty(groupParts)) {
-    return {
-      stickyMode: 'column',
-      content: trackListPart,
-    };
-  } else if (isTrackPage) {
-    const combinedGroupPart = {
-      classes: ['no-sticky-header'],
-      content: groupParts
-        .map(groupPart => groupPart.filter(Boolean).join('\n'))
-        .join('\n<hr>\n'),
-    };
-    return {
-      stickyMode: 'column',
-      multiple: [trackListPart, combinedGroupPart],
-    };
-  } else {
-    return {
-      stickyMode: 'last',
-      multiple: [...groupParts, trackListPart],
-    };
-  }
 }
 
 export function generateAlbumSecondaryNav(album, currentTrack, {
@@ -696,174 +254,4 @@ export function generateAlbumSecondaryNav(album, currentTrack, {
     content: groupParts,
   };
 }
-
-function checkGalleryPage(album) {
-  return album.tracks.some(t => t.hasUniqueCoverArt);
-}
-
-function checkCommentaryPage(album) {
-  return !!album.commentary || album.tracks.some(t => t.commentary);
-}
-
-export function generateAlbumNavLinks(album, currentTrack, {
-  generateNavigationLinks,
-  html,
-  language,
-  link,
-
-  currentExtra = null,
-  showTrackNavigation = true,
-  showExtraLinks = null,
-}) {
-  const isTrackPage = !!currentTrack;
-
-  showExtraLinks ??= currentTrack ? false : true;
-
-  const extraLinks = showExtraLinks ? [
-    checkGalleryPage(album) &&
-      link.albumGallery(album, {
-        class: [currentExtra === 'gallery' && 'current'],
-        text: language.$('albumPage.nav.gallery'),
-      }),
-
-    checkCommentaryPage(album) &&
-      link.albumCommentary(album, {
-        class: [currentExtra === 'commentary' && 'current'],
-        text: language.$('albumPage.nav.commentary'),
-      }),
-  ].filter(Boolean) : [];
-
-  const previousNextLinks =
-    showTrackNavigation &&
-    album.tracks.length > 1 &&
-      generateNavigationLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track',
-        returnAsArray: true,
-      })
-
-  const randomLink =
-    showTrackNavigation &&
-    album.tracks.length > 1 &&
-      html.tag('a',
-        {
-          href: '#',
-          'data-random': 'track-in-album',
-          id: 'random-button'
-        },
-        (isTrackPage
-          ? language.$('trackPage.nav.random')
-          : language.$('albumPage.nav.randomTrack')));
-
-  const allLinks = [
-    ...previousNextLinks || [],
-    ...extraLinks || [],
-    randomLink,
-  ].filter(Boolean);
-
-  if (empty(allLinks)) {
-    return '';
-  }
-
-  return `(${language.formatUnitList(allLinks)})`;
-}
-
-export function generateAlbumExtrasPageNav(album, currentExtra, {
-  html,
-  language,
-  link,
-}) {
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      {
-        html: language.$('albumPage.nav.album', {
-          album: link.album(album, {class: 'current'}),
-        }),
-      },
-      {
-        divider: false,
-        html: generateAlbumNavLinks(album, null, {
-          currentExtra,
-          showTrackNavigation: false,
-          showExtraLinks: true,
-
-          html,
-          language,
-          link,
-        }),
-      }
-    ],
-  };
-}
-
-export function generateAlbumChronologyLinks(album, currentTrack, {
-  generateChronologyLinks,
-  html,
-}) {
-  return html.tag(
-    'div',
-    {
-      [html.onlyIfContent]: true,
-      class: 'nav-chronology-links',
-    },
-    [
-      ...html.fragment(
-        currentTrack && [
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'artistContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'contributorContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-        ]),
-
-      ...html.fragment(
-        generateChronologyLinks(currentTrack || album, {
-          contribKey: 'coverArtistContribs',
-          dateKey: 'coverArtDate',
-          getThings: (artist) => [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ],
-          headingString: 'misc.chronology.heading.coverArt',
-        })),
-    ]);
-}
-
-export function generateAlbumAdditionalFilesList(album, additionalFiles, {
-  fileSize = true,
-
-  generateAdditionalFilesList,
-  getSizeOfAdditionalFile,
-  link,
-  urls,
-}) {
-  return generateAdditionalFilesList(additionalFiles, {
-    getFileSize:
-      (fileSize
-        ? (file) =>
-            // TODO: Kinda near the metal here...
-            getSizeOfAdditionalFile(
-              urls
-                .from('media.root')
-                .to('media.albumAdditionalFile', album.directory, file))
-        : () => null),
-    linkFile: (file) =>
-      link.albumAdditionalFile({album, file}),
-  });
-}
+*/
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index f867d123..9e9fdf5b 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -7,15 +7,15 @@ export function targets({wikiData}) {
   return wikiData.artistAliasData;
 }
 
-export function write(aliasArtist) {
+export function pathsForTarget(aliasArtist) {
   const {aliasedArtist} = aliasArtist;
 
-  const redirect = {
-    type: 'redirect',
-    fromPath: ['artist', aliasArtist.directory],
-    toPath: ['artist', aliasedArtist.directory],
-    title: () => aliasedArtist.name,
-  };
-
-  return [redirect];
+  return [
+    {
+      type: 'redirect',
+      fromPath: ['artist', aliasArtist.directory],
+      toPath: ['artist', aliasedArtist.directory],
+      title: () => aliasedArtist.name,
+    },
+  ];
 }
diff --git a/src/page/artist.js b/src/page/artist.js
index 4ef44d32..c53a4913 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -2,18 +2,7 @@
 //
 // NB: See artist-alias.js for artist alias redirect pages.
 
-import {
-  bindOpts,
-  empty,
-  unique,
-} from '../util/sugar.js';
-
-import {
-  chunkByProperties,
-  getTotalDuration,
-  sortAlbumsTracksChronologically,
-  sortFlashesChronologically,
-} from '../util/wiki-data.js';
+import {empty} from '../util/sugar.js';
 
 export const description = `per-artist info & artwork gallery pages`;
 
@@ -21,663 +10,97 @@ export function targets({wikiData}) {
   return wikiData.artistData;
 }
 
-export function write(artist, {wikiData}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  const {name, urls, contextNotes} = artist;
-
-  const artThingsAll = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.albumsAsWallpaperArtist ?? []),
-      ...(artist.albumsAsBannerArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ]),
-    {getDate: (o) => o.coverArtDate});
-
-  const artThingsGallery = sortAlbumsTracksChronologically(
-    [
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ],
-    {latestFirst: true, getDate: (o) => o.coverArtDate});
-
-  const commentaryThings = sortAlbumsTracksChronologically([
-    ...(artist.albumsAsCommentator ?? []),
-    ...(artist.tracksAsCommentator ?? []),
-  ]);
-
-  const hasGallery = !empty(artThingsGallery);
-
-  const getArtistsAndContrib = (thing, key) => ({
-    artists: thing[key]?.filter(({who}) => who !== artist),
-    contrib: thing[key]?.find(({who}) => who === artist),
-    thing,
-    key,
-  });
-
-  const artListChunks = chunkByProperties(
-    artThingsAll.flatMap((thing) =>
-      ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
-        .map((key) => getArtistsAndContrib(thing, key))
-        .filter(({contrib}) => contrib)
-        .map((props) => ({
-          album: thing.album || thing,
-          track: thing.album ? thing : null,
-          date: thing.date,
-          ...props,
-        }))),
-    ['date', 'album']);
-
-  const commentaryListChunks = chunkByProperties(
-    commentaryThings.map((thing) => ({
-      album: thing.album || thing,
-      track: thing.album ? thing : null,
-    })),
-    ['album']);
-
-  const allTracks = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.tracksAsArtist ?? []),
-      ...(artist.tracksAsContributor ?? []),
-    ]));
-
-  const chunkTracks = (tracks) =>
-    chunkByProperties(
-      tracks.map((track) => ({
-        track,
-        date: +track.date,
-        album: track.album,
-        duration: track.duration,
-        originalReleaseTrack: track.originalReleaseTrack,
-        artists: track.artistContribs.some(({who}) => who === artist)
-          ? track.artistContribs.filter(({who}) => who !== artist)
-          : track.contributorContribs.filter(({who}) => who !== artist),
-        contrib: {
-          who: artist,
-          whatArray: [
-            track.artistContribs.find(({who}) => who === artist)?.what,
-            track.contributorContribs.find(({who}) => who === artist)?.what,
-          ].filter(Boolean),
-        },
-      })),
-      ['date', 'album'])
-    .map(({date, album, chunk}) => ({
-      date,
-      album,
-      chunk,
-      duration: getTotalDuration(chunk, {originalReleasesOnly: true}),
-    }));
-
-  const trackListChunks = chunkTracks(allTracks);
-  const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack));
-
-  const countGroups = (things) => {
-    const usedGroups = things.flatMap(
-      (thing) => thing.groups || thing.album?.groups || []);
-    return groupData
-      .map((group) => ({
-        group,
-        contributions: usedGroups.filter(g => g === group).length,
-      }))
-      .filter(({contributions}) => contributions > 0)
-      .sort((a, b) => b.contributions - a.contributions);
-  };
-
-  const musicGroups = countGroups(allTracks);
-  const artGroups = countGroups(artThingsAll);
-
-  let flashes, flashListChunks;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashes = sortFlashesChronologically(artist.flashesAsContributor.slice());
-    flashListChunks = chunkByProperties(
-      flashes.map((flash) => ({
-        act: flash.act,
-        flash,
-        date: flash.date,
-        // Manual artists/contrib properties here, 8ecause we don't
-        // want to show the full list of other contri8utors inline.
-        // (It can often 8e very, very large!)
-        artists: [],
-        contrib: flash.contributorContribs.find(({who}) => who === artist),
-      })),
-      ['act']
-    ).map(({act, chunk}) => ({
-      act,
-      chunk,
-      dateFirst: chunk[0].date,
-      dateLast: chunk[chunk.length - 1].date,
-    }));
-  }
-
-  const generateEntryAccents = ({
-    getArtistString,
-    language,
-    original,
-    entry,
-    artists,
-    contrib,
-  }) =>
-    original
-      ? language.$('artistPage.creditList.entry.rerelease', {entry})
-      : !empty(artists)
-      ? contrib.what || contrib.whatArray?.length
-        ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
-            entry,
-            artists: getArtistString(artists),
-            contribution: contrib.whatArray
-              ? language.formatUnitList(contrib.whatArray)
-              : contrib.what,
-          })
-        : language.$('artistPage.creditList.entry.withArtists', {
-            entry,
-            artists: getArtistString(artists),
-          })
-      : contrib.what || contrib.whatArray?.length
-      ? language.$('artistPage.creditList.entry.withContribution', {
-          entry,
-          contribution: contrib.whatArray
-            ? language.formatUnitList(contrib.whatArray)
-            : contrib.what,
-        })
-      : entry;
-
-  const unbound_generateTrackList = (chunks, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('dl',
-      chunks.flatMap(({date, album, chunk, duration}) => [
-        html.tag('dt',
-          date && duration ?
-            language.$('artistPage.creditList.album.withDate.withDuration', {
-              album: link.album(album),
-              date: language.formatDate(date),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          date ?
-            language.$('artistPage.creditList.album.withDate', {
-              album: link.album(album),
-              date: language.formatDate(date),
-            }) :
-
-          duration ?
-            language.$('artistPage.creditList.album.withDuration', {
-              album: link.album(album),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          language.$('artistPage.creditList.album', {
-            album: link.album(album),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            chunk
-              .map(({track, ...props}) => ({
-                original: track.originalReleaseTrack,
-                entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                  track: link.track(track),
-                  duration: language.formatDuration(track.duration ?? 0),
-                }),
-                ...props,
-              }))
-              .map(({original, ...opts}) =>
-                html.tag('li',
-                  {class: original && 'rerelease'},
-                  generateEntryAccents({
-                    getArtistString,
-                    language,
-                    original,
-                    ...opts,
-                  })
-                )
-              ))),
-      ]));
-
-  const unbound_serializeArtistsAndContrib =
-    (key, {serializeContribs, serializeLink}) =>
-    (thing) => {
-      const {artists, contrib} = getArtistsAndContrib(thing, key);
-      const ret = {};
-      ret.link = serializeLink(thing);
-      if (contrib.what) ret.contribution = contrib.what;
-      if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
-      return ret;
-    };
-
-  const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
-    chunks.map(({date, album, chunk, duration}) => ({
-      album: serializeLink(album),
-      date,
-      duration,
-      tracks: chunk.map(({track}) => ({
-        link: serializeLink(track),
-        duration: track.duration,
-      })),
-    }));
-
-  const jumpTo = {
-    tracks: !empty(allTracks),
-    art: !empty(artThingsAll),
-    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
-    commentary: !empty(commentaryThings),
-  };
-
-  const showJumpTo = Object.values(jumpTo).includes(true);
-
-  const data = {
-    type: 'data',
-    path: ['artist', artist.directory],
-    data: ({serializeContribs, serializeLink}) => {
-      const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
-        serializeContribs,
-        serializeLink,
-      });
-
-      const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
-        serializeLink,
-      });
-
-      return {
-        albums: {
-          asCoverArtist: artist.albumsAsCoverArtist
-            .map(serializeArtistsAndContrib('coverArtistContribs')),
-          asWallpaperArtist: artist.albumsAsWallpaperArtist
-            .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
-          asBannerArtist: artist.albumsAsBannerArtis
-            .map(serializeArtistsAndContrib('bannerArtistContribs')),
-        },
-        flashes: wikiInfo.enableFlashesAndGames
-          ? {
-              asContributor: artist.flashesAsContributor
-                .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
-                .map(({contrib, thing: flash}) => ({
-                  link: serializeLink(flash),
-                  contribution: contrib.what,
-                })),
-            }
-          : null,
-        tracks: {
-          asArtist: artist.tracksAsArtist
-            .map(serializeArtistsAndContrib('artistContribs')),
-          asContributor: artist.tracksAsContributo
-            .map(serializeArtistsAndContrib('contributorContribs')),
-          chunked: serializeTrackListChunks(trackListChunks),
-        },
-      };
-    },
-  };
-
-  const infoPage = {
-    type: 'page',
-    path: ['artist', artist.directory],
-    page: ({
-      fancifyURL,
-      generateInfoGalleryLinks,
-      getArtistAvatar,
-      getArtistString,
-      html,
-      link,
-      language,
-      transformMultiline,
-    }) => {
-      const generateTrackList = bindOpts(unbound_generateTrackList, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('artistPage.title', {artist: name}),
-
-        cover: artist.hasAvatar && {
-          src: getArtistAvatar(artist),
-          alt: language.$('misc.alt.artistAvatar'),
-        },
-
-        main: {
-          headingMode: 'sticky',
+export function pathsForTarget(artist) {
+  const hasGalleryPage =
+    !empty(artist.tracksAsCoverArtist) ||
+    !empty(artist.albumsAsCoverArtist);
 
-          content: [
-            ...html.fragment(
-              contextNotes && [
-                html.tag('p',
-                  language.$('releaseInfo.note')),
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
 
-                html.tag('blockquote',
-                  transformMultiline(contextNotes)),
-
-                html.tag('hr'),
-              ]),
-
-            !empty(urls) &&
-              html.tag('p',
-                language.$('releaseInfo.visitOn', {
-                  links: language.formatDisjunctionList(
-                    urls.map((url) => fancifyURL(url, {language}))
-                  ),
-                })),
-
-            hasGallery &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery', {
-                  link: link.artistGallery(artist, {
-                    text: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
-
-            showJumpTo &&
-              html.tag('p',
-                language.$('misc.jumpTo.withLinks', {
-                  links: language.formatUnitList(
-                    [
-                      jumpTo.tracks &&
-                        html.tag('a',
-                          {href: '#tracks'},
-                          language.$('artistPage.trackList.title')),
-
-                      jumpTo.art &&
-                        html.tag('a',
-                          {href: '#art'},
-                          language.$('artistPage.artList.title')),
-
-                      jumpTo.flashes &&
-                        html.tag('a',
-                          {href: '#flashes'},
-                          language.$('artistPage.flashList.title')),
-
-                      jumpTo.commentary &&
-                        html.tag('a',
-                          {href: '#commentary'},
-                          language.$('artistPage.commentaryList.title')),
-                    ].filter(Boolean)),
-                })),
-
-            ...html.fragment(
-              !empty(allTracks) && [
-                html.tag('h2',
-                  {id: 'tracks', class: ['content-heading']},
-                  language.$('artistPage.trackList.title')),
-
-                totalDuration > 0 &&
-                  html.tag('p',
-                    language.$('artistPage.contributedDurationLine', {
-                      artist: artist.name,
-                      duration: language.formatDuration(
-                        totalDuration,
-                        {
-                          approximate: true,
-                          unit: true,
-                        }
-                      ),
-                    })),
-
-                !empty(musicGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.musicGroupsLine', {
-                      groups: language.formatUnitList(
-                        musicGroups.map(({group, contributions}) =>
-                          language.$('artistPage.groupsLine.item', {
-                            group: link.groupInfo(group),
-                            contributions:
-                              language.countContributions(
-                                contributions
-                              ),
-                          })
-                        )
-                      ),
-                    })),
-
-                generateTrackList(trackListChunks),
-              ]),
-
-            ...html.fragment(
-              !empty(artThingsAll) && [
-                html.tag('h2',
-                  {id: 'art', class: ['content-heading']},
-                  language.$('artistPage.artList.title')),
-
-                hasGallery &&
-                  html.tag('p',
-                    language.$('artistPage.viewArtGallery.orBrowseList', {
-                      link: link.artistGallery(artist, {
-                        text: language.$('artistPage.viewArtGallery.link'),
-                      })
-                    })),
-
-                !empty(artGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.artGroupsLine', {
-                    groups: language.formatUnitList(
-                      artGroups.map(({group, contributions}) =>
-                        language.$('artistPage.groupsLine.item', {
-                          group: link.groupInfo(group),
-                          contributions:
-                            language.countContributions(
-                              contributions
-                            ),
-                        })
-                      )
-                    ),
-                  })),
-
-                html.tag('dl',
-                  artListChunks.flatMap(({date, album, chunk}) => [
-                    html.tag('dt', language.$('artistPage.creditList.album.withDate', {
-                      album: link.album(album),
-                      date: language.formatDate(date),
-                    })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track, key, ...props}) => ({
-                            ...props,
-                            entry:
-                              track
-                                ? language.$('artistPage.creditList.entry.track', {
-                                    track: link.track(track),
-                                  })
-                                : html.tag('i',
-                                    language.$('artistPage.creditList.entry.album.' + {
-                                      wallpaperArtistContribs:
-                                        'wallpaperArt',
-                                      bannerArtistContribs:
-                                        'bannerArt',
-                                      coverArtistContribs:
-                                        'coverArt',
-                                    }[key])),
-                          }))
-                          .map((opts) => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashes) && [
-                html.tag('h2',
-                  {id: 'flashes', class: ['content-heading']},
-                  language.$('artistPage.flashList.title')),
-
-                html.tag('dl',
-                  flashListChunks.flatMap(({
-                    act,
-                    chunk,
-                    dateFirst,
-                    dateLast,
-                  }) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.flashAct.withDateRange', {
-                        act: link.flash(chunk[0].flash, {
-                          text: act.name,
-                        }),
-                        dateRange: language.formatDateRange(
-                          dateFirst,
-                          dateLast
-                        ),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({flash, ...props}) => ({
-                            ...props,
-                            entry: language.$('artistPage.creditList.entry.flash', {
-                              flash: link.flash(flash),
-                            }),
-                          }))
-                          .map(opts => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              !empty(commentaryThings) && [
-                html.tag('h2',
-                  {id: 'commentary', class: ['content-heading']},
-                  language.$('artistPage.commentaryList.title')),
-
-                html.tag('dl',
-                  commentaryListChunks.flatMap(({album, chunk}) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.album', {
-                        album: link.album(album),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track}) => track
-                            ? language.$('artistPage.creditList.entry.track', {
-                                track: link.track(track),
-                              })
-                            : html.tag('i',
-                                language.$('artistPage.creditList.entry.album.commentary')))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-          ],
-        },
-
-        nav: generateNavForArtist(artist, false, hasGallery, {
-          generateInfoGalleryLinks,
-          link,
-          language,
-          wikiData,
-        }),
-      };
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
+      },
     },
-  };
-
-  const galleryPage = hasGallery && {
-    type: 'page',
-    path: ['artistGallery', artist.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      getAlbumCover,
-      getGridHTML,
-      getTrackCover,
-      html,
-      link,
-      language,
-    }) => ({
-      title: language.$('artistGalleryPage.title', {artist: name}),
-
-      main: {
-        classes: ['top-index'],
-        headingMode: 'static',
 
-        content: [
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(artThingsGallery.length, {
-                unit: true,
-              }),
-            })),
+    hasGalleryPage && {
+      type: 'page',
+      path: ['artistGallery', artist.directory],
 
-          html.tag('div',
-            {class: 'grid-listing'},
-            getGridHTML({
-              entries: artThingsGallery.map((item) => ({item})),
-              srcFn: (thing) =>
-                thing.album
-                  ? getTrackCover(thing)
-                  : getAlbumCover(thing),
-              linkFn: (thing, opts) =>
-                thing.album
-                  ? link.track(thing, opts)
-                  : link.album(thing, opts),
-            })),
-        ],
+      contentFunction: {
+        name: 'generateArtistGalleryPage',
+        args: [artist],
       },
-
-      nav: generateNavForArtist(artist, true, hasGallery, {
-        generateInfoGalleryLinks,
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [data, infoPage, galleryPage].filter(Boolean);
+    },
+  ];
 }
 
-// Utility functions
+/*
+const unbound_serializeArtistsAndContrib =
+  (key, {serializeContribs, serializeLink}) =>
+  (thing) => {
+    const {artists, contrib} = getArtistsAndContrib(thing, key);
+    const ret = {};
+    ret.link = serializeLink(thing);
+    if (contrib.what) ret.contribution = contrib.what;
+    if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
+    return ret;
+  };
 
-function generateNavForArtist(artist, isGallery, hasGallery, {
-  generateInfoGalleryLinks,
-  language,
-  link,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
+const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
+  chunks.map(({date, album, chunk, duration}) => ({
+    album: serializeLink(album),
+    date,
+    duration,
+    tracks: chunk.map(({track}) => ({
+      link: serializeLink(track),
+      duration: track.duration,
+    })),
+  }));
+
+const data = {
+  type: 'data',
+  path: ['artist', artist.directory],
+  data: ({serializeContribs, serializeLink}) => {
+    const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
+      serializeContribs,
+      serializeLink,
+    });
 
-  const infoGalleryLinks =
-    hasGallery &&
-    generateInfoGalleryLinks(artist, isGallery, {
-      link,
-      language,
-      linkKeyGallery: 'artistGallery',
-      linkKeyInfo: 'artist',
+    const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+      serializeLink,
     });
 
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
+    return {
+      albums: {
+        asCoverArtist: artist.albumsAsCoverArtist
+          .map(serializeArtistsAndContrib('coverArtistContribs')),
+        asWallpaperArtist: artist.albumsAsWallpaperArtist
+          .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
+        asBannerArtist: artist.albumsAsBannerArtis
+          .map(serializeArtistsAndContrib('bannerArtistContribs')),
       },
-      {
-        html: language.$('artistPage.nav.artist', {
-          artist: link.artist(artist, {class: 'current'}),
-        }),
+      flashes: wikiInfo.enableFlashesAndGames
+        ? {
+            asContributor: artist.flashesAsContributor
+              .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
+              .map(({contrib, thing: flash}) => ({
+                link: serializeLink(flash),
+                contribution: contrib.what,
+              })),
+          }
+        : null,
+      tracks: {
+        asArtist: artist.tracksAsArtist
+          .map(serializeArtistsAndContrib('artistContribs')),
+        asContributor: artist.tracksAsContributo
+          .map(serializeArtistsAndContrib('contributorContribs')),
+        chunked: serializeTrackListChunks(trackListChunks),
       },
-      hasGallery && {
-        divider: false,
-        html: `(${infoGalleryLinks})`,
-      },
-    ],
-  };
-}
+    };
+  },
+};
+*/
diff --git a/src/page/group.js b/src/page/group.js
index 81e1728d..4d5f91c8 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -15,307 +15,28 @@ export function targets({wikiData}) {
   return wikiData.groupData;
 }
 
-export function write(group, {wikiData}) {
-  const {listingSpec, wikiInfo} = wikiData;
+export function pathsForTarget(group) {
+  const hasGalleryPage = !empty(group.albums);
 
-  const tracks = group.albums.flatMap((album) => album.tracks);
-  const totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true});
+  return [
+    {
+      type: 'page',
+      path: ['groupInfo', group.directory],
 
-  const albumLines = group.albums.map((album) => ({
-    album,
-    otherGroup: album.groups.find((g) => g !== group),
-  }));
-
-  const infoPage = {
-    type: 'page',
-    path: ['groupInfo', group.directory],
-    page: ({
-      fancifyURL,
-      generateInfoGalleryLinks,
-      generateNavigationLinks,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('groupInfoPage.title', {group: group.name}),
-
-      themeColor: group.color,
-      theme: getThemeString(group.color),
-
-      main: {
-        headingMode: 'sticky',
-
-        content: [
-          !empty(group.urls) &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(
-                  group.urls.map(url => fancifyURL(url, {language}))),
-              })),
-
-          group.description &&
-            html.tag('blockquote',
-              transformMultiline(group.description)),
-
-          ...html.fragment(
-            !empty(group.albums) && [
-              html.tag('h2',
-                {class: ['content-heading']},
-                language.$('groupInfoPage.albumList.title')),
-
-              html.tag('p',
-                language.$('groupInfoPage.viewAlbumGallery', {
-                  link: link.groupGallery(group, {
-                    text: language.$('groupInfoPage.viewAlbumGallery.link'),
-                  }),
-                })),
-
-              html.tag('ul',
-                albumLines.map(({album, otherGroup}) => {
-                  const item = album.date
-                    ? language.$('groupInfoPage.albumList.item', {
-                        year: album.date.getFullYear(),
-                        album: link.album(album),
-                      })
-                    : language.$('groupInfoPage.albumList.item.withoutYear', {
-                        album: link.album(album),
-                      });
-                  return html.tag('li',
-                    otherGroup
-                      ? language.$('groupInfoPage.albumList.item.withAccent', {
-                          item,
-                          accent: html.tag('span',
-                            {class: 'other-group-accent'},
-                            language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                              group: link.groupInfo(otherGroup, {
-                                color: false,
-                              }),
-                            })),
-                        })
-                      : item);
-                })),
-            ]),
-        ],
-      },
-
-      sidebarLeft: generateGroupSidebar(group, false, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      }),
-
-      nav: generateGroupNav(group, false, {
-        generateInfoGalleryLinks,
-        generateNavigationLinks,
-        language,
-        link,
-        wikiData,
-      }),
-    }),
-  };
-
-  const galleryPage = !empty(group.albums) && {
-    type: 'page',
-    path: ['groupGallery', group.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      generateNavigationLinks,
-      getAlbumCover,
-      getAlbumGridHTML,
-      getCarouselHTML,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('groupGalleryPage.title', {group: group.name}),
-
-      themeColor: group.color,
-      theme: getThemeString(group.color),
-
-      main: {
-        classes: ['top-index'],
-        headingMode: 'static',
-
-        content: [
-          getCarouselHTML({
-            items: group.featuredAlbums.slice(0, 12 + 1),
-            srcFn: getAlbumCover,
-            linkFn: link.album,
-          }),
-
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
-              tracks: html.tag('b',
-                language.countTracks(tracks.length, {
-                  unit: true,
-                })),
-              albums: html.tag('b',
-                language.countAlbums(group.albums.length, {
-                  unit: true,
-                })),
-              time: html.tag('b',
-                language.formatDuration(totalDuration, {
-                  unit: true,
-                })),
-            })),
-
-          wikiInfo.enableGroupUI &&
-          wikiInfo.enableListings &&
-            html.tag('p',
-              {class: 'quick-info'},
-              language.$('groupGalleryPage.anotherGroupLine', {
-                link: link.listing(
-                  listingSpec.find(l => l.directory === 'groups/by-category'),
-                  {
-                    text: language.$('groupGalleryPage.anotherGroupLine.link'),
-                  }),
-              })),
-
-          html.tag('div',
-            {class: 'grid-listing'},
-            getAlbumGridHTML({
-              entries: sortChronologically(
-                group.albums
-                  .filter(album => album.isListedInGalleries)
-                  .map(album => ({
-                    item: album,
-                    directory: album.directory,
-                    name: album.name,
-                    date: album.date,
-                  }))
-              ).reverse(),
-              details: true,
-            })),
-        ],
+      contentFunction: {
+        name: 'generateGroupInfoPage',
+        args: [group],
       },
+    },
 
-      sidebarLeft: generateGroupSidebar(group, true, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      }),
+    hasGalleryPage && {
+      type: 'page',
+      path: ['groupGallery', group.directory],
 
-      nav: generateGroupNav(group, true, {
-        generateInfoGalleryLinks,
-        generateNavigationLinks,
-        language,
-        link,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [infoPage, galleryPage].filter(Boolean);
-}
-
-// Utility functions
-
-function generateGroupSidebar(currentGroup, isGallery, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
-  const {groupCategoryData, wikiInfo} = wikiData;
-
-  if (!wikiInfo.enableGroupUI) {
-    return null;
-  }
-
-  return {
-    content: [
-      html.tag('h1',
-        language.$('groupSidebar.title')),
-
-      ...groupCategoryData.map((category) =>
-        html.tag('details',
-          {
-            open: category === currentGroup.category,
-            class: category === currentGroup.category && 'current',
-          },
-          [
-            html.tag('summary',
-              {style: getLinkThemeString(category.color)},
-              html.tag('span',
-                language.$('groupSidebar.groupList.category', {
-                  category: `<span class="group-name">${category.name}</span>`,
-                }))),
-            html.tag('ul',
-              category.groups.map((group) => {
-                const linkKey = (
-                  isGallery && !empty(group.albums)
-                    ? 'groupGallery'
-                    : 'groupInfo');
-
-                return html.tag('li',
-                  {
-                    class: group === currentGroup && 'current',
-                    style: getLinkThemeString(group.color),
-                  },
-                  language.$('groupSidebar.groupList.item', {
-                    group: link[linkKey](group),
-                  }));
-              })),
-          ])),
-    ],
-  };
-}
-
-function generateGroupNav(currentGroup, isGallery, {
-  generateInfoGalleryLinks,
-  generateNavigationLinks,
-  link,
-  language,
-  wikiData,
-}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  if (!wikiInfo.enableGroupUI) {
-    return {simple: true};
-  }
-
-  const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-  const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
-    linkKeyGallery: 'groupGallery',
-    linkKeyInfo: 'groupInfo',
-  });
-
-  const previousNextLinks = generateNavigationLinks(currentGroup, {
-    data: groupData,
-    linkKey,
-  });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
-      },
-      {
-        html: language.$('groupPage.nav.group', {
-          group: link[linkKey](currentGroup, {class: 'current'}),
-        }),
-      },
-      {
-        divider: false,
-        html: previousNextLinks
-          ? `(${infoGalleryLinks}; ${previousNextLinks})`
-          : `(${previousNextLinks})`,
+      contentFunction: {
+        name: 'generateGroupGalleryPage',
+        args: [group],
       },
-    ],
-  };
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbea..e07c1355 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -2,52 +2,19 @@
 // other modules here! It's not the page spec for the homepage - see
 // homepage.js for that.
 //
-// Each module published in this list should follow a particular format,
-// including any of the following exports:
+// (TODO: The docs here from initial draft were totally outdated.
+//        We don't have docs for the new setup yet.
+//        Write those!!)
 //
-// condition({wikiData})
-//     Returns a boolean indicating whether to process targets/writes (true) or
-//     skip this page spec altogether (false). This is usually used for
-//     selectively toggling pages according to site feature flags, though it may
-//     also be used to e.g. skip out if no targets would be found (preventing
-//     writeTargetless from generating an empty index page).
-//
-// targets({wikiData})
-//     Gets the objects which this page's write() function should be called on.
-//     Usually this will simply mean returning the appropriate thingData array,
-//     but it may also apply filter/map/etc if useful.
-//
-// write(thing, {wikiData})
-//     Provides descriptors for any page and data writes associated with the
-//     given thing (which will be a value from the targets() array). This
-//     includes page (HTML) writes, data (JSON) writes, etc. Notably, this
-//     function does not perform any file operations itself; it only describes
-//     the operations which will be processed elsewhere, once for each
-//     translation language.  The write function also immediately transforms
-//     any data which will be reused across writes of the same page, so that
-//     this data is effectively cached (rather than recalculated for each
-//     language/write).
-//
-// writeTargetless({wikiData})
-//     Provides descriptors for page/data/etc writes which will be used
-//     without concern for targets. This is usually used for writing index pages
-//     which should be generated just once (rather than corresponding to
-//     targets).
-//
-// As these modules are effectively the HTML templates for all site layout,
-// common patterns may also be exported alongside the special exports above.
-// These functions should be referenced only from adjacent modules, as they
-// pertain only to site page generation.
 
 export * as album from './album.js';
-export * as albumCommentary from './album-commentary.js';
 export * as artist from './artist.js';
 export * as artistAlias from './artist-alias.js';
-export * as flash from './flash.js';
+// export * as flash from './flash.js';
 export * as group from './group.js';
-export * as homepage from './homepage.js';
+// export * as homepage from './homepage.js';
 export * as listing from './listing.js';
-export * as news from './news.js';
+// export * as news from './news.js';
 export * as static from './static.js';
-export * as tag from './tag.js';
+// export * as tag from './tag.js';
 export * as track from './track.js';
diff --git a/src/page/listing.js b/src/page/listing.js
index 73c30827..1db7aa7b 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -14,6 +14,29 @@ import {getTotalDuration} from '../util/wiki-data.js';
 
 export const description = `wiki-wide listing pages & index`;
 
+export function targets({wikiData}) {
+  return (
+    wikiData.listingSpec
+      .filter(listing => listing.contentFunction)
+      .filter(listing =>
+        !listing.featureFlag ||
+        wikiData.wikiInfo[listing.featureFlag]));
+}
+
+export function pathsForTarget(listing) {
+  return [
+    {
+      type: 'page',
+      path: ['listing', listing.directory],
+      contentFunction: {
+        name: listing.contentFunction,
+        args: [listing],
+      },
+    },
+  ];
+}
+
+/*
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableListings;
 }
@@ -274,3 +297,4 @@ function generateLinkIndexForListings(currentListing, forSidebar, {
             genUL(listings)),
         ]));
 }
+*/
diff --git a/src/page/static.js b/src/page/static.js
index 8572db4e..82330dec 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -8,26 +8,16 @@ export function targets({wikiData}) {
   return wikiData.staticPageData;
 }
 
-export function write(staticPage) {
-  const page = {
-    type: 'page',
-    path: ['staticPage', staticPage.directory],
-    page: ({
-      transformMultiline,
-    }) => ({
-      title: staticPage.name,
-      stylesheet: staticPage.stylesheet,
+export function pathsForTarget(staticPage) {
+  return [
+    {
+      type: 'page',
+      path: ['staticPage', staticPage.directory],
 
-      main: {
-        classes: ['long-content'],
-        headingMode: 'sticky',
-
-        content: transformMultiline(staticPage.content),
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
       },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
diff --git a/src/page/track.js b/src/page/track.js
index b6b03f35..e75b6958 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,553 +1,21 @@
 // Track page specification.
 
-import {
-  generateAlbumChronologyLinks,
-  generateAlbumNavLinks,
-  generateAlbumSecondaryNav,
-  generateAlbumSidebar,
-  generateAlbumAdditionalFilesList as unbound_generateAlbumAdditionalFilesList,
-} from './album.js';
-
-import {
-  bindOpts,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getTrackCover,
-  getAlbumListTag,
-  sortFlashesChronologically,
-} from '../util/wiki-data.js';
-
 export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
   return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-  const {wikiInfo} = wikiData;
-
-  const {
-    album,
-    contributorContribs,
-    referencedByTracks,
-    referencedTracks,
-    sampledByTracks,
-    sampledTracks,
-    otherReleases,
-  } = track;
-
-  const listTag = getAlbumListTag(album);
-
-  let flashesThatFeature;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashesThatFeature = sortFlashesChronologically(
-      [track, ...otherReleases].flatMap((track) =>
-        track.featuredInFlashes.map((flash) => ({
-          flash,
-          as: track,
-          directory: flash.directory,
-          name: flash.name,
-          date: flash.date,
-        }))
-      )
-    );
-  }
-
-  const unbound_getTrackItem = (track, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('li',
-      language.$('trackList.item.withArtists', {
-        track: link.track(track),
-        by: html.tag('span',
-          {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: getArtistString(track.artistContribs),
-          })),
-      }));
-
-  const hasCommentary =
-    track.commentary || otherReleases.some((t) => t.commentary);
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
 
-  const hasAdditionalFiles = !empty(track.additionalFiles);
-  const hasSheetMusicFiles = !empty(track.sheetMusicFiles);
-  const hasMidiProjectFiles = !empty(track.midiProjectFiles);
-  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
-
-  const generateCommentary = ({language, link, transformMultiline}) =>
-    transformMultiline([
-      track.commentary,
-      ...otherReleases.map((track) =>
-        track.commentary
-          ?.split('\n')
-          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
-          .flatMap(line => [
-            line,
-            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
-              original: link.track(track),
-            }),
-          ])
-          .join('\n')
-      ),
-    ].filter(Boolean).join('\n'));
-
-  const data = {
-    type: 'data',
-    path: ['track', track.directory],
-    data: ({
-      serializeContribs,
-      serializeCover,
-      serializeGroupsForTrack,
-      serializeLink,
-    }) => ({
-      name: track.name,
-      directory: track.directory,
-      dates: {
-        released: track.date,
-        originallyReleased: track.originalDate,
-        coverArtAdded: track.coverArtDate,
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        args: [track],
       },
-      duration: track.duration,
-      color: track.color,
-      cover: serializeCover(track, getTrackCover),
-      artistsContribs: serializeContribs(track.artistContribs),
-      contributorContribs: serializeContribs(track.contributorContribs),
-      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
-      album: serializeLink(track.album),
-      groups: serializeGroupsForTrack(track),
-      references: track.references.map(serializeLink),
-      referencedBy: track.referencedBy.map(serializeLink),
-      alsoReleasedAs: otherReleases.map((track) => ({
-        track: serializeLink(track),
-        album: serializeLink(track.album),
-      })),
-    }),
-  };
-
-  const getSocialEmbedDescription = ({
-    getArtistString: _getArtistString,
-    language,
-  }) => {
-    const hasArtists = !empty(track.artistContribs);
-    const hasCoverArtists = !empty(track.coverArtistContribs);
-    const getArtistString = (contribs) =>
-      _getArtistString(contribs, {
-        // We don't want to put actual HTML tags in social embeds (sadly
-        // they don't get parsed and displayed, generally speaking), so
-        // override the link argument so that artist "links" just show
-        // their names.
-        link: {artist: (artist) => artist.name},
-      });
-    if (!hasArtists && !hasCoverArtists) return '';
-    return language.formatString(
-      'trackPage.socialEmbed.body' +
-        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
-          .filter(Boolean)
-          .join(''),
-      Object.fromEntries(
-        [
-          hasArtists && ['artists', getArtistString(track.artistContribs)],
-          hasCoverArtists && [
-            'coverArtists',
-            getArtistString(track.coverArtistContribs),
-          ],
-        ].filter(Boolean)
-      )
-    );
-  };
-
-  const page = {
-    type: 'page',
-    path: ['track', track.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateAdditionalFilesList,
-      generateAdditionalFilesShortcut,
-      generateChronologyLinks,
-      generateContentHeading,
-      generateNavigationLinks,
-      generateTrackListDividedByGroups,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      getTrackCover,
-      html,
-      link,
-      language,
-      transformLyrics,
-      transformMultiline,
-      to,
-      urls,
-    }) => {
-      const getTrackItem = bindOpts(unbound_getTrackItem, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, {
-        [bindOpts.bindIndex]: 2,
-        generateAdditionalFilesList,
-        getSizeOfAdditionalFile,
-        link,
-        urls,
-      });
-
-      return {
-        title: language.$('trackPage.title', {track: track.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-
-        themeColor: track.color,
-        theme:
-          getThemeString(track.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-              `--track-directory: ${track.directory}`,
-            ]
-          }),
-
-        socialEmbed: {
-          heading: language.$('trackPage.socialEmbed.heading', {
-            album: track.album.name,
-          }),
-          headingLink: absoluteTo('localized.album', album.directory),
-          title: language.$('trackPage.socialEmbed.title', {
-            track: track.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-          color: track.color,
-        },
-
-        // disabled for now! shifting banner position per height of page is disorienting
-        /*
-        banner: !empty(album.bannerArtistContribs) && {
-          classes: ['dim'],
-          dimensions: album.bannerDimensions,
-          path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'bottom'
-        },
-        */
-
-        cover: {
-          src: getTrackCover(track),
-          alt: language.$('misc.alt.trackCover'),
-          artTags: track.artTags,
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(track.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(track.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(track.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(track.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                track.date &&
-                  language.$('releaseInfo.released', {
-                    date: language.formatDate(track.date),
-                  }),
-
-                track.hasCoverArt &&
-                track.coverArtDate &&
-                +track.coverArtDate !== +track.date &&
-                  language.$('releaseInfo.artReleased', {
-                    date: language.formatDate(track.coverArtDate),
-                  }),
-
-                track.duration &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(
-                      track.duration
-                    ),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasSheetMusicFiles &&
-                  language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#sheet-music-files'},
-                      language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
-                  }),
-
-                hasMidiProjectFiles &&
-                  language.$('releaseInfo.midiProjectFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#midi-project-files'},
-                      language.$('releaseInfo.midiProjectFiles.shortcut.link')),
-                  }),
-
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(track.additionalFiles),
-              ]),
-
-            html.tag('p',
-              (empty(track.urls)
-                ? language.$('releaseInfo.listenOn.noLinks')
-                : language.$('releaseInfo.listenOn', {
-                    links: language.formatDisjunctionList(
-                      track.urls.map(url => fancifyURL(url, {language}))),
-                  }))),
-
-            ...html.fragment(
-              !empty(otherReleases) && [
-                generateContentHeading({
-                  id: 'also-released-as',
-                  title: language.$('releaseInfo.alsoReleasedAs'),
-                }),
-
-                html.tag('ul', otherReleases.map(track =>
-                  html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(contributorContribs) && [
-                generateContentHeading({
-                  id: 'contributors',
-                  title: language.$('releaseInfo.contributors'),
-                }),
-
-                html.tag('ul', contributorContribs.map(contrib =>
-                  html.tag('li', getArtistString([contrib], {
-                    showContrib: true,
-                    showIcons: true,
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedTracks) && [
-                generateContentHeading({
-                  id: 'references',
-                  title:
-                    language.$('releaseInfo.tracksReferenced', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', referencedTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedByTracks) && [
-                generateContentHeading({
-                  id: 'referenced-by',
-                  title:
-                    language.$('releaseInfo.tracksThatReference', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                generateTrackListDividedByGroups(referencedByTracks, {
-                  getTrackItem,
-                  wikiData,
-                }),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledTracks) && [
-                generateContentHeading({
-                  id: 'samples',
-                  title:
-                    language.$('releaseInfo.tracksSampled', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', sampledTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledByTracks) && [
-                generateContentHeading({
-                  id: 'sampled-by',
-                  title:
-                    language.$('releaseInfo.tracksThatSample', {
-                      track: html.tag('i', track.name),
-                    })
-                }),
-
-                html.tag('ul', sampledByTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashesThatFeature) && [
-                generateContentHeading({
-                  id: 'featured-in',
-                  title:
-                    language.$('releaseInfo.flashesThatFeature', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', flashesThatFeature.map(({flash, as}) =>
-                  html.tag('li',
-                    {class: as !== track && 'rerelease'},
-                    (as === track
-                      ? language.$('releaseInfo.flashesThatFeature.item', {
-                        flash: link.flash(flash),
-                      })
-                      : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                        flash: link.flash(flash),
-                        track: link.track(as),
-                      }))))),
-              ]),
-
-            ...html.fragment(
-              track.lyrics && [
-                generateContentHeading({
-                  id: 'lyrics',
-                  title: language.$('releaseInfo.lyrics'),
-                }),
-
-                html.tag('blockquote', transformLyrics(track.lyrics)),
-              ]),
-
-            ...html.fragment(
-              hasSheetMusicFiles && [
-                generateContentHeading({
-                  id: 'sheet-music-files',
-                  title: language.$('releaseInfo.sheetMusicFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, {
-                  fileSize: false,
-                }),
-              ]),
-
-            ...html.fragment(
-              hasMidiProjectFiles && [
-                generateContentHeading({
-                  id: 'midi-project-files',
-                  title: language.$('releaseInfo.midiProjectFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.midiProjectFiles),
-              ]),
-
-            ...html.fragment(
-              hasAdditionalFiles && [
-                generateContentHeading({
-                  id: 'additional-files',
-                  title: language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
-                      unit: true,
-                    }),
-                  })
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.additionalFiles),
-              ]),
-
-            ...html.fragment(
-              hasCommentary && [
-                generateContentHeading({
-                  id: 'artist-commentary',
-                  title: language.$('releaseInfo.artistCommentary'),
-                }),
-
-                html.tag('blockquote', generateCommentary({
-                  link,
-                  language,
-                  transformMultiline,
-                })),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, track, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              path: ['localized.album', album.directory],
-              title: album.name,
-            },
-            listTag === 'ol' &&
-              {
-                html: language.$('trackPage.nav.track.withNumber', {
-                  number: album.tracks.indexOf(track) + 1,
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-            listTag === 'ul' &&
-              {
-                html: language.$('trackPage.nav.track', {
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-          ].filter(Boolean),
-
-          content: generateAlbumChronologyLinks(album, track, {
-            generateChronologyLinks,
-            html,
-          }),
-
-          bottomRowContent:
-            album.tracks.length > 1 &&
-              generateAlbumNavLinks(album, track, {
-                generateNavigationLinks,
-                html,
-                language,
-              }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, track, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
     },
-  };
-
-  return [data, page];
+  ];
 }
diff --git a/src/static/client.js b/src/static/client.js
index efae8501..2f0b6aee 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -216,6 +216,7 @@ fetch(rebase('data.json', 'rebaseShared'))
 
 // Data & info card ---------------------------------------
 
+/*
 const NORMAL_HOVER_INFO_DELAY = 750;
 const FAST_HOVER_INFO_DELAY = 250;
 const END_FAST_HOVER_DELAY = 500;
@@ -444,6 +445,7 @@ function addInfoCardLinkHandlers(type) {
 if (localStorage.tryInfoCards) {
   addInfoCardLinkHandlers('track');
 }
+*/
 
 // Custom hash links --------------------------------------
 
@@ -559,6 +561,7 @@ function prepareStickyHeadings() {
   } of stickyHeadingInfo) {
     const coverRevealImage = contentCover?.querySelector('.reveal');
     if (coverRevealImage) {
+      stickyCover.classList.add('content-sticky-heading-cover-needs-reveal');
       coverRevealImage.addEventListener('hsmusic-reveal', () => {
         stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
       });
@@ -643,11 +646,17 @@ updateStickyHeading();
 // Image overlay ------------------------------------------
 
 function addImageOverlayClickHandlers() {
+  const container = document.getElementById('image-overlay-container');
+
+  if (!container) {
+    console.warn(`#image-overlay-container missing, image overlay module disabled.`);
+    return;
+  }
+
   for (const img of document.querySelectorAll('.image-link')) {
     img.addEventListener('click', handleImageLinkClicked);
   }
 
-  const container = document.getElementById('image-overlay-container');
   const actionContainer = document.getElementById('image-overlay-action-container');
 
   container.addEventListener('click', handleContainerClicked);
@@ -861,3 +870,37 @@ function loadImage(imageUrl, onprogress) {
     xhr.send();
   });
 }
+
+// Group contributions table ------------------------------
+
+const groupContributionsTableInfo =
+  Array.from(document.querySelectorAll('#content dl'))
+    .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
+    .map(dl => ({
+      sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'),
+      sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'),
+      sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'),
+      sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'),
+    }));
+
+function sortGroupContributionsTableBy(info, sort) {
+  const [showThese, hideThese] =
+    (sort === 'count'
+      ? [info.sortingByCountElements, info.sortingByDurationElements]
+      : [info.sortingByDurationElements, info.sortingByCountElements]);
+
+  for (const element of showThese) element.classList.add('visible');
+  for (const element of hideThese) element.classList.remove('visible');
+}
+
+for (const info of groupContributionsTableInfo) {
+  info.sortingByCountLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'duration');
+  });
+
+  info.sortingByDurationLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'count');
+  });
+}
diff --git a/src/static/site3.css b/src/static/site4.css
index 3ebe782d..6a23ff40 100644
--- a/src/static/site3.css
+++ b/src/static/site4.css
@@ -433,11 +433,15 @@ a:hover {
   text-decoration: underline;
 }
 
-.nav-main-links > span {
+a.current {
+  font-weight: 800;
+}
+
+.nav-main-links > span > span {
   white-space: nowrap;
 }
 
-.nav-main-links > span > a.current {
+.nav-main-links > span.current > span.nav-link-content > a {
   font-weight: 800;
 }
 
@@ -447,7 +451,7 @@ a:hover {
   font-weight: 800;
 }
 
-.nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before {
+.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before {
   content: "\0020/\0020";
 }
 
@@ -643,12 +647,29 @@ p code {
   font-weight: 800;
 }
 
-blockquote {
+#content blockquote {
   margin-left: 40px;
   max-width: 600px;
   margin-right: 0;
 }
 
+#content blockquote blockquote {
+  margin-left: 10px;
+  padding-left: 10px;
+  margin-right: 20px;
+  border-left: dotted 1px;
+  padding-top: 6px;
+  padding-bottom: 6px;
+}
+
+#content blockquote blockquote > :first-child {
+  margin-top: 0;
+}
+
+#content blockquote blockquote > :last-child {
+  margin-bottom: 0;
+}
+
 main.long-content .main-content-container,
 main.long-content > h1 {
   padding-left: 12%;
@@ -718,6 +739,25 @@ li > ul {
   margin-top: 5px;
 }
 
+.group-contributions-table {
+  display: inline-block;
+}
+
+.group-contributions-table .group-contributions-row {
+  display: flex;
+  justify-content: space-between;
+}
+
+.group-contributions-table .group-contributions-metrics {
+  margin-left: 1.5ch;
+  white-space: nowrap;
+}
+
+.group-contributions-sorted-by-count:not(.visible),
+.group-contributions-sorted-by-duration:not(.visible) {
+  display: none;
+}
+
 /* Images */
 
 .image-container {
@@ -752,6 +792,11 @@ li > ul {
   text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75);
 }
 
+.image-inner-area {
+  width: 100%;
+  height: 100%;
+}
+
 img {
   object-fit: cover;
 }
@@ -832,11 +877,6 @@ img {
   margin-bottom: auto;
 }
 
-.grid-item span {
-  overflow-wrap: break-word;
-  hyphens: auto;
-}
-
 .grid-item:hover {
   text-decoration: none;
 }
@@ -847,6 +887,8 @@ img {
 
 .grid-item > span {
   display: block;
+  overflow-wrap: break-word;
+  hyphens: auto;
 }
 
 .grid-item > span:not(:first-child) {
@@ -857,6 +899,11 @@ img {
   margin-top: 6px;
 }
 
+.grid-item > span:not(:first-of-type) {
+  font-size: 0.9em;
+  opacity: 0.8;
+}
+
 .grid-item:hover > span:first-of-type {
   text-decoration: underline;
 }
@@ -1358,6 +1405,24 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   contain: paint;
 }
 
+/* Sticky sidebar */
+
+.sidebar-column.sidebar.sticky-column,
+.sidebar-column.sidebar.sticky-last,
+.sidebar-multiple.sticky-last > .sidebar:last-child,
+.sidebar-multiple.sticky-column {
+  position: sticky;
+  top: 10px;
+}
+
+.sidebar-multiple.sticky-last {
+  align-self: stretch;
+}
+
+.sidebar-multiple.sticky-column {
+  align-self: flex-start;
+}
+
 /* Image overlay */
 
 #image-overlay-container {
diff --git a/src/strings-default.json b/src/strings-default.json
index a075f445..a6614931 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -19,10 +19,16 @@
   "count.albums.withUnit.zero": "",
   "count.albums.withUnit.one": "{ALBUMS} album",
   "count.albums.withUnit.two": "",
-  "count.albums.withUnit.two": "",
   "count.albums.withUnit.few": "",
   "count.albums.withUnit.many": "",
   "count.albums.withUnit.other": "{ALBUMS} albums",
+  "count.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",
@@ -44,6 +50,13 @@
   "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",
@@ -96,14 +109,19 @@
   "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 track has no URLs at which it can be listened.",
+  "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:",
@@ -120,8 +138,8 @@
   "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
   "releaseInfo.artTags": "Tags:",
   "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
+  "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}",
@@ -133,10 +151,10 @@
   "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.",
   "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files",
   "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:",
-  "releaseInfo.note": "Note:",
+  "releaseInfo.note": "Context notes:",
   "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-  "trackList.group": "{GROUP}:",
-  "trackList.group.other": "Other",
+  "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}",
@@ -214,6 +232,7 @@
   "misc.contentWarnings": "cw: {WARNINGS}",
   "misc.contentWarnings.reveal": "click to show",
   "misc.albumGrid.details": "({TRACKS}, {TIME})",
+  "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})",
   "misc.albumGrid.noCoverArt": "{ALBUM}",
   "misc.albumGalleryGrid.noCoverArt": "{NAME}",
   "misc.uiLanguage": "UI Language: {LANGUAGES}",
@@ -255,6 +274,7 @@
   "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}",
@@ -270,7 +290,17 @@
   "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
   "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
   "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-  "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
+  "artistPage.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",
@@ -329,8 +359,8 @@
   "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.date": "{DATE}",
-  "listingPage.listAlbums.byDateAdded.album": "{ALBUM}",
+  "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})",
@@ -345,16 +375,20 @@
   "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.item": "{ARTIST} ({DATE})",
+  "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.category": "{CATEGORY}",
-  "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})",
-  "listingPage.listGroups.byCategory.group.gallery": "Gallery",
+  "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})",
@@ -427,6 +461,7 @@
   "listingPage.other.randomPages.title.short": "Random Pages",
   "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!)",
diff --git a/src/upd8.js b/src/upd8.js
index 9f54b3bb..366dc21b 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -772,6 +772,7 @@ async function main() {
     developersComment,
     getSizeOfAdditionalFile,
     getSizeOfImageFile,
+    niceShowAggregate,
   });
 }
 
diff --git a/src/util/html.js b/src/util/html.js
index 2db1f2eb..2468b8db 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,5 +1,10 @@
 // Some really simple functions for formatting HTML content.
 
+import {inspect} from 'util';
+
+import * as commonValidators from '../data/things/validators.js';
+import {empty} from './sugar.js';
+
 // COMPREHENSIVE!
 // https://html.spec.whatwg.org/multipage/syntax.html#void-elements
 export const selfClosingTags = [
@@ -38,120 +43,790 @@ export const joinChildren = Symbol();
 // or when there are multiple children.
 export const noEdgeWhitespace = Symbol();
 
-export function tag(tagName, ...args) {
-  const selfClosing = selfClosingTags.includes(tagName);
+// Note: This is only guaranteed to return true for blanks (as returned by
+// html.blank()) and false for Tags and Templates (regardless of contents or
+// other properties). Don't depend on this to match any other values.
+export function isBlank(value) {
+  if (isTag(value)) {
+    return false;
+  }
+
+  if (isTemplate(value)) {
+    return false;
+  }
+
+  if (!Array.isArray(value)) {
+    return false;
+  }
+
+  return value.length === 0;
+}
+
+export function isTag(value) {
+  return value instanceof Tag;
+}
+
+export function isTemplate(value) {
+  return value instanceof Template;
+}
+
+export function isHTML(value) {
+  if (typeof value === 'string') {
+    return true;
+  }
+
+  if (value === null || value === undefined || value === false) {
+    return true;
+  }
+
+  if (isBlank(value) || isTag(value) || isTemplate(value)) {
+    return true;
+  }
+
+  if (Array.isArray(value)) {
+    if (value.every(isHTML)) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+export function isAttributes(value) {
+  if (typeof value !== 'object' || Array.isArray(value)) {
+    return false;
+  }
+
+  if (value === null) {
+    return false;
+  }
+
+  if (isTag(value) || isTemplate(value)) {
+    return false;
+  }
+
+  // TODO: Validate attribute values (just the general shape)
+
+  return true;
+}
+
+export const validators = {
+  // TODO: Move above implementations here and detail errors
+
+  isBlank(value) {
+    if (!isBlank(value)) {
+      throw new TypeError(`Expected html.blank()`);
+    }
+
+    return true;
+  },
+
+  isTag(value) {
+    if (!isTag(value)) {
+      throw new TypeError(`Expected HTML tag`);
+    }
+
+    return true;
+  },
+
+  isTemplate(value) {
+    if (!isTemplate(value)) {
+      throw new TypeError(`Expected HTML template`);
+    }
+
+    return true;
+  },
+
+  isHTML(value) {
+    if (!isHTML(value)) {
+      throw new TypeError(`Expected HTML content`);
+    }
+
+    return true;
+  },
+
+  isAttributes(value) {
+    if (!isAttributes(value)) {
+      throw new TypeError(`Expected HTML attributes`);
+    }
+
+    return true;
+  },
+};
 
-  let openTag;
+export function blank() {
+  return [];
+}
+
+export function tag(tagName, ...args) {
   let content;
-  let attrs;
+  let attributes;
 
-  if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-    attrs = args[0];
+  if (
+    typeof args[0] === 'object' &&
+    !(Array.isArray(args[0]) ||
+      args[0] instanceof Tag ||
+      args[0] instanceof Template)
+  ) {
+    attributes = args[0];
     content = args[1];
   } else {
     content = args[0];
   }
 
-  if (selfClosing && content) {
-    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  return new Tag(tagName, attributes, content);
+}
+
+export function tags(content) {
+  return new Tag(null, null, content);
+}
+
+export class Tag {
+  #tagName = '';
+  #content = null;
+  #attributes = null;
+
+  constructor(tagName, attributes, content) {
+    this.tagName = tagName;
+    this.attributes = attributes;
+    this.content = content;
+  }
+
+  clone() {
+    return new Tag(this.tagName, this.attributes, this.content);
+  }
+
+  set tagName(value) {
+    if (value === undefined || value === null) {
+      this.tagName = '';
+      return;
+    }
+
+    if (typeof value !== 'string') {
+      throw new Error(`Expected tagName to be a string`);
+    }
+
+    if (selfClosingTags.includes(value) && this.content.length) {
+      throw new Error(`Tag <${value}> is self-closing but this tag has content`);
+    }
+
+    this.#tagName = value;
+  }
+
+  get tagName() {
+    return this.#tagName;
+  }
+
+  set attributes(attributes) {
+    if (attributes instanceof Attributes) {
+      this.#attributes = attributes;
+    } else {
+      this.#attributes = new Attributes(attributes);
+    }
+  }
+
+  get attributes() {
+    if (this.#attributes === null) {
+      this.attributes = {};
+    }
+
+    return this.#attributes;
+  }
+
+  set content(value) {
+    if (
+      this.selfClosing &&
+      !(value === null ||
+        value === undefined ||
+        !Boolean(value) ||
+        Array.isArray(value) && value.filter(Boolean).length === 0)
+    ) {
+      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
+    }
+
+    let contentArray;
+
+    if (Array.isArray(value)) {
+      contentArray = value;
+    } else {
+      contentArray = [value];
+    }
+
+    this.#content = contentArray
+      .flat(Infinity)
+      .filter(Boolean);
+
+    this.#content.toString = () => this.#stringifyContent();
+  }
+
+  get content() {
+    if (this.#content === null) {
+      this.#content = [];
+    }
+
+    return this.#content;
+  }
+
+  get selfClosing() {
+    if (this.tagName) {
+      return selfClosingTags.includes(this.tagName);
+    } else {
+      return false;
+    }
+  }
+
+  #setAttributeFlag(attribute, value) {
+    if (value) {
+      this.attributes.set(attribute, true);
+    } else {
+      this.attributes.remove(attribute);
+    }
+  }
+
+  #getAttributeFlag(attribute) {
+    return !!this.attributes.get(attribute);
   }
 
-  if (Array.isArray(content)) {
-    if (content.some(item => Array.isArray(item))) {
-      throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`);
+  #setAttributeString(attribute, value) {
+    // Note: This function accepts and records the empty string ('')
+    // distinctly from null/undefined.
+
+    if (value === undefined || value === null) {
+      this.attributes.remove(attribute);
+      return undefined;
+    } else {
+      this.attributes.set(attribute, String(value));
+    }
+  }
+
+  #getAttributeString(attribute) {
+    const value = this.attributes.get(attribute);
+
+    if (value === undefined || value === null) {
+      return undefined;
+    } else {
+      return String(value);
+    }
+  }
+
+  set onlyIfContent(value) {
+    this.#setAttributeFlag(onlyIfContent, value);
+  }
+
+  get onlyIfContent() {
+    return this.#getAttributeFlag(onlyIfContent);
+  }
+
+  set joinChildren(value) {
+    this.#setAttributeString(joinChildren, value);
+  }
+
+  get joinChildren() {
+    return this.#getAttributeString(joinChildren);
+  }
+
+  set noEdgeWhitespace(value) {
+    this.#setAttributeFlag(noEdgeWhitespace, value);
+  }
+
+  get noEdgeWhitespace() {
+    return this.#getAttributeFlag(noEdgeWhitespace);
+  }
+
+  toString() {
+    const attributesString = this.attributes.toString();
+    const contentString = this.content.toString();
+
+    if (this.onlyIfContent && !contentString) {
+      return '';
+    }
+
+    if (!this.tagName) {
+      return contentString;
+    }
+
+    const openTag = (attributesString
+      ? `<${this.tagName} ${attributesString}>`
+      : `<${this.tagName}>`);
+
+    if (this.selfClosing) {
+      return openTag;
+    }
+
+    const closeTag = `</${this.tagName}>`;
+
+    if (!this.content.length) {
+      return openTag + closeTag;
     }
 
-    const joiner = attrs?.[joinChildren];
-    content = content.filter(Boolean).join(
-      (joiner === ''
+    if (!contentString.includes('\n')) {
+      return openTag + contentString + closeTag;
+    }
+
+    const parts = [
+      openTag,
+      contentString
+        .split('\n')
+        .map((line, i) =>
+          (i === 0 && this.noEdgeWhitespace
+            ? line
+            : '    ' + line))
+        .join('\n'),
+      closeTag,
+    ];
+
+    return parts.join(
+      (this.noEdgeWhitespace
         ? ''
-        : (joiner
-            ? `\n${joiner}\n`
-            : '\n')));
+        : '\n'));
   }
 
-  if (attrs?.[onlyIfContent] && !content) {
-    return '';
+  #stringifyContent() {
+    if (this.selfClosing) {
+      return '';
+    }
+
+    const joiner =
+      (this.joinChildren === undefined
+        ? '\n'
+        : (this.joinChildren === ''
+            ? ''
+            : `\n${this.joinChildren}\n`));
+
+    return this.content
+      .map(item => item.toString())
+      .filter(Boolean)
+      .join(joiner);
   }
 
-  if (attrs) {
-    const attrString = attributes(attrs);
-    if (attrString) {
-      openTag = `${tagName} ${attrString}`;
+  [inspect.custom]() {
+    if (this.tagName) {
+      if (empty(this.content)) {
+        return `Tag <${this.tagName} />`;
+      } else {
+        return `Tag <${this.tagName}> (${this.content.length} items)`;
+      }
+    } else {
+      if (empty(this.content)) {
+        return `Tag (no name)`;
+      } else {
+        return `Tag (no name, ${this.content.length} items)`;
+      }
     }
   }
+}
+
+export class Attributes {
+  #attributes = Object.create(null);
+
+  constructor(attributes) {
+    this.attributes = attributes;
+  }
+
+  set attributes(value) {
+    if (value === undefined || value === null) {
+      this.#attributes = {};
+      return;
+    }
+
+    if (typeof value !== 'object') {
+      throw new Error(`Expected attributes to be an object`);
+    }
+
+    this.#attributes = Object.create(null);
+    Object.assign(this.#attributes, value);
+  }
 
-  if (!openTag) {
-    openTag = tagName;
+  get attributes() {
+    return this.#attributes;
   }
 
-  if (content) {
-    if (content.includes('\n')) {
-      return [
-        `<${openTag}>`,
-        content
-          .split('\n')
-          .map((line, i) =>
-            (i === 0 && attrs?.[noEdgeWhitespace]
-              ? line
-              : '    ' + line))
-          .join('\n'),
-        `</${tagName}>`,
-      ].join(
-        (attrs?.[noEdgeWhitespace]
-          ? ''
-          : '\n'));
+  set(attribute, value) {
+    if (value === null || value === undefined) {
+      this.remove(attribute);
     } else {
-      return `<${openTag}>${content}</${tagName}>`;
+      this.#attributes[attribute] = value;
     }
-  } else if (selfClosing) {
-    return `<${openTag}>`;
-  } else {
-    return `<${openTag}></${tagName}>`;
+    return value;
+  }
+
+  get(attribute) {
+    return this.#attributes[attribute];
+  }
+
+  remove(attribute) {
+    return delete this.#attributes[attribute];
+  }
+
+  toString() {
+    return Object.entries(this.attributes)
+      .map(([key, val]) => {
+        if (typeof val === 'undefined' || val === null)
+          return [key, val, false];
+        else if (typeof val === 'string')
+          return [key, val, true];
+        else if (typeof val === 'boolean')
+          return [key, val, val];
+        else if (typeof val === 'number')
+          return [key, val.toString(), true];
+        else if (Array.isArray(val))
+          return [key, val.filter(Boolean).join(' '), val.length > 0];
+        else
+          throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
+      })
+      .filter(([_key, _val, keep]) => keep)
+      .map(([key, val]) =>
+        typeof val === 'boolean'
+          ? `${key}`
+          : `${key}="${this.#escapeAttributeValue(val)}"`
+      )
+      .join(' ');
+  }
+
+  #escapeAttributeValue(value) {
+    return value
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&apos;');
   }
 }
 
-export function escapeAttributeValue(value) {
-  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+export function template(description) {
+  return new Template(description);
 }
 
-export function attributes(attribs) {
-  return Object.entries(attribs)
-    .map(([key, val]) => {
-      if (typeof val === 'undefined' || val === null)
-        return [key, val, false];
-      else if (typeof val === 'string')
-        return [key, val, true];
-      else if (typeof val === 'boolean')
-        return [key, val, val];
-      else if (typeof val === 'number')
-        return [key, val.toString(), true];
-      else if (Array.isArray(val))
-        return [key, val.filter(Boolean).join(' '), val.length > 0];
-      else
-        throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-    })
-    .filter(([_key, _val, keep]) => keep)
-    .map(([key, val]) =>
-      typeof val === 'boolean'
-        ? `${key}`
-        : `${key}="${escapeAttributeValue(val)}"`
-    )
-    .join(' ');
+export class Template {
+  #description = {};
+  #slotValues = {};
+
+  constructor(description) {
+    if (!description[Stationery.validated]) {
+      Template.validateDescription(description);
+    }
+
+    this.#description = description;
+  }
+
+  clone() {
+    const clone = new Template(this.#description);
+    clone.setSlots(this.#slotValues);
+    return clone;
+  }
+
+  static validateDescription(description) {
+    if (typeof description !== 'object') {
+      throw new TypeError(`Expected object, got ${typeof description}`);
+    }
+
+    if (description === null) {
+      throw new TypeError(`Expected object, got null`);
+    }
+
+    const topErrors = [];
+
+    if (!('content' in description)) {
+      topErrors.push(new TypeError(`Expected description.content`));
+    } else if (typeof description.content !== 'function') {
+      topErrors.push(new TypeError(`Expected description.content to be function`));
+    }
+
+    if ('annotation' in description) {
+      if (typeof description.annotation !== 'string') {
+        topErrors.push(new TypeError(`Expected annotation to be string`));
+      }
+    }
+
+    if ('slots' in description) validateSlots: {
+      if (typeof description.slots !== 'object') {
+        topErrors.push(new TypeError(`Expected description.slots to be object`));
+        break validateSlots;
+      }
+
+      try {
+        this.validateSlotsDescription(description.slots);
+      } catch (slotError) {
+        topErrors.push(slotError);
+      }
+    }
+
+    if (!empty(topErrors)) {
+      throw new AggregateError(topErrors,
+        (typeof description.annotation === 'string'
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
+
+    return true;
+  }
+
+  static validateSlotsDescription(slots) {
+    const slotErrors = [];
+
+    for (const [slotName, slotDescription] of Object.entries(slots)) {
+      if (typeof slotDescription !== 'object' || slotDescription === null) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
+        continue;
+      }
+
+      if ('default' in slotDescription) validateDefault: {
+        if (
+          slotDescription.default === undefined ||
+          slotDescription.default === null
+        ) {
+          slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
+          break validateDefault;
+        }
+
+        try {
+          Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
+        } catch (error) {
+          error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
+          slotErrors.push(error);
+        }
+      }
+
+      if ('validate' in slotDescription && 'type' in slotDescription) {
+        slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
+      } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
+      } else if ('validate' in slotDescription) {
+        if (typeof slotDescription.validate !== 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
+        }
+      } else if ('type' in slotDescription) {
+        const acceptableSlotTypes = [
+          'string',
+          'number',
+          'bigint',
+          'boolean',
+          'symbol',
+          'html',
+        ];
+
+        if (slotDescription.type === 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
+        } else if (slotDescription.type === 'object') {
+          slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
+        } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
+        }
+      }
+    }
+
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors, `Errors in slot descriptions`);
+    }
+
+    return true;
+  }
+
+  slot(slotName, value) {
+    this.setSlot(slotName, value);
+    return this;
+  }
+
+  slots(slotNamesToValues) {
+    this.setSlots(slotNamesToValues);
+    return this;
+  }
+
+  setSlot(slotName, value) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+
+    try {
+      Template.validateSlotValueAgainstDescription(value, description);
+    } catch (error) {
+      error.message =
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}`
+          : `Error validating template slot "${slotName}" value: ${error.message}`);
+      throw error;
+    }
+
+    this.#slotValues[slotName] = value;
+  }
+
+  setSlots(slotNamesToValues) {
+    if (
+      typeof slotNamesToValues !== 'object' ||
+      Array.isArray(slotNamesToValues) ||
+      slotNamesToValues === null
+    ) {
+      throw new TypeError(`Expected object mapping of slot names to values`);
+    }
+
+    const slotErrors = [];
+
+    for (const [slotName, value] of Object.entries(slotNamesToValues)) {
+      const description = this.#getSlotDescriptionNoError(slotName);
+      if (!description) {
+        slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`));
+        continue;
+      }
+
+      try {
+        Template.validateSlotValueAgainstDescription(value, description);
+      } catch (error) {
+        error.message = `(${slotName}) ${error.message}`;
+        slotErrors.push(error);
+      }
+    }
+
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors,
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slots`
+          : `Error validating template slots`));
+    }
+
+    Object.assign(this.#slotValues, slotNamesToValues);
+  }
+
+  static validateSlotValueAgainstDescription(value, description) {
+    if (value === undefined) {
+      throw new TypeError(`Specify value as null or don't specify at all`);
+    }
+
+    // Null is always an acceptable slot value.
+    if (value !== null) {
+      if ('validate' in description) {
+        description.validate({
+          ...commonValidators,
+          ...validators,
+        })(value);
+      }
+
+      if ('type' in description) {
+        const {type} = description;
+        if (type === 'html') {
+          if (!isHTML(value)) {
+            throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`);
+          }
+        } else {
+          if (typeof value !== type) {
+            throw new TypeError(`Slot expects ${type}, got ${typeof value}`);
+          }
+        }
+      }
+    }
+
+    return true;
+  }
+
+  getSlotValue(slotName) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+    const providedValue = this.#slotValues[slotName] ?? null;
+
+    if (description.type === 'html') {
+      if (!providedValue) {
+        return blank();
+      }
+
+      if (providedValue instanceof Tag || providedValue instanceof Template) {
+        return providedValue.clone();
+      }
+
+      return providedValue;
+    }
+
+    if (providedValue !== null) {
+      return providedValue;
+    }
+
+    if ('default' in description) {
+      return description.default;
+    }
+
+    return null;
+  }
+
+  getSlotDescription(slotName) {
+    return this.#getSlotDescriptionOrError(slotName);
+  }
+
+  #getSlotDescriptionNoError(slotName) {
+    if (this.#description.slots) {
+      if (Object.hasOwn(this.#description.slots, slotName)) {
+        return this.#description.slots[slotName];
+      }
+    }
+
+    return null;
+  }
+
+  #getSlotDescriptionOrError(slotName) {
+    const description = this.#getSlotDescriptionNoError(slotName);
+
+    if (!description) {
+      throw new TypeError(
+        (this.description.annotation
+          ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot`
+          : `Template doesn't have a "${slotName}" slot`));
+    }
+
+    return description;
+  }
+
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
+  }
+
+  get content() {
+    const slots = {};
+
+    for (const slotName of Object.keys(this.description.slots ?? {})) {
+      slots[slotName] = this.getSlotValue(slotName);
+    }
+
+    return this.description.content(slots);
+  }
+
+  set description(_value) {
+    throw new Error(`Template description can't be changed after constructed`);
+  }
+
+  get description() {
+    return this.#description;
+  }
+
+  toString() {
+    return this.content.toString();
+  }
+
+  [inspect.custom]() {
+    const {annotation} = this.description;
+    if (annotation) {
+      return `Template "${annotation}"`;
+    } else {
+      return `Template (no annotation)`;
+    }
+  }
+}
+
+export function stationery(description) {
+  return new Stationery(description);
 }
 
-// Ensures the passed value is an array of elements, for usage in [...spread]
-// syntax. This may be used when it's not guaranteed whether the return value of
-// an external function is one child or an array, or in combination with
-// conditionals, e.g. fragment(cond && [x, y, z]).
-export function fragment(childOrChildren) {
-  if (!childOrChildren) {
-    return [];
+export class Stationery {
+  #templateDescription = null;
+
+  static validated = Symbol('Stationery.validated');
+
+  constructor(templateDescription) {
+    Template.validateDescription(templateDescription);
+    templateDescription[Stationery.validated] = true;
+    this.#templateDescription = templateDescription;
   }
 
-  if (Array.isArray(childOrChildren)) {
-    return childOrChildren;
+  template() {
+    return new Template(this.#templateDescription);
   }
 
-  return [childOrChildren];
+  [inspect.custom]() {
+    const {annotation} = this.#templateDescription;
+    if (annotation) {
+      return `Stationery "${annotation}"`;
+    } else {
+      return `Stationery (no annotation)`;
+    }
+  }
 }
diff --git a/src/util/link.js b/src/util/link.js
index 62106345..a9f79c8b 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -24,23 +24,29 @@ export function unbound_getLinkThemeString(color, {
 
 const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/;
 
-const linkHelper =
-  (hrefFn, {
-    color = true,
-    attr = null,
-  } = {}) =>
-  (thing, {
+function linkHelper({
+  path: pathOption,
+
+  expectThing = true,
+  color: colorOption = true,
+
+  attr: attrOption = null,
+  data: dataOption = null,
+  text: textOption = null,
+}) {
+  const generateLink = (data, {
     getLinkThemeString,
     to,
 
     text = '',
     attributes = null,
     class: className = '',
-    color: color2 = true,
+    color = true,
     hash = '',
     preferShortName = false,
   }) => {
-    let href = hrefFn(thing, {to});
+    const path = (expectThing ? pathOption(data) : pathOption());
+    let href = to(...path);
 
     if (link.globalOptions.appendIndexHTML) {
       if (appendIndexHTMLRegex.test(href)) {
@@ -52,41 +58,100 @@ const linkHelper =
       href += (hash.startsWith('#') ? '' : '#') + hash;
     }
 
-    return html.tag(
-      'a',
+    return html.tag('a',
       {
-        ...(attr ? attr(thing) : {}),
+        ...(attrOption ? attrOption(data) : {}),
         ...(attributes ? attributes : {}),
         href,
         style:
-          typeof color2 === 'string'
-            ? getLinkThemeString(color2)
-            : color2 && color
-            ? getLinkThemeString(thing.color)
+          typeof color === 'string'
+            ? getLinkThemeString(color)
+            : color && colorOption
+            ? getLinkThemeString(data.color)
             : '',
         class: className,
       },
+
       (text ||
-        (preferShortName
-          ? thing.nameShort ?? thing.name
-          : thing.name))
-    );
+        (textOption
+          ? textOption(data)
+          : (preferShortName
+              ? data.nameShort ?? data.name
+              : data.name))));
+  };
+
+  generateLink.data = thing => {
+    if (!expectThing) {
+      throw new Error(`This kind of link doesn't need any data serialized`);
+    }
+
+    const data = (dataOption ? dataOption(thing) : {});
+
+    if (colorOption) {
+      data.color = thing.color;
+    }
+
+    if (!textOption) {
+      data.name = thing.name;
+      data.nameShort = thing.nameShort ?? thing.name;
+    }
+
+    return data;
   };
 
-const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-  linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-    attr: (thing) => ({
-      ...(attr ? attr(thing) : {}),
-      ...(expose ? {[expose]: thing.directory} : {}),
+  return generateLink;
+}
+
+function linkDirectory(key, {
+  exposeDirectory = null,
+  prependLocalized = true,
+
+  data = null,
+  attr = null,
+  ...conf
+} = {}) {
+  return linkHelper({
+    data: thing => ({
+      ...(data ? data(thing) : {}),
+      directory: thing.directory,
     }),
+
+    path: data =>
+      (prependLocalized
+        ? ['localized.' + key, data.directory]
+        : [key, data.directory]),
+
+    attr: (data) => ({
+      ...(attr ? attr(data) : {}),
+      ...(exposeDirectory ? {[exposeDirectory]: data.directory} : {}),
+    }),
+
     ...conf,
   });
+}
 
-const linkPathname = (key, conf) =>
-  linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
+function linkIndex(key, conf) {
+  return linkHelper({
+    path: () => [key],
 
-const linkIndex = (key, conf) =>
-  linkHelper((_, {to}) => to('localized.' + key), conf);
+    expectThing: false,
+    ...conf,
+  });
+}
+
+function linkAdditionalFile(key, conf) {
+  return linkHelper({
+    data: ({file, album}) => ({
+      directory: album.directory,
+      file,
+    }),
+
+    path: data => ['media.albumAdditionalFile', data.directory, data.file],
+
+    color: false,
+    ...conf,
+  });
+}
 
 // Mapping of Thing constructor classes to the key for a link.x() function.
 // These represent a sensible "default" link, i.e. to the primary page for
@@ -114,6 +179,7 @@ const link = {
   },
 
   album: linkDirectory('album'),
+  albumAdditionalFile: linkAdditionalFile('albumAdditionalFile'),
   albumGallery: linkDirectory('albumGallery'),
   albumCommentary: linkDirectory('albumCommentary'),
   artist: linkDirectory('artist', {color: false}),
@@ -130,32 +196,26 @@ const link = {
   newsEntry: linkDirectory('newsEntry', {color: false}),
   staticPage: linkDirectory('staticPage', {color: false}),
   tag: linkDirectory('tag'),
-  track: linkDirectory('track', {expose: 'data-track'}),
-
-  // TODO: This is a bit hacky. Files are just strings (not objects), so we
-  // have to manually provide the album alongside the file. They also don't
-  // follow the usual {name: whatever} type shape, so we have to provide that
-  // ourselves.
-  _albumAdditionalFileHelper: linkHelper(
-    (fakeFileObject, {to}) =>
-      to(
-        'media.albumAdditionalFile',
-        fakeFileObject.album.directory,
-        fakeFileObject.name),
-    {color: false}),
-
-  albumAdditionalFile: ({file, album}, {to, ...opts}) =>
-    link._albumAdditionalFileHelper(
-      {
-        name: file,
-        album,
-      },
-      {to, ...opts}),
-
-  media: linkPathname('media.path', {color: false}),
-  root: linkPathname('shared.path', {color: false}),
-  data: linkPathname('data.path', {color: false}),
-  site: linkPathname('localized.path', {color: false}),
+  track: linkDirectory('track', {exposeDirectory: 'data-track'}),
+
+  media: linkDirectory('media.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  root: linkDirectory('shared.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+  data: linkDirectory('data.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  site: linkDirectory('localized.path', {
+    prependLocalized: false,
+    color: false,
+  }),
 
   // This is NOT an arrow functions because it should be callable for other
   // "this" objects - i.e, if we bind arguments in other functions on the same
diff --git a/src/util/replacer.js b/src/util/replacer.js
index ea957eda..50a90004 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -221,11 +221,10 @@ function parseNodes(input, i, stopAt, textOnly) {
       let hash;
 
       if (stop_literal === tagHash) {
-        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
         if (!stopped) throw endOfInput(i, `reading hash`);
-
-        if (!N) throw makeError(i, `Expected content (hash).`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
 
         hash = N;
         i = stop_iParse;
@@ -294,6 +293,10 @@ function parseNodes(input, i, stopAt, textOnly) {
 }
 
 export function parseInput(input) {
+  if (typeof input !== 'string') {
+    throw new TypeError(`Expected input to be string, got ${input}`);
+  }
+
   try {
     return parseNodes(input, 0);
   } catch (errorNode) {
@@ -378,7 +381,7 @@ function evaluateTag(node, opts) {
     (transformName && transformName(value.name, node, input)) ||
     null;
 
-  const hash = node.data.hash && transformNodes(node.data.hash, opts);
+  const hash = node.data.hash && transformNode(node.data.hash, opts);
 
   const args =
     node.data.args &&
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 0813c1d4..da21d6d0 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -26,18 +26,24 @@ export function* splitArray(array, fn) {
   }
 }
 
-// Null-accepting function to check if an array is empty. Accepts null (and
-// treats as empty) as a shorthand for "hey, check if this property is an array
-// with/without stuff in it" for objects where properties that are PRESENT but
-// don't currently have a VALUE are null (instead of undefined).
-export function empty(arrayOrNull) {
-  if (arrayOrNull === null) {
+// Null-accepting function to check if an array or set is empty. Accepts null
+// (which is treated as empty) as a shorthand for "hey, check if this property
+// is an array with/without stuff in it" for objects where properties that are
+// PRESENT but don't currently have a VALUE are null (rather than undefined).
+export function empty(value) {
+  if (value === null) {
     return true;
-  } else if (Array.isArray(arrayOrNull)) {
-    return arrayOrNull.length === 0;
-  } else {
-    throw new Error(`Expected array or null`);
   }
+
+  if (Array.isArray(value)) {
+    return value.length === 0;
+  }
+
+  if (value instanceof Set) {
+    return value.size === 0;
+  }
+
+  throw new Error(`Expected array, set, or null`);
 }
 
 // Repeats all the items of an array a number of times.
@@ -67,6 +73,76 @@ export function accumulateSum(array, fn = x => x) {
     0);
 }
 
+// Stitches together the items of separate arrays into one array of objects
+// whose keys are the corresponding items from each array at that index.
+// This is mostly useful for iterating over multiple arrays at once!
+export function stitchArrays(keyToArray) {
+  const errors = [];
+
+  for (const [key, value] of Object.entries(keyToArray)) {
+    if (value === null) continue;
+    if (Array.isArray(value)) continue;
+    errors.push(new TypeError(`(${key}) Expected array or null, got ${value}`));
+  }
+
+  if (!empty(errors)) {
+    throw new AggregateError(errors, `Expected arrays or null`);
+  }
+
+  const keys = Object.keys(keyToArray);
+  const arrays = Object.values(keyToArray).filter(val => Array.isArray(val));
+  const length = Math.max(...arrays.map(({length}) => length));
+  const results = [];
+
+  for (let i = 0; i < length; i++) {
+    const object = {};
+    for (const key of keys) {
+      object[key] =
+        (Array.isArray(keyToArray[key])
+          ? keyToArray[key][i]
+          : null);
+    }
+    results.push(object);
+  }
+
+  return results;
+}
+
+// Turns this:
+//
+//   [
+//     [123, 'orange', null],
+//     [456, 'apple', true],
+//     [789, 'banana', false],
+//     [1000, 'pear', undefined],
+//   ]
+//
+// Into this:
+//
+//   [
+//     [123, 456, 789, 1000],
+//     ['orange', 'apple', 'banana', 'pear'],
+//     [null, true, false, undefined],
+//   ]
+//
+// And back again, if you call it again on its results.
+export function transposeArrays(arrays) {
+  if (empty(arrays)) {
+    return [];
+  }
+
+  const length = arrays[0].length;
+  const results = new Array(length).fill(null).map(() => []);
+
+  for (const array of arrays) {
+    for (let i = 0; i < length; i++) {
+      results[i].push(array[i]);
+    }
+  }
+
+  return results;
+}
+
 export const mapInPlace = (array, fn) =>
   array.splice(0, array.length, ...array.map(fn));
 
@@ -82,6 +158,24 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
 export const withEntries = (obj, fn) =>
   Object.fromEntries(fn(Object.entries(obj)));
 
+export function setIntersection(set1, set2) {
+  const intersection = new Set();
+  for (const item of set1) {
+    if (set2.has(item)) {
+      intersection.add(item);
+    }
+  }
+  return intersection;
+}
+
+export function filterProperties(obj, properties) {
+  const set = new Set(properties);
+  return Object.fromEntries(
+    Object
+      .entries(obj)
+      .filter(([key]) => set.has(key)));
+}
+
 export function queue(array, max = 50) {
   if (max === 0) {
     return array.map((fn) => fn());
@@ -146,10 +240,20 @@ export function bindOpts(fn, bind) {
     ]);
   };
 
-  Object.defineProperty(bound, 'name', {
-    value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`,
+  annotateFunction(bound, {
+    name: fn,
+    trait: 'options-bound',
   });
 
+  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
+    if (key === 'length') continue;
+    if (key === 'name') continue;
+    if (key === 'arguments') continue;
+    if (key === 'caller') continue;
+    if (key === 'prototype') continue;
+    Object.defineProperty(bound, key, descriptor);
+  }
+
   return bound;
 }
 
@@ -216,6 +320,10 @@ export function openAggregate({
       );
     };
 
+  aggregate.push = (error) => {
+    errors.push(error);
+  };
+
   aggregate.call = (fn, ...args) => {
     return aggregate.wrap(fn)(...args);
   };
@@ -421,6 +529,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 export function showAggregate(topError, {
   pathToFileURL = f => f,
   showTraces = true,
+  print = true,
 } = {}) {
   const recursive = (error, {level}) => {
     let header = showTraces
@@ -465,7 +574,13 @@ export function showAggregate(topError, {
     }
   };
 
-  console.error(recursive(topError, {level: 0}));
+  const message = recursive(topError, {level: 0});
+
+  if (print) {
+    console.error(message);
+  } else {
+    return message;
+  }
 }
 
 export function decorateErrorWithIndex(fn) {
@@ -478,3 +593,74 @@ export function decorateErrorWithIndex(fn) {
     }
   };
 }
+
+// Delicious function annotations, such as:
+//
+//   (*bound) soWeAreBackInTheMine
+//   (data *unfulfilled) generateShrekTwo
+//
+export function annotateFunction(fn, {
+  name: nameOrFunction = null,
+  description: newDescription,
+  trait: newTrait,
+}) {
+  let name;
+
+  if (typeof nameOrFunction === 'function') {
+    name = nameOrFunction.name;
+  } else if (typeof nameOrFunction === 'string') {
+    name = nameOrFunction;
+  }
+
+  name ??= fn.name ?? 'anonymous';
+
+  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
+
+  let prefix, suffix, description, trait;
+  if (match) {
+    ({prefix, suffix, description, trait} = match.groups);
+  }
+
+  prefix ??= '';
+  suffix ??= name;
+  description ??= '';
+  trait ??= '';
+
+  if (newDescription) {
+    if (description) {
+      description += '; ' + newDescription;
+    } else {
+      description = newDescription;
+    }
+  }
+
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
+    } else {
+      trait = '#' + newTrait;
+    }
+  }
+
+  let parenthesesPart;
+
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
+  } else {
+    parenthesesPart = '';
+  }
+
+  let finalName;
+
+  if (prefix && parenthesesPart) {
+    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
+  } else if (parenthesesPart) {
+    finalName = `(${parenthesesPart}) ${suffix}`;
+  } else {
+    finalName = suffix;
+  }
+
+  Object.defineProperty(fn, 'name', {value: finalName});
+}
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
index d1d0f51a..454cb374 100644
--- a/src/util/transform-content.js
+++ b/src/util/transform-content.js
@@ -3,7 +3,6 @@
 // interfaces for converting various content found in wiki data to HTML for
 // display on the site.
 
-import * as html from './html.js';
 export {transformInline} from './replacer.js';
 
 export const replacerSpec = {
@@ -34,7 +33,7 @@ export const replacerSpec = {
   date: {
     find: null,
     value: (ref) => new Date(ref),
-    html: (date, {language}) =>
+    html: (date, {html, language}) =>
       html.tag('time',
         {datetime: date.toString()},
         language.formatDate(date)),
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 89c621c5..a3133748 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -3,6 +3,8 @@
 import {
   accumulateSum,
   empty,
+  stitchArrays,
+  unique,
 } from './sugar.js';
 
 // Generic value operations
@@ -70,6 +72,36 @@ export function chunkByProperties(array, properties) {
   }));
 }
 
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const newChunk = index => arrays.map(array => [array[index]]);
+  const results = [newChunk(0)];
+
+  for (let i = 1; i < arrays[0].length; i++) {
+    const current = results.at(-1);
+
+    const args = [];
+    for (let j = 0; j < arrays.length; j++) {
+      const item = arrays[j][i];
+      const previous = current[j].at(-1);
+      args.push(item, previous);
+    }
+
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
+
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
+  }
+
+  return results;
+}
+
 // Sorting functions - all utils here are mutating, so make sure to initially
 // slice/filter/somehow generate a new array from input data if retaining the
 // initial sort matters! (Spoilers: If what you're doing involves any kind of
@@ -117,6 +149,123 @@ export function normalizeName(s) {
   return s;
 }
 
+// Sorts multiple arrays by an arbitrary function (which is the last argument).
+// Paired values from each array are provided to the callback sequentially:
+//
+//   (a_fromFirstArray, b_fromFirstArray,
+//    a_fromSecondArray, b_fromSecondArray,
+//    a_fromThirdArray, b_fromThirdArray) =>
+//     relative positioning (negative, positive, or zero)
+//
+// Like native single-array sort, this is a mutating function.
+export function sortMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const length = arrays[0].length;
+  const symbols = new Array(length).fill(null).map(() => Symbol());
+  const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index]));
+
+  symbols.sort((a, b) => {
+    const indexA = indexes[a];
+    const indexB = indexes[b];
+
+    const args = [];
+    for (let i = 0; i < arrays.length; i++) {
+      args.push(arrays[i][indexA]);
+      args.push(arrays[i][indexB]);
+    }
+
+    return fn(...args);
+  });
+
+  for (const array of arrays) {
+    // Note: We're mutating this array pulling values from itself, but only all
+    // at once after all those values have been pulled.
+    array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]]));
+  }
+
+  return arrays;
+}
+
+// Filters multiple arrays by an arbitrary function (which is the last argument).
+// Values from each array are provided to the callback sequentially:
+//
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
+//
+// Please be aware that this is a mutating function, unlike native single-array
+// filter. The mutated arrays are returned. Also attached under `.removed` are
+// corresponding arrays of items filtered out.
+export function filterMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const removed = new Array(arrays.length).fill(null).map(() => []);
+
+  for (let i = arrays[0].length - 1; i >= 0; i--) {
+    const args = arrays.map(array => array[i]);
+    args.push(i, arrays);
+
+    if (!fn(...args)) {
+      for (let j = 0; j < arrays.length; j++) {
+        const item = arrays[j][i];
+        arrays[j].splice(i, 1);
+        removed[j].unshift(item);
+      }
+    }
+  }
+
+  Object.assign(arrays, {removed});
+  return arrays;
+}
+
+// Reduces multiple arrays with an arbitrary function (which is the last
+// argument). Note that this reduces into multiple accumulators, one for
+// each input array, not just a single value. That's reflected in both the
+// callback parameters:
+//
+//   (accumulator1,
+//    accumulator2,
+//    value_fromFirstArray,
+//    value_fromSecondArray,
+//    index,
+//    [firstArray, secondArray]) =>
+//      [newAccumulator1, newAccumulator2]
+//
+// As well as the final return value of reduceMultipleArrays:
+//
+//   [finalAccumulator1, finalAccumulator2]
+//
+// This is not a mutating function.
+export function reduceMultipleArrays(...args) {
+  const [arrays, fn, initialAccumulators] =
+    (typeof args.at(-1) === 'function'
+      ? [args.slice(0, -1), args.at(-1), null]
+      : [args.slice(0, -2), args.at(-2), args.at(-1)]);
+
+  if (empty(arrays[0])) {
+    throw new TypeError(`Reduce of empty arrays with no initial value`);
+  }
+
+  let [accumulators, i] =
+    (initialAccumulators
+      ? [initialAccumulators, 0]
+      : [arrays.map(array => array[0]), 1]);
+
+  for (; i < arrays[0].length; i++) {
+    const args = [...accumulators, ...arrays.map(array => array[i])];
+    args.push(i, arrays);
+    accumulators = fn(...args);
+  }
+
+  return accumulators;
+}
+
 // Component sort functions - these sort by one particular property, applying
 // unique particulars where appropriate. Usually you don't want to use these
 // directly, but if you're making a custom sort they can come in handy.
@@ -146,65 +295,126 @@ export function normalizeName(s) {
 // sortByDirectory will handle the rest, given all directories are unique
 // except when album and track directories overlap with each other.
 export function sortByDirectory(data, {
-  getDirectory = (o) => o.directory,
+  getDirectory = object => object.directory,
 } = {}) {
-  return data.sort((a, b) => {
-    const ad = getDirectory(a);
-    const bd = getDirectory(b);
-    return compareCaseLessSensitive(ad, bd);
-  });
+  const directories = data.map(getDirectory);
+
+  sortMultipleArrays(data, directories,
+    (a, b, directoryA, directoryB) =>
+      compareCaseLessSensitive(directoryA, directoryB));
+
+  return data;
 }
 
 export function sortByName(data, {
-  getName = (o) => o.name,
+  getName = object => object.name,
 } = {}) {
-  const nameMap = new Map();
-  const normalizedNameMap = new Map();
-  for (const o of data) {
-    const name = getName(o);
-    const normalizedName = normalizeName(name);
-    nameMap.set(o, name);
-    normalizedNameMap.set(o, normalizedName);
-  }
+  const names = data.map(getName);
+  const normalizedNames = names.map(normalizeName);
+
+  sortMultipleArrays(data, normalizedNames, names,
+    (
+      a, b,
+      normalizedA, normalizedB,
+      nonNormalizedA, nonNormalizedB,
+    ) =>
+      compareNormalizedNames(
+        normalizedA, normalizedB,
+        nonNormalizedA, nonNormalizedB,
+      ));
 
-  return data.sort((a, b) => {
-    const ann = normalizedNameMap.get(a);
-    const bnn = normalizedNameMap.get(b);
-    const comparison = compareCaseLessSensitive(ann, bnn);
-    if (comparison !== 0)
-      return comparison;
-
-    const an = nameMap.get(a);
-    const bn = nameMap.get(b);
-    return compareCaseLessSensitive(an, bn);
-  });
+  return data;
+}
+
+export function compareNormalizedNames(
+  normalizedA, normalizedB,
+  nonNormalizedA, nonNormalizedB,
+) {
+  const comparison = compareCaseLessSensitive(normalizedA, normalizedB);
+  return (
+    (comparison === 0
+      ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB)
+      : comparison));
 }
 
 export function sortByDate(data, {
+  getDate = object => object.date,
   latestFirst = false,
-  getDate = (o) => o.date,
 } = {}) {
-  return data.sort((a, b) => {
-    const ad = getDate(a);
-    const bd = getDate(b);
-
-    // It's possible for objects with and without dates to be mixed
-    // together in the same array. If that's the case, we put all items
-    // without dates at the end.
-    if (ad && bd) {
-      return (latestFirst ? bd - ad : ad - bd);
-    } else if (ad) {
-      return -1;
-    } else if (bd) {
-      return 1;
-    } else {
-      // If neither of the items being compared have a date, don't move
-      // them relative to each other. This is basically the same as
-      // filtering out all non-date items and then pushing them at the
-      // end after sorting the rest.
-      return 0;
-    }
-  });
+  const dates = data.map(getDate);
+
+  sortMultipleArrays(data, dates,
+    (a, b, dateA, dateB) =>
+      compareDates(dateA, dateB, {latestFirst}));
+
+  return data;
+}
+
+export function compareDates(a, b, {
+  latestFirst = false,
+} = {}) {
+  if (a && b) {
+    return (latestFirst ? b - a : a - b);
+  }
+
+  // It's possible for objects with and without dates to be mixed
+  // together in the same array. If that's the case, we put all items
+  // without dates at the end.
+  if (a) return -1;
+  if (b) return 1;
+
+  // If neither of the items being compared have a date, don't move
+  // them relative to each other. This is basically the same as
+  // filtering out all non-date items and then pushing them at the
+  // end after sorting the rest.
+  return 0;
+}
+
+export function getLatestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date > accumulator ? date : accumulator,
+      -Infinity);
+}
+
+export function getEarliestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date < accumulator ? date : accumulator,
+      Infinity);
+}
+
+// Funky sort which takes a data set and a corresponding list of "counts",
+// which are really arbitrary numbers representing some property of each data
+// object defined by the caller. It sorts and mutates *both* of these, so the
+// sorted data will still correspond to the same indexed count.
+export function sortByCount(data, counts, {
+  greatestFirst = false,
+} = {}) {
+  sortMultipleArrays(data, counts, (data1, data2, count1, count2) =>
+    (greatestFirst
+      ? count2 - count1
+      : count1 - count2));
+
+  return data;
+}
+
+// Corresponding filter function for the above sort. By default, items whose
+// corresponding count is zero will be removed.
+export function filterByCount(data, counts, {
+  min = 1,
+  max = Infinity,
+} = {}) {
+  filterMultipleArrays(data, counts, (data, count) =>
+    count >= min && count <= max);
 }
 
 export function sortByPositionInParent(data, {
@@ -315,6 +525,60 @@ export function sortChronologically(data, {
   return data;
 }
 
+// This one's a little odd! Sorts an array of {entry, thing} pairs using
+// the provided sortFunction, which will operate on each item's `thing`, not
+// its entry (or the item as a whole). If multiple entries are associated
+// with the same thing, they'll end up bunched together in the output,
+// retaining their original relative positioning.
+export function sortEntryThingPairs(data, sortFunction) {
+  const things = unique(data.map(item => item.thing));
+  sortFunction(things);
+
+  const outputArrays = [];
+  const thingToOutputArray = new Map();
+
+  for (const thing of things) {
+    const array = [];
+    thingToOutputArray.set(thing, array);
+    outputArrays.push(array);
+  }
+
+  for (const item of data) {
+    thingToOutputArray.get(item.thing).push(item);
+  }
+
+  data.splice(0, data.length, ...outputArrays.flat());
+
+  return data;
+}
+
+/*
+// Alternate draft version of sortEntryThingPairs.
+// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168
+
+// Maps the provided "preparation" function across a list of arbitrary values,
+// building up a list of sortable values; sorts these with the provided sorting
+// function; and reorders the sources to match their corresponding prepared
+// values. As usual, if multiple source items correspond to the same sorting
+// data, this retains the source relative positioning.
+export function prepareAndSort(sources, prepareForSort, sortFunction) {
+  const prepared = [];
+  const preparedToSource = new Map();
+
+  for (const original of originals) {
+    const prep = prepareForSort(source);
+    prepared.push(prep);
+    preparedToSource.set(prep, source);
+  }
+
+  sortFunction(prepared);
+
+  sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep)));
+
+  return sources;
+}
+*/
+
 // Highly contextual sort functions - these are only for very specific types
 // of Things, and have appropriately hard-coded behavior.
 
@@ -554,3 +818,65 @@ export function getNewReleases(numReleases, {wikiData}) {
     .slice(0, numReleases)
     .map((album) => ({item: album}));
 }
+
+// Carousel layout and utilities
+
+// Layout constants:
+//
+// Carousels support fitting 4-18 items, with a few "dead" zones to watch out
+// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles.
+//
+// Carousels are limited to 1-3 rows and 4-6 columns.
+// Lower edge case: 1-3 items are treated as 4 items (with blank space).
+// Upper edge case: all items past 18 are dropped (treated as 18 items).
+//
+// This is all done through JS instead of CSS because it's just... ANNOYING...
+// to write a mapping like this in CSS lol.
+const carouselLayoutMap = [
+  // 0-3
+  null, null, null, null,
+
+  // 4-6
+  {rows: 1, columns: 4}, //  4: 1x4, drop 0
+  {rows: 1, columns: 5}, //  5: 1x5, drop 0
+  {rows: 1, columns: 6}, //  6: 1x6, drop 0
+
+  // 7-12
+  {rows: 1, columns: 6}, //  7: 1x6, drop 1
+  {rows: 2, columns: 4}, //  8: 2x4, drop 0
+  {rows: 2, columns: 4}, //  9: 2x4, drop 1
+  {rows: 2, columns: 5}, // 10: 2x5, drop 0
+  {rows: 2, columns: 5}, // 11: 2x5, drop 1
+  {rows: 2, columns: 6}, // 12: 2x6, drop 0
+
+  // 13-18
+  {rows: 2, columns: 6}, // 13: 2x6, drop 1
+  {rows: 2, columns: 6}, // 14: 2x6, drop 2
+  {rows: 3, columns: 5}, // 15: 3x5, drop 0
+  {rows: 3, columns: 5}, // 16: 3x5, drop 1
+  {rows: 3, columns: 5}, // 17: 3x5, drop 2
+  {rows: 3, columns: 6}, // 18: 3x6, drop 0
+];
+
+const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null);
+const maxCarouselLayoutItems = carouselLayoutMap.length - 1;
+const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems];
+const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems];
+
+export function getCarouselLayoutForNumberOfItems(numItems) {
+  return (
+    numItems < minCarouselLayoutItems ? shortestCarouselLayout :
+    numItems > maxCarouselLayoutItems ? longestCarouselLayout :
+    carouselLayoutMap[numItems]);
+}
+
+export function filterItemsForCarousel(items) {
+  if (empty(items)) {
+    return [];
+  }
+
+  return items
+    .filter(item => item.hasCoverArt)
+    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
+    .slice(0, maxCarouselLayoutItems + 1);
+}
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index ffaaa7a7..d6053353 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -5,59 +5,28 @@
 import chroma from 'chroma-js';
 
 import {
-  fancifyFlashURL,
-  fancifyURL,
-  getAlbumGridHTML,
-  getAlbumStylesheet,
-  getArtistString,
-  getCarouselHTML,
-  getFlashGridHTML,
-  getGridHTML,
-  getRevealStringFromArtTags,
-  getRevealStringFromContentWarningMessage,
-  getThemeString,
-  generateAdditionalFilesList,
-  generateAdditionalFilesShortcut,
-  generateChronologyLinks,
-  generateContentHeading,
-  generateCoverLink,
-  generateInfoGalleryLinks,
-  generateTrackListDividedByGroups,
-  generateNavigationLinks,
-  generateStickyHeadingContainer,
-  iconifyURL,
-  img,
-} from '../misc-templates.js';
-
-import {
   replacerSpec,
   transformInline,
-  transformLyrics,
-  transformMultiline,
+  // transformLyrics,
+  // transformMultiline,
 } from '../util/transform-content.js';
 
 import * as html from '../util/html.js';
 
-import {bindOpts, withEntries} from '../util/sugar.js';
+import {bindOpts} from '../util/sugar.js';
 import {getColors} from '../util/colors.js';
 import {bindFind} from '../util/find.js';
-
-import link, {getLinkThemeString} from '../util/link.js';
-
-import {
-  getAlbumCover,
-  getArtistAvatar,
-  getFlashCover,
-  getTrackCover,
-} from '../util/wiki-data.js';
+import {thumb} from '../util/urls.js';
 
 export function bindUtilities({
   absoluteTo,
+  cachebust,
   defaultLanguage,
   getSizeOfAdditionalFile,
   getSizeOfImageFile,
   language,
   languages,
+  pagePath,
   to,
   urls,
   wikiData,
@@ -69,42 +38,22 @@ export function bindUtilities({
 
   Object.assign(bound, {
     absoluteTo,
+    cachebust,
     defaultLanguage,
     getSizeOfAdditionalFile,
     getSizeOfImageFile,
     html,
     language,
     languages,
+    pagePath,
+    thumb,
     to,
     urls,
     wikiData,
-  })
-
-  bound.img = bindOpts(img, {
-    [bindOpts.bindIndex]: 0,
-    getSizeOfImageFile,
-    html,
-    to,
-  });
-
-  bound.getColors = bindOpts(getColors, {
-    chroma,
-  });
-
-  bound.getLinkThemeString = bindOpts(getLinkThemeString, {
-    getColors: bound.getColors,
+    wikiInfo: wikiData.wikiInfo,
   });
 
-  bound.getThemeString = bindOpts(getThemeString, {
-    getColors: bound.getColors,
-  });
-
-  bound.link = withEntries(link, (entries) =>
-    entries
-      .map(([key, fn]) => [key, bindOpts(fn, {
-        getLinkThemeString: bound.getLinkThemeString,
-        to,
-      })]));
+  bound.getColors = bindOpts(getColors, {chroma});
 
   bound.find = bindFind(wikiData, {mode: 'warn'});
 
@@ -117,6 +66,7 @@ export function bindUtilities({
     wikiData,
   });
 
+  /*
   bound.transformMultiline = bindOpts(transformMultiline, {
     img: bound.img,
     to,
@@ -127,81 +77,14 @@ export function bindUtilities({
     transformInline: bound.transformInline,
     transformMultiline: bound.transformMultiline,
   });
+  */
 
-  bound.iconifyURL = bindOpts(iconifyURL, {
-    html,
-    language,
-    to,
-  });
-
-  bound.fancifyURL = bindOpts(fancifyURL, {
-    html,
-    language,
-  });
-
-  bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-    [bindOpts.bindIndex]: 2,
-    html,
-    language,
-
-    fancifyURL: bound.fancifyURL,
-  });
-
-  bound.getRevealStringFromContentWarningMessage = bindOpts(getRevealStringFromContentWarningMessage, {
-    html,
-    language,
-  });
-
-  bound.getRevealStringFromArtTags = bindOpts(getRevealStringFromArtTags, {
-    language,
-
-    getRevealStringFromContentWarningMessage: bound.getRevealStringFromContentWarningMessage,
-  });
-
-  bound.getArtistString = bindOpts(getArtistString, {
-    html,
-    link: bound.link,
-    language,
-
-    iconifyURL: bound.iconifyURL,
-  });
-
-  bound.getAlbumCover = bindOpts(getAlbumCover, {
-    to,
-  });
-
-  bound.getTrackCover = bindOpts(getTrackCover, {
-    to,
-  });
-
-  bound.getFlashCover = bindOpts(getFlashCover, {
-    to,
-  });
-
-  bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-    to,
-  });
-
-  bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-    html,
-    language,
-  });
-
-  bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-    html,
-    language,
-  });
-
+  /*
   bound.generateNavigationLinks = bindOpts(generateNavigationLinks, {
     link: bound.link,
     language,
   });
 
-  bound.generateContentHeading = bindOpts(generateContentHeading, {
-    [bindOpts.bindIndex]: 0,
-    html,
-  });
-
   bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
     [bindOpts.bindIndex]: 0,
     html,
@@ -217,30 +100,12 @@ export function bindUtilities({
     generateNavigationLinks: bound.generateNavigationLinks,
   });
 
-  bound.generateCoverLink = bindOpts(generateCoverLink, {
-    [bindOpts.bindIndex]: 0,
-    html,
-    img: bound.img,
-    link: bound.link,
-    language,
-    to,
-    wikiData,
-
-    getRevealStringFromArtTags: bound.getRevealStringFromArtTags,
-  });
-
   bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
     [bindOpts.bindIndex]: 2,
     link: bound.link,
     language,
   });
 
-  bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-    html,
-    language,
-    wikiData,
-  });
-
   bound.getGridHTML = bindOpts(getGridHTML, {
     [bindOpts.bindIndex]: 0,
     img: bound.img,
@@ -271,11 +136,8 @@ export function bindUtilities({
     [bindOpts.bindIndex]: 0,
     img: bound.img,
     html,
-  })
-
-  bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-    to,
   });
+  */
 
   return bound;
 }
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 6dfa7d71..d7c33d87 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -11,7 +11,7 @@ import {serializeThings} from '../../data/serialize.js';
 import * as pageSpecs from '../../page/index.js';
 
 import {logInfo, logWarn, progressCallAll} from '../../util/cli.js';
-
+import {empty} from '../../util/sugar.js';
 import {
   getPagePathname,
   getPagePathnameAcrossLanguages,
@@ -20,11 +20,21 @@ import {
 } from '../../util/urls.js';
 
 import {
-  generateDocumentHTML,
   generateGlobalWikiDataJSON,
   generateRedirectHTML,
 } from '../page-template.js';
 
+import {
+  watchContentDependencies,
+} from '../../content/dependencies/index.js';
+
+import {
+  fillRelationsLayoutFromSlotResults,
+  flattenRelationsTree,
+  getRelationsTree,
+  getNeededContentDependencyNames,
+} from '../../content-function.js';
+
 const defaultHost = '0.0.0.0';
 const defaultPort = 8002;
 
@@ -64,20 +74,39 @@ export async function go({
   developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImageFile,
+  niceShowAggregate,
 }) {
+  const showError = (error) => {
+    if (error instanceof AggregateError && niceShowAggregate) {
+      niceShowAggregate(error);
+    } else {
+      console.error(error);
+    }
+  };
+
   const host = cliOptions['host'] ?? defaultHost;
   const port = parseInt(cliOptions['port'] ?? defaultPort);
 
+  const contentDependenciesWatcher = await watchContentDependencies();
+  const {contentDependencies: allContentDependencies} = contentDependenciesWatcher;
+
+  contentDependenciesWatcher.on('error', () => {});
+  await new Promise(resolve => contentDependenciesWatcher.once('ready', resolve));
+
   let targetSpecPairs = getPageSpecsWithTargets({wikiData});
   const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
-    targetSpecPairs.map(({
+    targetSpecPairs.flatMap(({
       pageSpec,
       target,
       targetless,
-    }) => () =>
-      targetless
-        ? pageSpec.writeTargetless({wikiData})
-        : pageSpec.write(target, {wikiData}))).flat();
+    }) => () => {
+      if (targetless) {
+        const result = pageSpec.pathsTargetless({wikiData});
+        return Array.isArray(result) ? result : [result];
+      } else {
+        return pageSpec.pathsForTarget(target);
+      }
+    })).flat();
 
   logInfo`Will be serving a total of ${pages.length} pages.`;
 
@@ -143,7 +172,7 @@ export async function go({
         response.writeHead(500, contentTypeJSON);
         response.end({error: `Internal error serializing wiki JSON`});
         console.error(`${requestHead} [500] /data.json`);
-        console.error(error);
+        showError(error);
       }
       return;
     }
@@ -186,7 +215,7 @@ export async function go({
           response.writeHead(500, contentTypePlain);
           response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
           console.error(`${requestHead} [500] ${pathname}`);
-          console.error(error);
+          showError(error);
         }
         return;
       }
@@ -239,7 +268,7 @@ export async function go({
         response.writeHead(500, contentTypePlain);
         response.end(`Failed during file-to-response pipeline`);
         console.error(`${requestHead} [500] ${pathname}`);
-        console.error(error);
+        showError(error);
       }
       return;
     }
@@ -305,8 +334,6 @@ export async function go({
         return;
       }
 
-      response.writeHead(200, contentTypeHTML);
-
       const localizedPathnames = getPagePathnameAcrossLanguages({
         defaultLanguage,
         languages,
@@ -314,37 +341,135 @@ export async function go({
         urls,
       });
 
+      const {name, args = []} = page.contentFunction;
+
       const bound = bindUtilities({
         absoluteTo,
+        cachebust,
         defaultLanguage,
         getSizeOfAdditionalFile,
         getSizeOfImageFile,
         language,
         languages,
+        pagePath: servePath,
         to,
         urls,
         wikiData,
       });
 
-      const pageInfo = page.page(bound);
-
-      const pageHTML = generateDocumentHTML(pageInfo, {
+      const allExtraDependencies = {
         ...bound,
-        cachebust,
-        developersComment,
-        localizedPathnames,
-        oEmbedJSONHref: null, // No oEmbed support for live dev server
-        pagePath: servePath,
-        pathname,
-      });
+
+        appendIndexHTML: false,
+      };
+
+      // NOTE: ALL THIS STUFF IS PASTED, REVIEW AND INTEGRATE SOON(TM)
+
+      const treeInfo = getRelationsTree(allContentDependencies, name, wikiData, ...args);
+      const flatTreeInfo = flattenRelationsTree(treeInfo);
+      const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+      const neededContentDependencyNames =
+        getNeededContentDependencyNames(allContentDependencies, name);
+
+      // Content functions aren't recursive, so by following the set above
+      // sequentually, we will always provide fulfilled content functions as the
+      // dependencies for later content functions.
+      const fulfilledContentDependencies = {};
+      for (const name of neededContentDependencyNames) {
+        const unfulfilledContentFunction = allContentDependencies[name];
+        if (!unfulfilledContentFunction) continue;
+
+        const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+        if (empty(contentDependencies) && empty(extraDependencies)) {
+          fulfilledContentDependencies[name] = unfulfilledContentFunction;
+          continue;
+        }
+
+        const fulfillments = {};
+
+        for (const dependencyName of contentDependencies ?? []) {
+          if (dependencyName in fulfilledContentDependencies) {
+            fulfillments[dependencyName] =
+              fulfilledContentDependencies[dependencyName];
+          }
+        }
+
+        for (const dependencyName of extraDependencies ?? []) {
+          if (dependencyName in allExtraDependencies) {
+            fulfillments[dependencyName] =
+              allExtraDependencies[dependencyName];
+          }
+        }
+
+        fulfilledContentDependencies[name] =
+          unfulfilledContentFunction.fulfill(fulfillments);
+      }
+
+      // There might still be unfulfilled content functions if dependencies weren't
+      // provided as part of allContentDependencies or allExtraDependencies.
+      // Catch and report these early, together in an aggregate error.
+      const unfulfilledErrors = [];
+      const unfulfilledNames = [];
+      for (const name of neededContentDependencyNames) {
+        const contentFunction = fulfilledContentDependencies[name];
+        if (!contentFunction) continue;
+        if (!contentFunction.fulfilled) {
+          try {
+            contentFunction();
+          } catch (error) {
+            error.message = `(${name}) ${error.message}`;
+            unfulfilledErrors.push(error);
+            unfulfilledNames.push(name);
+          }
+        }
+      }
+
+      if (!empty(unfulfilledErrors)) {
+        throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+      }
+
+      const slotResults = {};
+
+      function runContentFunction({name, args, relations: layout}) {
+        const contentFunction = fulfilledContentDependencies[name];
+
+        if (!contentFunction) {
+          throw new Error(`Content function ${name} unfulfilled or not listed`);
+        }
+
+        const generateArgs = [];
+
+        if (contentFunction.data) {
+          generateArgs.push(contentFunction.data(...args));
+        }
+
+        if (layout) {
+          generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout));
+        }
+
+        return contentFunction(...generateArgs);
+      }
+
+      for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+        slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+      }
+
+      const topLevelResult = runContentFunction(root);
+
+      // END PASTE
+
+      const pageHTML = topLevelResult.toString();
 
       console.log(`${requestHead} [200] ${pathname}`);
+      response.writeHead(200, contentTypeHTML);
       response.end(pageHTML);
     } catch (error) {
       response.writeHead(500, contentTypePlain);
       response.end(`Error generating page, view server log for details\n`);
       console.error(`${requestHead} [500] ${pathname}`);
-      console.error(error);
+      showError(error);
     }
   });
 
@@ -360,7 +485,7 @@ export async function go({
       }, 10_000);
     } else {
       console.error(`Server error detected (code: ${error.code})`);
-      console.error(error);
+      showError(error);
     }
   });
 
@@ -387,7 +512,7 @@ function getPageSpecsWithTargets({
         ? pageSpec.targets({wikiData})
             .map(target => ({pageSpec, target}))
         : [],
-      Object.hasOwn(pageSpec, 'writeTargetless') &&
+      Object.hasOwn(pageSpec, 'pathsTargetless') &&
         {pageSpec, targetless: true},
     ])
     .filter(Boolean);
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 8e02342c..2fb82b84 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -1,14 +1,18 @@
 import * as path from 'path';
 
 import {bindUtilities} from '../bind-utilities.js';
-import {validateWrites} from '../validate-writes.js';
+// import {validateWrites} from '../validate-writes.js';
 
 import {
-  generateDocumentHTML,
-  generateGlobalWikiDataJSON,
-  generateOEmbedJSON,
-  generateRedirectHTML,
-} from '../page-template.js';
+  quickLoadContentDependencies,
+} from '../../content/dependencies/index.js';
+
+import {
+  fillRelationsLayoutFromSlotResults,
+  flattenRelationsTree,
+  getRelationsTree,
+  getNeededContentDependencyNames,
+} from '../../content-function.js';
 
 import {serializeThings} from '../../data/serialize.js';
 
@@ -143,10 +147,12 @@ export async function go({
     outputPath,
     urls,
     wikiData,
+    /*
     wikiDataJSON: generateGlobalWikiDataJSON({
       serializeThings,
       wikiData,
     })
+    */
   });
 
   const buildSteps = writeAll
@@ -158,64 +164,47 @@ export async function go({
   {
     let error = false;
 
-    const buildStepsWithTargets = buildSteps
+    // TODO: Port this to aggregate error
+    writes = buildSteps
       .map(([flag, pageSpec]) => {
-        // Condition not met: skip this build step altogether.
         if (pageSpec.condition && !pageSpec.condition({wikiData})) {
           return null;
         }
 
-        // May still call writeTargetless if present.
-        if (!pageSpec.targets) {
-          return {flag, pageSpec, targets: []};
-        }
-
-        if (!pageSpec.write) {
-          logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-          error = true;
-          return null;
-        }
+        const paths = [];
 
-        const targets = pageSpec.targets({wikiData});
-        if (!Array.isArray(targets)) {
-          logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`;
-          error = true;
-          return null;
+        if (pageSpec.pathsTargetless) {
+          const result = pageSpec.pathsTargetless({wikiData});
+          if (Array.isArray(result)) {
+            paths.push(...result);
+          } else {
+            paths.push(result);
+          }
         }
 
-        return {flag, pageSpec, targets};
-      })
-      .filter(Boolean);
+        if (pageSpec.targets) {
+          if (!pageSpec.pathsForTarget) {
+            logError`${flag + '.targets'} is specified, but ${flag + '.pathsForTarget'} is missing!`;
+            error = true;
+            return null;
+          }
 
-    if (error) {
-      return false;
-    }
+          const targets = pageSpec.targets({wikiData});
 
-    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
-      const writesFns = targets.map(target => () => {
-        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
-        const valid = validateWrites(writes, {
-          functionName: flag + '.write',
-          urlSpec,
-        });
-        error ||=! valid;
-        return valid ? writes : [];
-      });
+          if (!Array.isArray(targets)) {
+            logError`${flag + '.targets'} was called, but it didn't return an array! (${targets})`;
+            error = true;
+            return null;
+          }
 
-      if (pageSpec.writeTargetless) {
-        writesFns.push(() => {
-          const writes = pageSpec.writeTargetless({wikiData});
-          const valid = validateWrites(writes, {
-            functionName: flag + '.writeTargetless',
-            urlSpec,
-          });
-          error ||=! valid;
-          return valid ? writes : [];
-        });
-      }
+          paths.push(...targets.flatMap(target => pageSpec.pathsForTarget(target)));
+          // TODO: Validate each pathsForTargets entry
+        }
 
-      return writesFns;
-    })).flat();
+        return paths;
+      })
+      .filter(Boolean)
+      .flat();
 
     if (error) {
       return false;
@@ -267,6 +256,8 @@ export async function go({
   ));
   */
 
+  const allContentDependencies = await quickLoadContentDependencies();
+
   const perLanguageFn = async (language, i, entries) => {
     const baseDirectory =
       language === defaultLanguage ? '' : language.code;
@@ -303,16 +294,19 @@ export async function go({
 
         const bound = bindUtilities({
           absoluteTo,
+          cachebust,
           defaultLanguage,
           getSizeOfAdditionalFile,
           getSizeOfImageFile,
           language,
           languages,
+          pagePath,
           to,
           urls,
           wikiData,
         });
 
+        /*
         const pageInfo = page.page(bound);
 
         const oEmbedJSON = generateOEmbedJSON(pageInfo, {
@@ -327,20 +321,111 @@ export async function go({
             urls
               .from('shared.root')
               .to('shared.path', pathname + 'oembed.json');
+        */
 
-        const pageHTML = generateDocumentHTML(pageInfo, {
+        const allExtraDependencies = {
           ...bound,
-          cachebust,
-          developersComment,
-          localizedPathnames,
-          oEmbedJSONHref,
-          pagePath,
-          pathname,
-        });
+          appendIndexHTML: false,
+        };
+
+        const {name, args = []} = page.contentFunction;
+        const treeInfo = getRelationsTree(allContentDependencies, name, wikiData, ...args);
+        const flatTreeInfo = flattenRelationsTree(treeInfo);
+        const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+        const neededContentDependencyNames =
+          getNeededContentDependencyNames(allContentDependencies, name);
+
+        // Content functions aren't recursive, so by following the set above
+        // sequentually, we will always provide fulfilled content functions as the
+        // dependencies for later content functions.
+        const fulfilledContentDependencies = {};
+        for (const name of neededContentDependencyNames) {
+          const unfulfilledContentFunction = allContentDependencies[name];
+          if (!unfulfilledContentFunction) continue;
+
+          const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+          if (empty(contentDependencies) && empty(extraDependencies)) {
+            fulfilledContentDependencies[name] = unfulfilledContentFunction;
+            continue;
+          }
+
+          const fulfillments = {};
+
+          for (const dependencyName of contentDependencies ?? []) {
+            if (dependencyName in fulfilledContentDependencies) {
+              fulfillments[dependencyName] =
+                fulfilledContentDependencies[dependencyName];
+            }
+          }
+
+          for (const dependencyName of extraDependencies ?? []) {
+            if (dependencyName in allExtraDependencies) {
+              fulfillments[dependencyName] =
+                allExtraDependencies[dependencyName];
+            }
+          }
+
+          fulfilledContentDependencies[name] =
+            unfulfilledContentFunction.fulfill(fulfillments);
+        }
+
+        // There might still be unfulfilled content functions if dependencies weren't
+        // provided as part of allContentDependencies or allExtraDependencies.
+        // Catch and report these early, together in an aggregate error.
+        const unfulfilledErrors = [];
+        const unfulfilledNames = [];
+        for (const name of neededContentDependencyNames) {
+          const contentFunction = fulfilledContentDependencies[name];
+          if (!contentFunction) continue;
+          if (!contentFunction.fulfilled) {
+            try {
+              contentFunction();
+            } catch (error) {
+              error.message = `(${name}) ${error.message}`;
+              unfulfilledErrors.push(error);
+              unfulfilledNames.push(name);
+            }
+          }
+        }
+
+        if (!empty(unfulfilledErrors)) {
+          throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+        }
+
+        const slotResults = {};
+
+        function runContentFunction({name, args, relations: layout}) {
+          const contentFunction = fulfilledContentDependencies[name];
+
+          if (!contentFunction) {
+            throw new Error(`Content function ${name} unfulfilled or not listed`);
+          }
+
+          const generateArgs = [];
+
+          if (contentFunction.data) {
+            generateArgs.push(contentFunction.data(...args));
+          }
+
+          if (layout) {
+            generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout));
+          }
+
+          return contentFunction(...generateArgs);
+        }
+
+        for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+          slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+        }
+
+        const topLevelResult = runContentFunction(root);
+        const pageHTML = topLevelResult.toString();
 
         return writePage({
           html: pageHTML,
-          oEmbedJSON,
+          // oEmbedJSON,
           outputDirectory: path.join(outputPath, getPagePathname({
             baseDirectory,
             device: true,
@@ -497,6 +582,7 @@ async function writeSharedFilesAndPages({
   const {groupData, wikiInfo} = wikiData;
 
   return progressPromiseAll(`Writing files & pages shared across languages.`, [
+    /*
     groupData?.some((group) => group.directory === 'fandom') &&
       redirect(
         'Fandom - Gallery',
@@ -520,6 +606,7 @@ async function writeSharedFilesAndPages({
         'localized.commentaryIndex',
         ''
       ),
+    */
 
     wikiDataJSON &&
       writeFile(
diff --git a/src/write/page-template.js b/src/write/page-template.js
index fcd8759c..d3d7b098 100644
--- a/src/write/page-template.js
+++ b/src/write/page-template.js
@@ -3,11 +3,6 @@ import chroma from 'chroma-js';
 import * as html from '../util/html.js';
 import {getColors} from '../util/colors.js';
 
-import {
-  getFooterLocalizationLinks,
-  getRevealStringFromContentWarningMessage,
-} from '../misc-templates.js';
-
 export function generateDevelopersCommentHTML({
   buildTime,
   commit,
@@ -153,40 +148,7 @@ export function generateDocumentHTML(pageInfo, {
   const collapseSidebars =
     sidebarLeft.collapse !== false && sidebarRight.collapse !== false;
 
-  const mainHTML =
-    html.tag('main', {
-      id: 'content',
-      class: main.classes,
-    }, [
-      ...html.fragment(
-          !title ?
-            null
-        : main.headingMode === 'sticky' ?
-            generateStickyHeadingContainer({
-              coverSrc: cover.src,
-              coverAlt: cover.alt,
-              coverArtTags: cover.artTags,
-              title,
-            })
-        : main.headingMode === 'static' ?
-            html.tag('h1', title)
-        : null),
-
-      ...html.fragment(
-        cover.src &&
-          generateCoverLink({
-            src: cover.src,
-            alt: cover.alt,
-            tags: cover.artTags,
-          })),
 
-      html.tag('div',
-        {
-          [html.onlyIfContent]: true,
-          class: 'main-content-container',
-        },
-        main.content),
-    ]);
 
   const footerHTML =
     html.tag('footer',
@@ -378,31 +340,6 @@ export function generateDocumentHTML(pageInfo, {
         height: banner.dimensions[1] || 200,
       }));
 
-  const layoutHTML = [
-    navHTML,
-    banner.position === 'top' && bannerHTML,
-    secondaryNavHTML,
-    html.tag('div',
-      {
-        class: [
-          'layout-columns',
-          !collapseSidebars && 'vertical-when-thin',
-          (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
-          (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
-          !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
-          sidebarLeftHTML && 'has-sidebar-left',
-          sidebarRightHTML && 'has-sidebar-right',
-        ],
-      },
-      [
-        sidebarLeftHTML,
-        mainHTML,
-        sidebarRightHTML,
-      ]),
-    banner.position === 'bottom' && bannerHTML,
-    footerHTML,
-  ].filter(Boolean).join('\n');
-
   const processSkippers = skipperList =>
     skipperList
       .filter(Boolean)
@@ -612,92 +549,7 @@ export function generateDocumentHTML(pageInfo, {
       }),
   ].filter(Boolean).join('\n');
 
-  return `<!DOCTYPE html>\n` + html.tag('html',
-    {
-      lang: language.intlCode,
-      'data-language-code': language.code,
-      'data-url-key': 'localized.' + pagePath[0],
-      ...Object.fromEntries(
-        pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
-      'data-rebase-localized': to('localized.root'),
-      'data-rebase-shared': to('shared.root'),
-      'data-rebase-media': to('media.root'),
-      'data-rebase-data': to('data.root'),
-    },
-    [
-      developersComment,
-
-      html.tag('head', [
-        html.tag('title',
-          showWikiNameInTitle
-            ? language.formatString('misc.pageTitle.withWikiName', {
-                title,
-                wikiName: wikiInfo.nameShort,
-              })
-            : language.formatString('misc.pageTitle', {title})),
-
-        html.tag('meta', {charset: 'utf-8'}),
-        html.tag('meta', {
-          name: 'viewport',
-          content: 'width=device-width, initial-scale=1',
-        }),
-
-        ...(
-          Object.entries(meta)
-            .filter(([key, value]) => value)
-            .map(([key, value]) => html.tag('meta', {[key]: value}))),
-
-        canonical &&
-          html.tag('link', {
-            rel: 'canonical',
-            href: canonical,
-          }),
-
-        ...(
-          localizedCanonical
-            .map(({lang, href}) => html.tag('link', {
-              rel: 'alternate',
-              hreflang: lang,
-              href,
-            }))),
-
-        socialEmbedHTML,
-
-        html.tag('link', {
-          rel: 'stylesheet',
-          href: to('shared.staticFile', 'site3.css', cachebust),
-        }),
-
-        html.tag('style',
-          {[html.onlyIfContent]: true},
-          [
-            theme,
-            stylesheet,
-          ]),
-
-        html.tag('script', {
-          src: to('shared.staticFile', 'lazy-loading.js', cachebust),
-        }),
-      ]),
-
-      html.tag('body',
-        {style: body.style || ''},
-        [
-          html.tag('div', {id: 'page-container'}, [
-            mainHTML &&
-            skippersHTML,
-            layoutHTML,
-          ]),
-
-          infoCardHTML,
-          imageOverlayHTML,
-
-          html.tag('script', {
-            type: 'module',
-            src: to('shared.staticFile', 'client.js', cachebust),
-          }),
-        ]),
-    ]);
+  return `<!DOCTYPE html>\n`
 }
 
 export function generateOEmbedJSON(pageInfo, {language, wikiData}) {
diff --git a/src/write/validate-writes.js b/src/write/validate-writes.js
index 5d61d0e7..52c7dfab 100644
--- a/src/write/validate-writes.js
+++ b/src/write/validate-writes.js
@@ -1,3 +1,5 @@
+// TODO: All this is for an outdated spec + should use aggregate errors
+
 import {logError} from '../util/cli.js';
 
 function validateWritePath(path, urlGroup) {