« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-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
104 files changed, 9060 insertions, 0 deletions
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;
+}