« get me outta code hell

Merge branch 'data-steps' of github.com:hsmusic/hsmusic-wiki into data-steps - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-06-12 15:54:24 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-06-12 15:54:24 -0300
commit630af0a345f3be6c3e4aa3300ce138e48ed5ae91 (patch)
tree91cc3c76cebf93bf1042e89c05bc8f8d8442aff9
parent0e150bbdf4c384bd2eee6fe3e06ab7b4eb3563da (diff)
parent05df0a1199dca320e0c8b92d210e6ab6e9676dfb (diff)
Merge branch 'data-steps' of github.com:hsmusic/hsmusic-wiki into data-steps
-rw-r--r--src/content-function.js244
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js303
-rw-r--r--src/content/dependencies/generateAlbumInfoPageContent.js306
-rw-r--r--src/content/dependencies/generatePageLayout.js2
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js49
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js10
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js2
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js660
-rw-r--r--src/content/dependencies/generateTrackInfoPageContent.js654
-rw-r--r--src/content/dependencies/linkThing.js124
-rw-r--r--src/static/client.js1
-rw-r--r--src/util/html.js139
-rw-r--r--src/util/sugar.js36
-rw-r--r--test/unit/util/html.js28
14 files changed, 1337 insertions, 1221 deletions
diff --git a/src/content-function.js b/src/content-function.js
index 73e4629..d4cc3db 100644
--- a/src/content-function.js
+++ b/src/content-function.js
@@ -1,22 +1,62 @@
-import {annotateFunction, empty} from './util/sugar.js';
+import {
+  annotateFunction,
+  empty,
+  setIntersection,
+} from './util/sugar.js';
 
 export default function contentFunction({
   contentDependencies = [],
   extraDependencies = [],
 
+  slots,
   sprawl,
   relations,
   data,
   generate,
 }) {
+  const expectedContentDependencyKeys = new Set(contentDependencies);
+  const expectedExtraDependencyKeys = new Set(extraDependencies);
+
+  // Initial checks. These only need to be run once per description of a
+  // content function, and don't depend on any mutable context (e.g. which
+  // dependencies have been fulfilled so far).
+
+  const overlappingContentExtraDependencyKeys =
+    setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys);
+
+  if (!empty(overlappingContentExtraDependencyKeys)) {
+    throw new Error(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`);
+  }
+
+  if (!generate) {
+    throw new Error(`Expected generate function`);
+  }
+
+  if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) {
+    throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`);
+  }
+
+  if (slots && !expectedExtraDependencyKeys.has('html')) {
+    throw new Error(`Content functions with slots must specify html in extraDependencies`);
+  }
+
+  // Pass all the details to expectDependencies, which will recursively build
+  // up a set of fulfilled dependencies and make functions like `relations`
+  // and `generate` callable only with sufficient fulfilled dependencies.
+
   return expectDependencies({
+    slots,
     sprawl,
     relations,
     data,
     generate,
 
-    expectedContentDependencyKeys: contentDependencies,
-    expectedExtraDependencyKeys: extraDependencies,
+    expectedContentDependencyKeys,
+    expectedExtraDependencyKeys,
+    missingContentDependencyKeys: new Set(expectedContentDependencyKeys),
+    missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys),
+    invalidatingDependencyKeys: new Set(),
+    fulfilledDependencyKeys: new Set(),
     fulfilledDependencies: {},
   });
 }
@@ -24,6 +64,7 @@ export default function contentFunction({
 contentFunction.identifyingSymbol = Symbol(`Is a content function?`);
 
 export function expectDependencies({
+  slots,
   sprawl,
   relations,
   data,
@@ -31,43 +72,39 @@ export function expectDependencies({
 
   expectedContentDependencyKeys,
   expectedExtraDependencyKeys,
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
   fulfilledDependencies,
 }) {
-  if (!generate) {
-    throw new Error(`Expected generate function`);
-  }
-
   const hasSprawlFunction = !!sprawl;
   const hasRelationsFunction = !!relations;
   const hasDataFunction = !!data;
+  const hasSlotsDescription = !!slots;
 
-  if (hasSprawlFunction && !expectedExtraDependencyKeys.includes('wikiData')) {
-    throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`);
-  }
-
-  const fulfilledDependencyKeys = Object.keys(fulfilledDependencies);
-
-  const invalidatingDependencyKeys = Object.entries(fulfilledDependencies)
-    .filter(([key, value]) => value?.fulfilled === false)
-    .map(([key]) => key);
-
-  const missingContentDependencyKeys = expectedContentDependencyKeys
-    .filter(key => !fulfilledDependencyKeys.includes(key));
-
-  const missingExtraDependencyKeys = expectedExtraDependencyKeys
-    .filter(key => !fulfilledDependencyKeys.includes(key));
+  const isInvalidated = !empty(invalidatingDependencyKeys);
+  const isMissingContentDependencies = !empty(missingContentDependencyKeys);
+  const isMissingExtraDependencies = !empty(missingExtraDependencyKeys);
 
   let wrappedGenerate;
 
-  if (!empty(invalidatingDependencyKeys)) {
+  if (isInvalidated) {
     wrappedGenerate = function() {
-      throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${invalidatingDependencyKeys.join(', ')}`);
+      throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`);
     };
 
     annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'});
     wrappedGenerate.fulfilled = false;
-  } else if (empty(missingContentDependencyKeys) && empty(missingExtraDependencyKeys)) {
-    wrappedGenerate = function(arg1, arg2) {
+  } else if (isMissingContentDependencies || isMissingExtraDependencies) {
+    wrappedGenerate = function() {
+      throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
+    wrappedGenerate.fulfilled = false;
+  } else {
+    const callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => {
       if (hasDataFunction && !arg1) {
         throw new Error(`Expected data`);
       }
@@ -81,27 +118,52 @@ export function expectDependencies({
       }
 
       if (hasDataFunction && hasRelationsFunction) {
-        return generate(arg1, arg2, fulfilledDependencies);
+        return generate(arg1, arg2, ...extraArgs, fulfilledDependencies);
       } else if (hasDataFunction || hasRelationsFunction) {
-        return generate(arg1, fulfilledDependencies);
+        return generate(arg1, ...extraArgs, fulfilledDependencies);
       } else {
-        return generate(fulfilledDependencies);
+        return generate(...extraArgs, fulfilledDependencies);
       }
     };
 
-    annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'});
-    wrappedGenerate.fulfilled = true;
+    if (hasSlotsDescription) {
+      const stationery = fulfilledDependencies.html.stationery({
+        annotation: generate.name,
+
+        // These extra slots are for the data and relations (positional) args.
+        // No hacks to store them temporarily or otherwise "invisibly" alter
+        // the behavior of the template description's `content`, since that
+        // would be expressly against the purpose of templates!
+        slots: {
+          _cfArg1: {validate: v => v.isObject},
+          _cfArg2: {validate: v => v.isObject},
+          ...slots,
+        },
+
+        content(slots) {
+          const args = [slots._cfArg1, slots._cfArg2];
+          return callUnderlyingGenerate(args, slots);
+        },
+      });
+
+      wrappedGenerate = function(...args) {
+        return stationery.template().slots({
+          _cfArg1: args[0] ?? null,
+          _cfArg2: args[1] ?? null,
+        });
+      };
+    } else {
+      wrappedGenerate = function(...args) {
+        return callUnderlyingGenerate(args);
+      };
+    }
 
     wrappedGenerate.fulfill = function() {
-      throw new Error(`All dependencies already fulfilled`);
-    };
-  } else {
-    wrappedGenerate = function() {
-      throw new Error(`Dependencies still needed: ${missingContentDependencyKeys.concat(missingExtraDependencyKeys).join(', ')}`);
+      throw new Error(`All dependencies already fulfilled (${generate.name})`);
     };
 
-    annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
-    wrappedGenerate.fulfilled = false;
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'});
+    wrappedGenerate.fulfilled = true;
   }
 
   wrappedGenerate[contentFunction.identifyingSymbol] = true;
@@ -119,7 +181,31 @@ export function expectDependencies({
   }
 
   wrappedGenerate.fulfill ??= function fulfill(dependencies) {
+    // To avoid unneeded destructuring, `fullfillDependencies` is a mutating
+    // function. But `fulfill` itself isn't meant to mutate! We create a copy
+    // of these variables, so their original values are kept for additional
+    // calls to this same `fulfill`.
+    const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys);
+    const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys);
+    const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys);
+    const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys);
+    const newlyFulfilledDependencies = {...fulfilledDependencies};
+
+    try {
+      fulfillDependencies(dependencies, {
+        missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+        missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+        invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+        fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+        fulfilledDependencies: newlyFulfilledDependencies,
+      });
+    } catch (error) {
+      error.message += ` (${generate.name})`;
+      throw error;
+    }
+
     return expectDependencies({
+      slots,
       sprawl,
       relations,
       data,
@@ -127,16 +213,13 @@ export function expectDependencies({
 
       expectedContentDependencyKeys,
       expectedExtraDependencyKeys,
-
-      fulfilledDependencies: fulfillDependencies({
-        name: generate.name,
-        dependencies,
-
-        expectedContentDependencyKeys,
-        expectedExtraDependencyKeys,
-        fulfilledDependencies,
-      }),
+      missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+      missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+      invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+      fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+      fulfilledDependencies: newlyFulfilledDependencies,
     });
+
   };
 
   Object.assign(wrappedGenerate, {
@@ -147,63 +230,72 @@ export function expectDependencies({
   return wrappedGenerate;
 }
 
-export function fulfillDependencies({
-  name,
-  dependencies,
-  expectedContentDependencyKeys,
-  expectedExtraDependencyKeys,
+export function fulfillDependencies(dependencies, {
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
   fulfilledDependencies,
 }) {
-  const newFulfilledDependencies = {...fulfilledDependencies};
-  const fulfilledDependencyKeys = Object.keys(fulfilledDependencies);
+  // This is a mutating function. Be aware: it WILL mutate the provided sets
+  // and objects EVEN IF there are errors. This function doesn't exit early,
+  // so all provided dependencies which don't have an associated error should
+  // be treated as fulfilled (this is reflected via fulfilledDependencyKeys).
 
   const errors = [];
-  let bail = false;
 
   for (let [key, value] of Object.entries(dependencies)) {
-    if (fulfilledDependencyKeys.includes(key)) {
+    if (fulfilledDependencyKeys.has(key)) {
       errors.push(new Error(`Dependency ${key} is already fulfilled`));
-      bail = true;
       continue;
     }
 
-    const isContentKey = expectedContentDependencyKeys.includes(key);
-    const isExtraKey = expectedExtraDependencyKeys.includes(key);
+    const isContentKey = missingContentDependencyKeys.has(key);
+    const isExtraKey = missingExtraDependencyKeys.has(key);
 
     if (!isContentKey && !isExtraKey) {
       errors.push(new Error(`Dependency ${key} is not expected`));
-      bail = true;
       continue;
     }
 
     if (value === undefined) {
       errors.push(new Error(`Dependency ${key} was provided undefined`));
-      bail = true;
       continue;
     }
 
-    if (isContentKey && !value?.[contentFunction.identifyingSymbol]) {
-      errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`));
-      bail = true;
-      continue;
-    }
+    const isContentFunction =
+      !!value?.[contentFunction.identifyingSymbol];
 
-    if (isExtraKey && value?.[contentFunction.identifyingSymbol]) {
-      errors.push(new Error(`Extra dependency ${key} is a content function`));
-      bail = true;
-      continue;
-    }
+    const isFulfilledContentFunction =
+      isContentFunction && value.fulfilled;
 
-    if (!bail) {
-      newFulfilledDependencies[key] = value;
+    if (isContentKey) {
+      if (!isContentFunction) {
+        errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`));
+        continue;
+      }
+
+      if (!isFulfilledContentFunction) {
+        invalidatingDependencyKeys.add(key);
+      }
+
+      missingContentDependencyKeys.delete(key);
+    } else if (isExtraKey) {
+      if (isContentFunction) {
+        errors.push(new Error(`Extra dependency ${key} is a content function`));
+        continue;
+      }
+
+      missingExtraDependencyKeys.delete(key);
     }
+
+    fulfilledDependencyKeys.add(key);
+    fulfilledDependencies[key] = value;
   }
 
   if (!empty(errors)) {
-    throw new AggregateError(errors, `Errors fulfilling dependencies for ${name}`);
+    throw new AggregateError(errors, `Errors fulfilling dependencies`);
   }
-
-  return newFulfilledDependencies;
 }
 
 export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) {
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 749dd2a..e317adb 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -1,28 +1,51 @@
 import getChronologyRelations from '../util/getChronologyRelations.js';
 import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+import {accumulateSum, empty} from '../../util/sugar.js';
 
 export default {
   contentDependencies: [
-    'generateAlbumInfoPageContent',
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
     'generateAlbumSocialEmbed',
     'generateAlbumStyleRules',
+    'generateAlbumTrackList',
     'generateChronologyLinks',
     'generateColorStyleRules',
+    'generateContentHeading',
     'generatePageLayout',
+    'generateReleaseInfoContributionsLine',
     'linkAlbum',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
     'linkArtist',
+    'linkExternal',
     'linkTrack',
+    'transformContent',
   ],
 
-  extraDependencies: ['language'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, album) {
-    return {
-      layout: relation('generatePageLayout'),
+    const relations = {};
+    const sections = relations.sections = {};
 
-      coverArtistChronologyContributions: getChronologyRelations(album, {
+    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),
@@ -37,26 +60,124 @@ export default {
             ...artist.albumsAsCoverArtist,
             ...artist.tracksAsCoverArtist,
           ]),
-      }),
+      });
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    relations.chronologyLinks =
+      relation('generateChronologyLinks');
+
+    relations.sidebar =
+      relation('generateAlbumSidebar', album, null);
+
+    if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateAlbumCoverArtwork', album);
+    }
+
+    // Section: Release info
+
+    const releaseInfo = sections.releaseInfo = {};
+
+    releaseInfo.artistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.artistContribs);
+
+    releaseInfo.coverArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
+
+    releaseInfo.wallpaperArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
+
+    releaseInfo.bannerArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
+
+    // Section: Listen on
+
+    if (!empty(album.urls)) {
+      const listen = sections.listen = {};
+
+      listen.externalLinks =
+        album.urls.map(url =>
+          relation('linkExternal', url, {type: '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');
 
-      albumNavAccent: relation('generateAlbumNavAccent', album, null),
-      chronologyLinks: relation('generateChronologyLinks'),
+      artistCommentary.content =
+        relation('transformContent', album.commentary);
+    }
 
-      content: relation('generateAlbumInfoPageContent', album),
-      sidebar: relation('generateAlbumSidebar', album, null),
-      socialEmbed: relation('generateAlbumSocialEmbed', album),
-      albumStyleRules: relation('generateAlbumStyleRules', album),
-      colorStyleRules: relation('generateColorStyleRules', album.color),
-    };
+    return relations;
   },
 
   data(album) {
-    return {
-      name: album.name,
-    };
+    const data = {};
+
+    data.name = album.name;
+    data.date = album.date;
+
+    data.duration = accumulateSum(album.tracks, track => track.duration);
+    data.durationApproximate = album.tracks.length > 1;
+
+    if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+      data.coverArtDate = album.coverArtDate;
+    }
+
+    if (!empty(album.additionalFiles)) {
+      data.numAdditionalFiles = album.additionalFiles.length;
+    }
+
+    data.dateAddedToWiki = album.dateAddedToWiki;
+
+    return data;
   },
 
-  generate(data, relations, {language}) {
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
     return relations.layout
       .slots({
         title: language.$('albumPage.title', {album: data.name}),
@@ -65,8 +186,130 @@ export default {
         colorStyleRules: [relations.colorStyleRules],
         additionalStyleRules: [relations.albumStyleRules],
 
-        cover: relations.content.cover,
-        mainContent: relations.content.main.content,
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.albumCover'),
+              })
+            : null),
+
+        mainContent: [
+          html.tag('p',
+            {
+              [html.onlyIfContent]: true,
+              [html.joinChildren]: html.tag('br'),
+            },
+            [
+              sec.releaseInfo.artistContributionsLine
+                .slots({stringKey: 'releaseInfo.by'}),
+
+              sec.releaseInfo.coverArtistContributionsLine
+                .slots({stringKey: 'releaseInfo.coverArtBy'}),
+
+              sec.releaseInfo.wallpaperArtistContributionsLine
+                .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
+
+              sec.releaseInfo.bannerArtistContributionsLine
+                .slots({stringKey: 'releasInfo.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,
+                    }),
+                }),
+            ]),
+
+          sec.listen &&
+            html.tag('p',
+              language.$('releaseInfo.listenOn', {
+                links: language.formatDisjunctionList(sec.listen.externalLinks),
+              })),
+
+          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: [
@@ -97,3 +340,23 @@ export default {
       });
   },
 };
+
+/*
+  banner: !empty(album.bannerArtistContribs) && {
+    dimensions: album.bannerDimensions,
+    path: [
+      'media.albumBanner',
+      album.directory,
+      album.bannerFileExtension,
+    ],
+    alt: language.$('misc.alt.albumBanner'),
+    position: 'top',
+  },
+
+  secondaryNav: generateAlbumSecondaryNav(album, null, {
+    getLinkThemeString,
+    html,
+    language,
+    link,
+  }),
+*/
diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js
deleted file mode 100644
index 230d735..0000000
--- a/src/content/dependencies/generateAlbumInfoPageContent.js
+++ /dev/null
@@ -1,306 +0,0 @@
-import {accumulateSum, empty} from '../../util/sugar.js';
-
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesShortcut',
-    'generateAlbumAdditionalFilesList',
-    'generateAlbumCoverArtwork',
-    'generateAlbumTrackList',
-    'generateContentHeading',
-    'linkAlbumCommentary',
-    'linkAlbumGallery',
-    'linkContribution',
-    'linkExternal',
-    'transformContent',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, album) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    const contributionLinksRelation = contribs =>
-      contribs.map(contrib =>
-        relation('linkContribution', contrib.who, contrib.what));
-
-    // Section: Release info
-
-    const releaseInfo = sections.releaseInfo = {};
-
-    if (!empty(album.artistContribs)) {
-      releaseInfo.artistContributionLinks =
-        contributionLinksRelation(album.artistContribs);
-    }
-
-    if (album.hasCoverArt) {
-      relations.cover =
-        relation('generateAlbumCoverArtwork', album);
-      releaseInfo.coverArtistContributionLinks =
-        contributionLinksRelation(album.coverArtistContribs);
-    } else {
-      relations.cover = null;
-    }
-
-    if (album.hasWallpaperArt) {
-      releaseInfo.wallpaperArtistContributionLinks =
-        contributionLinksRelation(album.wallpaperArtistContribs);
-    }
-
-    if (album.hasBannerArt) {
-      releaseInfo.bannerArtistContributionLinks =
-        contributionLinksRelation(album.bannerArtistContribs);
-    }
-
-    // Section: Listen on
-
-    if (!empty(album.urls)) {
-      const listen = sections.listen = {};
-
-      listen.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url, {type: '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.date = album.date;
-    data.duration = accumulateSum(album.tracks, track => track.duration);
-    data.durationApproximate = album.tracks.length > 1;
-
-    data.hasCoverArt = album.hasCoverArt;
-
-    if (album.hasCoverArt) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
-
-      if (album.coverArtDate && +album.coverArtDate !== +album.date) {
-        data.coverArtDate = album.coverArtDate;
-      }
-    }
-
-    if (!empty(album.additionalFiles)) {
-      data.numAdditionalFiles = album.additionalFiles.length;
-    }
-
-    data.dateAddedToWiki = album.dateAddedToWiki;
-
-    return data;
-  },
-
-  generate(data, relations, {
-    html,
-    language,
-  }) {
-    const content = {};
-
-    const {sections: sec} = relations;
-
-    const formatContributions =
-      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
-        contributionLinks &&
-          language.$(stringKey, {
-            artists:
-              language.formatConjunctionList(
-                contributionLinks.map(link =>
-                  link.slots({showContribution, showIcons}))),
-          });
-
-    if (data.hasCoverArt) {
-      content.cover = relations.cover
-        .slots({
-          alt: language.$('misc.alt.albumCover'),
-        });
-    } else {
-      content.cover = null;
-    }
-
-    content.main = {
-      headingMode: 'sticky',
-      content: html.tags([
-        html.tag('p',
-          {
-            [html.onlyIfContent]: true,
-            [html.joinChildren]: html.tag('br'),
-          },
-          [
-            formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
-            formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
-            formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks),
-            formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks),
-
-            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,
-                  }),
-              }),
-          ]),
-
-        sec.listen &&
-          html.tag('p',
-            language.$('releaseInfo.listenOn', {
-              links: language.formatDisjunctionList(sec.listen.externalLinks),
-            })),
-
-        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')),
-        ],
-      ]),
-    };
-
-    return content;
-  },
-};
-
-/*
-  banner: !empty(album.bannerArtistContribs) && {
-    dimensions: album.bannerDimensions,
-    path: [
-      'media.albumBanner',
-      album.directory,
-      album.bannerFileExtension,
-    ],
-    alt: language.$('misc.alt.albumBanner'),
-    position: 'top',
-  },
-
-  secondaryNav: generateAlbumSecondaryNav(album, null, {
-    getLinkThemeString,
-    html,
-    language,
-    link,
-  }),
-*/
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 796dc1e..84acca0 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -104,7 +104,6 @@ export default {
         showWikiNameInTitle: {type: 'boolean', default: true},
 
         cover: {type: 'html'},
-        coverNeedsReveal: {type: 'boolean'},
 
         socialEmbed: {type: 'html'},
 
@@ -204,7 +203,6 @@ export default {
                 relations.stickyHeadingContainer.slots({
                   title: slots.title,
                   cover: slots.cover,
-                  needsReveal: slots.coverNeedsReveal,
                 });
               break;
             case 'static':
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
new file mode 100644
index 0000000..2b342d0
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -0,0 +1,49 @@
+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(({who, what}) =>
+            relation('linkContribution', who, what)),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    return html.template({
+      annotation: `generateReleaseInfoContributionsLine`,
+
+      slots: {
+        stringKey: {type: 'string'},
+
+        showContribution: {type: 'boolean', default: true},
+        showIcons: {type: 'boolean', default: true},
+      },
+
+      content(slots) {
+        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/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index fb6d830..6602a2a 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -8,7 +8,6 @@ export default {
       slots: {
         title: {type: 'html'},
         cover: {type: 'html'},
-        needsReveal: {type: 'boolean', default: false},
       },
 
       content(slots) {
@@ -27,13 +26,8 @@ export default {
 
               hasCover &&
                 html.tag('div', {class: 'content-sticky-heading-cover-container'},
-                  html.tag('div',
-                    {class: [
-                      'content-sticky-heading-cover',
-                      slots.needsReveal &&
-                        'content-sticky-heading-cover-needs-reveal',
-                    ]},
-                    slots.cover.slot('displayMode', 'thumbnail')))
+                  html.tag('div', {class: 'content-sticky-heading-cover'},
+                    slots.cover.slot('displayMode', 'thumbnail'))),
             ]),
 
             html.tag('div', {class: 'content-sticky-subheading-row'},
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
index f6084f3..757ad2d 100644
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -7,7 +7,7 @@ export default {
         relation('generateCoverArtwork',
           (track.hasUniqueCoverArt
             ? track.artTags
-            : album.artTags)),
+            : track.album.artTags)),
     };
   },
 
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index ee68f53..ed28ede 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,27 +1,61 @@
 import getChronologyRelations from '../util/getChronologyRelations.js';
-import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
+} from '../../util/wiki-data.js';
+
+import {empty} from '../../util/sugar.js';
 
 export default {
   contentDependencies: [
-    'generateTrackInfoPageContent',
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
     'generateColorStyleRules',
+    'generateContentHeading',
     'generatePageLayout',
+    'generateReleaseInfoContributionsLine',
+    'generateTrackCoverArtwork',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
     'linkAlbum',
     'linkArtist',
+    'linkContribution',
+    'linkExternal',
+    'linkFlash',
     'linkTrack',
+    'transformContent',
   ],
 
-  extraDependencies: ['language'],
+  extraDependencies: ['html', 'language', 'wikiData'],
 
-  relations(relation, track) {
+  sprawl({wikiInfo}) {
     return {
-      layout: relation('generatePageLayout'),
+      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);
 
-      artistChronologyContributions: getChronologyRelations(track, {
+    relations.artistChronologyContributions =
+      getChronologyRelations(track, {
         contributions: [...track.artistContribs, ...track.contributorContribs],
 
         linkArtist: artist => relation('linkArtist', artist),
@@ -32,9 +66,10 @@ export default {
             ...artist.tracksAsArtist,
             ...artist.tracksAsContributor,
           ]),
-      }),
+      });
 
-      coverArtistChronologyContributions: getChronologyRelations(track, {
+    relations.coverArtistChronologyContributions =
+      getChronologyRelations(track, {
         contributions: track.coverArtistContribs,
 
         linkArtist: artist => relation('linkArtist', artist),
@@ -53,28 +88,255 @@ export default {
           }),
       }),
 
-      albumLink: relation('linkAlbum', track.album),
-      trackLink: relation('linkTrack', track),
-      albumNavAccent: relation('generateAlbumNavAccent', track.album, track),
-      chronologyLinks: relation('generateChronologyLinks'),
+    relations.albumLink =
+      relation('linkAlbum', track.album);
 
-      content: relation('generateTrackInfoPageContent', track),
-      sidebar: relation('generateAlbumSidebar', track.album, track),
-      albumStyleRules: relation('generateAlbumStyleRules', track.album),
-      colorStyleRules: relation('generateColorStyleRules', track.color),
-    };
+    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
+
+    const releaseInfo = sections.releaseInfo = {};
+
+    releaseInfo.artistContributionLinks =
+      relation('generateReleaseInfoContributionsLine', track.artistContribs);
+
+    if (track.hasUniqueCoverArt) {
+      releaseInfo.coverArtistContributionsLine =
+        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
+    }
+
+    // Section: Listen on
+
+    const listen = sections.listen = {};
+
+    if (!empty(track.urls)) {
+      listen.externalLinks =
+        track.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    // 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(({who, what}) =>
+          relation('linkContribution', who, what));
+    }
+
+    // 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(track) {
-    return {
-      name: track.name,
+  data(sprawl, track) {
+    const data = {};
+    const {album} = track;
 
-      hasTrackNumbers: track.album.hasTrackNumbers,
-      trackNumber: track.album.tracks.indexOf(track) + 1,
-    };
+    data.name = track.name;
+    data.date = track.date;
+    data.duration = track.duration;
+
+    data.hasUniqueCoverArt = track.hasUniqueCoverArt;
+    data.hasAlbumCoverArt = album.hasCoverArt;
+
+    if (track.hasUniqueCoverArt) {
+      data.albumCoverArtDirectory = album.directory;
+      data.trackCoverArtDirectory = track.directory;
+      data.coverArtFileExtension = track.coverArtFileExtension;
+
+      if (track.coverArtDate && +track.coverArtDate !== +track.date) {
+        data.coverArtDate = track.coverArtDate;
+      }
+    } else if (track.album.hasCoverArt) {
+      data.albumCoverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    }
+
+    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.trackNumber = album.tracks.indexOf(track) + 1;
+
+    if (!empty(track.additionalFiles)) {
+      data.numAdditionalFiles = track.additionalFiles.length;
+    }
+
+    return data;
   },
 
-  generate(data, relations, {language}) {
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
     return relations.layout
       .slots({
         title: language.$('trackPage.title', {track: data.name}),
@@ -83,9 +345,246 @@ export default {
         colorStyleRules: [relations.colorStyleRules],
         additionalStyleRules: [relations.albumStyleRules],
 
-        cover: relations.content.cover,
-        coverNeedsReveal: relations.content.coverNeedsReveal,
-        mainContent: relations.content.main.content,
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.trackCover'),
+              })
+            : null),
+
+        mainContent: [
+          html.tag('p', {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: html.tag('br'),
+          }, [
+            sec.releaseInfo.artistContributionLinks
+              .slots({stringKey: 'releaseInfo.by'}),
+
+            sec.releaseInfo.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',
+            (sec.listen.externalLinks
+              ? language.$('releaseInfo.listenOn', {
+                  links: language.formatDisjunctionList(sec.listen.externalLinks),
+                })
+              : language.$('releaseInfo.listenOn.noLinks', {
+                  name: html.tag('i', data.name),
+                }))),
+
+          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: [
@@ -129,4 +628,109 @@ export default {
         ...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/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js
deleted file mode 100644
index 43f8e68..0000000
--- a/src/content/dependencies/generateTrackInfoPageContent.js
+++ /dev/null
@@ -1,654 +0,0 @@
-import {empty} from '../../util/sugar.js';
-import {sortFlashesChronologically} from '../../util/wiki-data.js';
-
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesShortcut',
-    'generateAlbumAdditionalFilesList',
-    'generateContentHeading',
-    'generateTrackCoverArtwork',
-    'generateTrackList',
-    'generateTrackListDividedByGroups',
-    'linkAlbum',
-    'linkContribution',
-    'linkExternal',
-    'linkFlash',
-    'linkTrack',
-    'transformContent',
-  ],
-
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  relations(relation, sprawl, track) {
-    const {album} = track;
-
-    const relations = {};
-    const sections = relations.sections = {};
-
-    const contributionLinksRelation = contribs =>
-      contribs
-        .slice(0, 4)
-        .map(contrib =>
-          relation('linkContribution', contrib.who, contrib.what));
-
-    const additionalFilesSection = additionalFiles => ({
-      heading: relation('generateContentHeading'),
-      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
-    });
-
-    if (track.hasUniqueCoverArt || album.hasCoverArt) {
-      relations.cover =
-        relation('generateTrackCoverArtwork', track);
-    }
-
-    // Section: Release info
-
-    const releaseInfo = sections.releaseInfo = {};
-
-    releaseInfo.artistContributionLinks =
-      contributionLinksRelation(track.artistContribs);
-
-    if (track.hasUniqueCoverArt) {
-      releaseInfo.coverArtistContributionLinks =
-        contributionLinksRelation(track.coverArtistContribs);
-    }
-
-    // Section: Listen on
-
-    const listen = sections.listen = {};
-
-    if (!empty(track.urls)) {
-      listen.externalLinks =
-        track.urls.map(url =>
-          relation('linkExternal', url));
-    }
-
-    // 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 =
-        contributionLinksRelation(track.contributorContribs);
-    }
-
-    // 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) {
-    const data = {};
-
-    const {album} = track;
-
-    data.name = track.name;
-    data.date = track.date;
-    data.duration = track.duration;
-
-    data.hasUniqueCoverArt = track.hasUniqueCoverArt;
-    data.hasAlbumCoverArt = album.hasCoverArt;
-
-    if (track.hasUniqueCoverArt) {
-      data.albumCoverArtDirectory = album.directory;
-      data.trackCoverArtDirectory = track.directory;
-      data.coverArtFileExtension = track.coverArtFileExtension;
-      data.coverNeedsReveal = track.artTags.some(t => t.isContentWarning);
-
-      if (track.coverArtDate && +track.coverArtDate !== +track.date) {
-        data.coverArtDate = track.coverArtDate;
-      }
-    } else if (track.album.hasCoverArt) {
-      data.albumCoverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
-      data.coverNeedsReveal = album.artTags.some(t => t.isContentWarning);
-    }
-
-    if (!empty(track.additionalFiles)) {
-      data.numAdditionalFiles = track.additionalFiles.length;
-    }
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const content = {};
-
-    const {sections: sec} = relations;
-
-    const formatContributions =
-      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
-        contributionLinks &&
-          language.$(stringKey, {
-            artists:
-              language.formatConjunctionList(
-                contributionLinks.map(link =>
-                  link.slots({showContribution, showIcons}))),
-          });
-
-    if (data.hasUniqueCoverArt || data.hasAlbumCoverArt) {
-      content.cover = relations.cover
-        .slots({
-          alt: language.$('misc.alt.trackCover'),
-        });
-      content.coverNeedsReveal = data.coverNeedsReveal;
-    } else {
-      content.cover = null;
-      content.coverNeedsReveal = null;
-    }
-
-    content.main = {
-      headingMode: 'sticky',
-
-      content: html.tags([
-        html.tag('p', {
-          [html.onlyIfContent]: true,
-          [html.joinChildren]: html.tag('br'),
-        }, [
-          formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
-          formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
-
-          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',
-          (sec.listen.externalLinks
-            ? language.$('releaseInfo.listenOn', {
-                links: language.formatDisjunctionList(sec.listen.externalLinks),
-              })
-            : language.$('releaseInfo.listenOn.noLinks', {
-                name: html.tag('i', data.name),
-              }))),
-
-        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')),
-        ],
-      ]),
-    };
-
-    return content;
-  },
-};
-
-/*
-  const generateCommentary = ({language, link, transformMultiline}) =>
-    transformMultiline([
-      track.commentary,
-      ...otherReleases.map((track) =>
-        track.commentary
-          ?.split('\n')
-          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
-          .flatMap(line => [
-            line,
-            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
-              original: link.track(track),
-            }),
-          ])
-          .join('\n')
-      ),
-    ].filter(Boolean).join('\n'));
-
-  const data = {
-    type: 'data',
-    path: ['track', track.directory],
-    data: ({
-      serializeContribs,
-      serializeCover,
-      serializeGroupsForTrack,
-      serializeLink,
-    }) => ({
-      name: track.name,
-      directory: track.directory,
-      dates: {
-        released: track.date,
-        originallyReleased: track.originalDate,
-        coverArtAdded: track.coverArtDate,
-      },
-      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/linkThing.js b/src/content/dependencies/linkThing.js
index 4ccdf58..03aa983 100644
--- a/src/content/dependencies/linkThing.js
+++ b/src/content/dependencies/linkThing.js
@@ -25,70 +25,68 @@ export default {
     };
   },
 
-  generate(data, relations, {html}) {
+  slots: {
+    // content: relations.linkTemplate.getSlotDescription('content'),
+    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: relations.linkTemplate.getSlotDescription('attributes'),
+    // hash: relations.linkTemplate.getSlotDescription('hash'),
+    attributes: {validate: v => v.isAttributes},
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html}) {
     const path = [data.pathKey, data.directory];
 
-    return html.template({
-      annotation: 'linkThing',
-
-      slots: {
-        content: relations.linkTemplate.getSlotDescription('content'),
-        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: relations.linkTemplate.getSlotDescription('attributes'),
-        hash: relations.linkTemplate.getSlotDescription('hash'),
-      },
-
-      content(slots) {
-        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,
-          });
-      },
-    });
+    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/static/client.js b/src/static/client.js
index 6212295..e75fbd9 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -561,6 +561,7 @@ function prepareStickyHeadings() {
   } of stickyHeadingInfo) {
     const coverRevealImage = contentCover?.querySelector('.reveal');
     if (coverRevealImage) {
+      stickyCover.classList.add('content-sticky-heading-cover-needs-reveal');
       coverRevealImage.addEventListener('hsmusic-reveal', () => {
         stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
       });
diff --git a/src/util/html.js b/src/util/html.js
index b5930d0..b75820e 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -489,7 +489,10 @@ export class Template {
   #slotValues = {};
 
   constructor(description) {
-    Template.validateDescription(description);
+    if (!description[Stationery.validated]) {
+      Template.validateDescription(description);
+    }
+
     this.#description = description;
   }
 
@@ -528,69 +531,79 @@ export class Template {
         break validateSlots;
       }
 
-      const slotErrors = [];
+      try {
+        this.validateSlotsDescription(description.slots);
+      } catch (slotError) {
+        topErrors.push(slotError);
+      }
+    }
 
-      for (const [slotName, slotDescription] of Object.entries(description.slots)) {
-        if (typeof slotDescription !== 'object' || slotDescription === null) {
-          slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
-          continue;
-        }
+    if (!empty(topErrors)) {
+      throw new AggregateError(topErrors,
+        (typeof description.annotation === 'string'
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
 
-        if ('default' in slotDescription) validateDefault: {
-          if (
-            slotDescription.default === undefined ||
-            slotDescription.default === null
-          ) {
-            slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
-            break validateDefault;
-          }
+    return true;
+  }
 
-          try {
-            Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
-          } catch (error) {
-            error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
-            slotErrors.push(error);
-          }
+  static validateSlotsDescription(slots) {
+    const slotErrors = [];
+
+    for (const [slotName, slotDescription] of Object.entries(slots)) {
+      if (typeof slotDescription !== 'object' || slotDescription === null) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
+        continue;
+      }
+
+      if ('default' in slotDescription) validateDefault: {
+        if (
+          slotDescription.default === undefined ||
+          slotDescription.default === null
+        ) {
+          slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
+          break validateDefault;
         }
 
-        if ('validate' in slotDescription && 'type' in slotDescription) {
-          slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
-        } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
-          slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
-        } else if ('validate' in slotDescription) {
-          if (typeof slotDescription.validate !== 'function') {
-            slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
-          }
-        } else if ('type' in slotDescription) {
-          const acceptableSlotTypes = [
-            'string',
-            'number',
-            'bigint',
-            'boolean',
-            'symbol',
-            'html',
-          ];
-
-          if (slotDescription.type === 'function') {
-            slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
-          } else if (slotDescription.type === 'object') {
-            slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
-          } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
-            slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
-          }
+        try {
+          Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
+        } catch (error) {
+          error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
+          slotErrors.push(error);
         }
       }
 
-      if (!empty(slotErrors)) {
-        topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`));
+      if ('validate' in slotDescription && 'type' in slotDescription) {
+        slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
+      } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
+      } else if ('validate' in slotDescription) {
+        if (typeof slotDescription.validate !== 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
+        }
+      } else if ('type' in slotDescription) {
+        const acceptableSlotTypes = [
+          'string',
+          'number',
+          'bigint',
+          'boolean',
+          'symbol',
+          'html',
+        ];
+
+        if (slotDescription.type === 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
+        } else if (slotDescription.type === 'object') {
+          slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
+        } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
+        }
       }
     }
 
-    if (!empty(topErrors)) {
-      throw new AggregateError(topErrors,
-        (typeof description.annotation === 'string'
-          ? `Errors validating template "${description.annotation}" description`
-          : `Errors validating template description`));
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors, `Errors in slot descriptions`);
     }
 
     return true;
@@ -769,3 +782,23 @@ export class Template {
     return this.content.toString();
   }
 }
+
+export function stationery(description) {
+  return new Stationery(description);
+}
+
+export class Stationery {
+  #templateDescription = null;
+
+  static validated = Symbol('Stationery.validated');
+
+  constructor(templateDescription) {
+    Template.validateDescription(templateDescription);
+    templateDescription[Stationery.validated] = true;
+    this.#templateDescription = templateDescription;
+  }
+
+  template() {
+    return new Template(this.#templateDescription);
+  }
+}
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 6ab70bc..3a7e6f8 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -26,18 +26,24 @@ export function* splitArray(array, fn) {
   }
 }
 
-// Null-accepting function to check if an array is empty. Accepts null (and
-// treats as empty) as a shorthand for "hey, check if this property is an array
-// with/without stuff in it" for objects where properties that are PRESENT but
-// don't currently have a VALUE are null (instead of undefined).
-export function empty(arrayOrNull) {
-  if (arrayOrNull === null) {
+// Null-accepting function to check if an array or set is empty. Accepts null
+// (which is treated as empty) as a shorthand for "hey, check if this property
+// is an array with/without stuff in it" for objects where properties that are
+// PRESENT but don't currently have a VALUE are null (rather than undefined).
+export function empty(value) {
+  if (value === null) {
     return true;
-  } else if (Array.isArray(arrayOrNull)) {
-    return arrayOrNull.length === 0;
-  } else {
-    throw new Error(`Expected array or null`);
   }
+
+  if (Array.isArray(value)) {
+    return value.length === 0;
+  }
+
+  if (value instanceof Set) {
+    return value.size === 0;
+  }
+
+  throw new Error(`Expected array, set, or null`);
 }
 
 // Repeats all the items of an array a number of times.
@@ -82,6 +88,16 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
 export const withEntries = (obj, fn) =>
   Object.fromEntries(fn(Object.entries(obj)));
 
+export function setIntersection(set1, set2) {
+  const intersection = new Set();
+  for (const item of set1) {
+    if (set2.has(item)) {
+      intersection.add(item);
+    }
+  }
+  return intersection;
+}
+
 export function filterProperties(obj, properties) {
   const set = new Set(properties);
   return Object.fromEntries(
diff --git a/test/unit/util/html.js b/test/unit/util/html.js
index 82f96b4..01a510e 100644
--- a/test/unit/util/html.js
+++ b/test/unit/util/html.js
@@ -904,3 +904,31 @@ t.test(`Template - slot value errors`, t => {
       `arrayOfHTML length: 0`,
     ]).toString());
 });
+
+t.test(`Stationery`, t => {
+  t.plan(3);
+
+  // 1-3: basic behavior
+
+  const stationery1 = new html.Stationery({
+    slots: {
+      slot1: {type: 'string', default: 'apricot'},
+      slot2: {type: 'string', default: 'disaster'},
+    },
+
+    content: ({slot1, slot2}) => html.tag('span', `${slot1} ${slot2}`),
+  });
+
+  const template1 = stationery1.template();
+  const template2 = stationery1.template();
+
+  template2.setSlots({slot1: 'aquaduct', slot2: 'dichotomy'});
+
+  const template3 = stationery1.template();
+
+  template3.setSlots({slot2: 'vinaigrette'});
+
+  t.equal(template1.toString(), `<span>apricot disaster</span>`);
+  t.equal(template2.toString(), `<span>aquaduct dichotomy</span>`);
+  t.equal(template3.toString(), `<span>apricot vinaigrette</span>`);
+});