« get me outta code hell

content: stub track page, misc. other changes - 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-04-12 13:20:32 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-04-12 13:20:32 -0300
commit3a5b49cf3a10702c0dae1190c9baabd8a2c2ef3b (patch)
treec7a1ac0d4c2096733fd3fdfdc549f5695a78532e
parentb8394a89d31da72ef7d2086a1088a29e68df4edc (diff)
content: stub track page, misc. other changes
* generateContributionLinks replaced with linkContribution,
  tests still need updating

* album pages respect albums without cover art

* track pages without unique art inherit art tags from album
  (fixes #13)

not heavily tested, this commit probably breaks some pages
which were loading correctly before
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js4
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js37
-rw-r--r--src/content/dependencies/generateAlbumInfoPageContent.js88
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js13
-rw-r--r--src/content/dependencies/generateContentHeading.js2
-rw-r--r--src/content/dependencies/generateContributionLinks.js87
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js40
-rw-r--r--src/content/dependencies/generateTrackInfoPageContent.js645
-rw-r--r--src/content/dependencies/linkAlbum.js8
-rw-r--r--src/content/dependencies/linkContribution.js74
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/thing.js1
-rw-r--r--src/page/index.js2
-rw-r--r--src/page/track.js550
-rw-r--r--src/write/build-modes/live-dev-server.js5
-rw-r--r--test/snapshot/generateContributionLinks.js9
-rw-r--r--test/unit/content/dependencies/generateContributionLinks.js4
17 files changed, 865 insertions, 709 deletions
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
index 04e6a5f1..f8fd5499 100644
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -41,8 +41,8 @@ export default {
   }) {
     return relations.additionalFilesList
       .slots({
-        additionalFileLinks: relations.additionalFileLinks,
-        additionalFileSizes:
+        fileLinks: relations.additionalFileLinks,
+        fileSizes:
           Object.fromEntries(data.fileLocations.map(file => [
             file,
             (data.showFileSizes
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5c575cb2..e5ce193c 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -7,37 +7,26 @@ export default {
     'generatePageLayout',
   ],
 
-  extraDependencies: [
-    'language',
-  ],
+  extraDependencies: ['language'],
 
   relations(relation, album) {
-    const relations = {};
-
-    relations.layout = relation('generatePageLayout');
-
-    relations.content = relation('generateAlbumInfoPageContent', album);
-    relations.socialEmbed = relation('generateAlbumSocialEmbed', album);
-    relations.albumStyleRules = relation('generateAlbumStyleRules', album);
-    relations.colorStyleRules = relation('generateColorStyleRules', album.color);
-
-    return relations;
+    return {
+      layout: relation('generatePageLayout'),
+
+      content: relation('generateAlbumInfoPageContent', album),
+      socialEmbed: relation('generateAlbumSocialEmbed', album),
+      albumStyleRules: relation('generateAlbumStyleRules', album),
+      colorStyleRules: relation('generateColorStyleRules', album.color),
+    };
   },
 
   data(album) {
-    const data = {};
-
-    data.name = album.name;
-    data.color = album.color;
-
-    return data;
+    return {
+      name: album.name,
+    };
   },
 
-  generate(data, relations, {
-    language,
-  }) {
-    // page.themeColor = data.color;
-
+  generate(data, relations, {language}) {
     return relations.layout
       .slots({
         title: language.$('albumPage.title', {album: data.name}),
diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js
index fd66f6b0..76862f9c 100644
--- a/src/content/dependencies/generateAlbumInfoPageContent.js
+++ b/src/content/dependencies/generateAlbumInfoPageContent.js
@@ -5,11 +5,11 @@ export default {
     'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumTrackList',
-    'generateContributionLinks',
     'generateContentHeading',
     'generateCoverArtwork',
     'linkAlbumCommentary',
     'linkAlbumGallery',
+    'linkContribution',
     'linkExternal',
   ],
 
@@ -22,14 +22,14 @@ export default {
   relations(relation, album) {
     const relations = {};
 
-    relations.cover =
-      relation('generateCoverArtwork', album.artTags);
-
     const contributionLinksRelation = contribs =>
-      relation('generateContributionLinks', contribs, {
-        showContribution: true,
-        showIcons: true,
-      });
+      contribs.map(contrib =>
+        relation('linkContribution', contrib.who, contrib.what));
+
+    if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', album.artTags);
+    }
 
     relations.artistLinks =
       contributionLinksRelation(album.artistContribs);
@@ -43,9 +43,6 @@ export default {
     relations.bannerArtistLinks =
       contributionLinksRelation(album.bannerArtistContribs);
 
-    const contentHeadingRelation = () =>
-      relation('generateContentHeading');
-
     if (album.tracks.some(t => t.hasUniqueCoverArt)) {
       relations.galleryLink =
         relation('linkAlbumGallery', album);
@@ -57,10 +54,8 @@ export default {
     }
 
     relations.externalLinks =
-      (empty(album.urls)
-        ? null
-        : album.urls.map(url =>
-            relation('linkExternal', url, {type: 'album'})));
+      album.urls.map(url =>
+        relation('linkExternal', url, {type: 'album'}));
 
     relations.trackList = relation('generateAlbumTrackList', album);
 
@@ -69,14 +64,14 @@ export default {
         relation('generateAdditionalFilesShortcut', album.additionalFiles);
 
       relations.additionalFilesHeading =
-        contentHeadingRelation();
+        relation('generateContentHeading');
 
       relations.additionalFilesList =
         relation('generateAlbumAdditionalFilesList', album);
     }
 
     relations.artistCommentaryHeading =
-      contentHeadingRelation();
+      relation('generateContentHeading');
 
     return relations;
   },
@@ -84,19 +79,19 @@ export default {
   data(album) {
     const data = {};
 
-    data.coverArtDirectory = album.directory;
-    data.coverArtFileExtension = album.coverArtFileExtension;
-
     data.date = album.date;
     data.duration = accumulateSum(album.tracks, track => track.duration);
     data.durationApproximate = album.tracks.length > 1;
 
-    if (
-      album.hasCoverArt &&
-      album.coverArtDate &&
-      +album.coverArtDate !== +album.date
-    ) {
-      data.coverArtDate = album.coverArtDate;
+    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)) {
@@ -116,39 +111,50 @@ export default {
   }) {
     const content = {};
 
-    content.cover = relations.cover
-      .slots({
-        path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension],
-        alt: language.$('misc.alt.trackCover')
-      });
+    const formatContributions = contributionLinks =>
+      language.formatConjunctionList(
+        contributionLinks.map(link =>
+          link
+            .slots({
+              showContribution: true,
+              showIcons: true,
+            })));
+
+    if (data.hasCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension],
+          alt: language.$('misc.alt.trackCover')
+        });
+    }
 
     content.main = {
       headingMode: 'sticky',
-      content: html.tag(null, [
+      content: html.tags([
         html.tag('p',
           {
             [html.onlyIfContent]: true,
-            [html.joinChildren]: '<br>',
+            [html.joinChildren]: html.tag('br'),
           },
           [
-            relations.artistLinks &&
+            !empty(relations.artistLinks) &&
               language.$('releaseInfo.by', {
-                artists: relations.artistLinks,
+                artists: formatContributions(relations.artistLinks),
               }),
 
-            relations.coverArtistLinks &&
+            !empty(relations.coverArtistLinks) &&
               language.$('releaseInfo.coverArtBy', {
-                artists: relations.coverArtistLinks,
+                artists: formatContributions(relations.coverArtistLinks),
               }),
 
-            relations.wallpaperArtistLinks &&
+            !empty(relations.wallpaperArtistLinks) &&
               language.$('releaseInfo.wallpaperArtBy', {
-                artists: relations.wallpaperArtistLinks,
+                artists: formatContributions(relations.wallpaperArtistLinks),
               }),
 
-            relations.bannerArtistLinks &&
+            !empty(relations.bannerArtistLinks) &&
               language.$('releaseInfo.bannerArtBy', {
-                artists: relations.bannerArtistLinks,
+                artists: formatContributions(relations.bannerArtistLinks),
               }),
 
             data.date &&
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index dd41ba11..fe46153d 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -2,7 +2,7 @@ import {compareArrays} from '../../util/sugar.js';
 
 export default {
   contentDependencies: [
-    'generateContributionLinks',
+    'linkContribution',
     'linkTrack',
   ],
 
@@ -16,10 +16,11 @@ export default {
     const relations = {};
 
     relations.contributionLinks =
-      relation('generateContributionLinks', track.artistContribs, {
-        showContribution: false,
-        showIcons: false,
-      });
+      track.artistContribs.map(({who, what}) =>
+        relation('linkContribution', who, what, {
+          showContribution: false,
+          showIcons: false,
+        }));
 
     relations.trackLink =
       relation('linkTrack', track);
@@ -67,7 +68,7 @@ export default {
             by:
               html.tag('span', {class: 'by'},
                 language.$('trackList.item.withArtists.by', {
-                  artists: relations.contributionLinks,
+                  artists: language.formatConjunctionList(relations.contributionLinks),
                 })),
           })));
   },
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index f5e4bd00..109a32fd 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -19,7 +19,7 @@ export default {
             id: slots.id,
             tabindex: '0',
           },
-          slots.content);
+          slots.title);
       },
     });
   }
diff --git a/src/content/dependencies/generateContributionLinks.js b/src/content/dependencies/generateContributionLinks.js
deleted file mode 100644
index c035c271..00000000
--- a/src/content/dependencies/generateContributionLinks.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import {empty} from '../../util/sugar.js';
-
-export default {
-  contentDependencies: [
-    'linkArtist',
-    'linkExternalAsIcon',
-  ],
-
-  extraDependencies: [
-    'html',
-    'language',
-  ],
-
-  relations(relation, contributions, {showIcons = false} = {}) {
-    const relations = {};
-
-    relations.artistLinks =
-      contributions.map(({who}) => relation('linkArtist', who));
-
-    if (showIcons) {
-      relations.artistIcons =
-        contributions.map(({who}) =>
-          who.urls.map(url =>
-            relation('linkExternalAsIcon', url)));
-    }
-
-    return relations;
-  },
-
-  data(contributions, {
-    showContribution = false,
-    showIcons = false,
-  } = {}) {
-    const data = {};
-
-    data.contributionData =
-      contributions.map(({who, what}) => ({
-        hasContributionPart: !!(showContribution && what),
-        hasExternalPart: !!(showIcons && !empty(who.urls)),
-        contribution: showContribution && what,
-      }));
-
-    return data;
-  },
-
-  generate(data, relations, {
-    html,
-    language,
-  }) {
-    return language.formatConjunctionList(
-      data.contributionData.map(({
-        hasContributionPart,
-        hasExternalPart,
-        contribution,
-      }, index) => {
-        const artistLink = relations.artistLinks[index];
-        const artistIcons = relations.artistIcons?.[index];
-
-        const externalLinks = hasExternalPart &&
-          html.tag('span',
-            {[html.noEdgeWhitespace]: true, class: 'icons'},
-            language.formatUnitList(artistIcons));
-
-        return (
-          (hasContributionPart
-            ? (hasExternalPart
-                ? language.$('misc.artistLink.withContribution.withExternalLinks', {
-                    artist: artistLink,
-                    contrib: contribution,
-                    links: externalLinks,
-                  })
-                : language.$('misc.artistLink.withContribution', {
-                    artist: artistLink,
-                    contrib: contribution,
-                  }))
-            : (hasExternalPart
-                ? language.$('misc.artistLink.withExternalLinks', {
-                    artist: artistLink,
-                    links: externalLinks,
-                  })
-                : language.$('misc.artistLink', {
-                    artist: artistLink,
-                  })))
-        );
-      }));
-  },
-};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 00000000..f7f14573
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: [
+    'generateTrackInfoPageContent',
+    'generateAlbumStyleRules',
+    'generateColorStyleRules',
+    'generatePageLayout',
+  ],
+
+  extraDependencies: ['language'],
+
+  relations(relation, track) {
+    return {
+      layout: relation('generatePageLayout'),
+
+      content: relation('generateTrackInfoPageContent', track),
+      albumStyleRules: relation('generateAlbumStyleRules', track.album),
+      colorStyleRules: relation('generateColorStyleRules', track.color),
+    };
+  },
+
+  data(track) {
+    return {
+      name: track.name,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.layout
+      .slots({
+        title: language.$('trackPage.title', {track: data.name}),
+        styleRules: [
+          relations.albumStyleRules,
+          relations.colorStyleRules,
+        ],
+
+        cover: relations.content.cover,
+        mainContent: relations.content.main.content,
+      });
+  },
+}
diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js
new file mode 100644
index 00000000..0ebb4121
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageContent.js
@@ -0,0 +1,645 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'linkAlbum',
+    'linkContribution',
+    'linkExternal',
+    'linkTrack',
+  ],
+
+  extraDependencies: [
+    'html',
+    'language',
+    'transformMultiline',
+  ],
+
+  relations(relation, track) {
+    const relations = {};
+
+    const {album} = track;
+
+    const contributionLinksRelation = contribs =>
+      contribs.map(contrib =>
+        relation('linkContribution', contrib.who, contrib.what));
+
+    if (track.hasUniqueCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', track.artTags);
+      relations.coverArtistLinks =
+        contributionLinksRelation(track.coverArtistContribs);
+    } else if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', album.artTags);
+    }
+
+    relations.artistLinks =
+      contributionLinksRelation(track.artistContribs);
+
+    relations.externalLinks =
+      track.urls.map(url =>
+        relation('linkExternal', url));
+
+    relations.otherReleasesHeading =
+      relation('generateContentHeading');
+
+    relations.otherReleases =
+      track.otherReleases.map(track => ({
+        trackLink: relation('linkTrack', track),
+        albumLink: relation('linkAlbum', track.album),
+      }));
+
+    if (!empty(track.contributorContribs)) {
+      relations.contributorsHeading =
+        relation('generateContentHeading');
+      relations.contributorLinks =
+        contributionLinksRelation(track.contributorContribs);
+    }
+
+    return relations;
+  },
+
+  data(track) {
+    const data = {};
+
+    const {album} = track;
+
+    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;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {
+    html,
+    language,
+    // transformMultiline,
+  }) {
+    const content = {};
+
+    if (data.hasUniqueCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: [
+            'media.trackCover',
+            data.albumCoverArtDirectory,
+            data.trackCoverArtDirectory,
+            data.coverArtFileExtension,
+          ],
+        });
+    } else if (data.hasAlbumCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: [
+            'media.albumCover',
+            data.albumCoverArtDirectory,
+            data.coverArtFileExtension,
+          ],
+        });
+    }
+
+    content.main = {
+      headingMode: 'sticky',
+
+      content: html.tags([
+        html.tag('p', {
+          [html.onlyIfContent]: true,
+          [html.joinChildren]: html.tag('br'),
+        }, [
+          !empty(relations.artistLinks) &&
+            language.$('releaseInfo.by', {artists: relations.artistLinks}),
+
+          !empty(relations.coverArtistLinks) &&
+            language.$('releaseInfo.coverArtBy', {artists: relations.coverArtistLinks}),
+
+          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',
+          {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: '<br>',
+          },
+          [
+            hasSheetMusicFiles &&
+              language.$('releaseInfo.sheetMusicFiles.shortcut', {
+                link: html.tag('a',
+                  {href: '#sheet-music-files'},
+                  language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+              }),
+
+            hasMidiProjectFiles &&
+              language.$('releaseInfo.midiProjectFiles.shortcut', {
+                link: html.tag('a',
+                  {href: '#midi-project-files'},
+                  language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+              }),
+
+            hasAdditionalFiles &&
+              generateAdditionalFilesShortcut(track.additionalFiles),
+          ]),
+        */
+
+        html.tag('p',
+          (empty(relations.externalLinks)
+            ? language.$('releaseInfo.listenOn.noLinks')
+            : language.$('releaseInfo.listenOn', {
+                links: language.formatDisjunctionList(relations.externalLinks),
+              }))),
+
+        !empty(relations.otherReleases) && [
+          relations.otherReleasesHeading
+            .slots({
+              id: 'also-released-as',
+              title: language.$('releaseInfo.alsoReleasedAs'),
+            }),
+
+          html.tag('ul',
+            relations.otherReleases.map(({trackLink, albumLink}) =>
+              html.tag('li',
+                language.$('releaseInfo.alsoReleasedAs.item', {
+                  track: trackLink,
+                  album: albumLink,
+                })))),
+        ],
+
+        relations.contributorLinks && [
+          relations.contributorsHeading
+            .slots({
+              id: 'contributors',
+              title: language.$('releaseInfo.contributors'),
+            }),
+
+          html.tag('ul', relations.contributorLinks.map(contributorLink =>
+            html.tag('li',
+              contributorLink
+                .slots({
+                  showIcons: true,
+                  showContribution: true,
+                })))),
+        ],
+      ]),
+    };
+
+    return content;
+  },
+};
+
+/*
+export function write(track, {wikiData}) {
+  const {wikiInfo} = wikiData;
+
+  const {album, contributorContribs, referencedByTracks, referencedTracks, sampledByTracks, sampledTracks, otherReleases, } = track;
+
+  const listTag = getAlbumListTag(album);
+
+  let flashesThatFeature;
+  if (wikiInfo.enableFlashesAndGames) {
+    flashesThatFeature = sortChronologically(
+      [track, ...otherReleases].flatMap((track) =>
+        track.featuredInFlashes.map((flash) => ({
+          flash,
+          as: track,
+          directory: flash.directory,
+          name: flash.name,
+          date: flash.date,
+        }))
+      )
+    );
+  }
+
+  const unbound_getTrackItem = (track, {
+    getArtistString,
+    html,
+    language,
+    link,
+  }) =>
+    html.tag('li',
+      language.$('trackList.item.withArtists', {
+        track: link.track(track),
+        by: html.tag('span',
+          {class: 'by'},
+          language.$('trackList.item.withArtists.by', {
+            artists: getArtistString(track.artistContribs),
+          })),
+      }));
+
+  const hasCommentary =
+    track.commentary || otherReleases.some((t) => t.commentary);
+
+  const hasAdditionalFiles = !empty(track.additionalFiles);
+  const hasSheetMusicFiles = !empty(track.sheetMusicFiles);
+  const hasMidiProjectFiles = !empty(track.midiProjectFiles);
+  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
+
+  const generateCommentary = ({language, link, transformMultiline}) =>
+    transformMultiline([
+      track.commentary,
+      ...otherReleases.map((track) =>
+        track.commentary
+          ?.split('\n')
+          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
+          .flatMap(line => [
+            line,
+            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
+              original: link.track(track),
+            }),
+          ])
+          .join('\n')
+      ),
+    ].filter(Boolean).join('\n'));
+
+  const data = {
+    type: 'data',
+    path: ['track', track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
+
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasArtists = !empty(track.artistContribs);
+    const hasCoverArtists = !empty(track.coverArtistContribs);
+    const getArtistString = (contribs) =>
+      _getArtistString(contribs, {
+        // We don't want to put actual HTML tags in social embeds (sadly
+        // they don't get parsed and displayed, generally speaking), so
+        // override the link argument so that artist "links" just show
+        // their names.
+        link: {artist: (artist) => artist.name},
+      });
+    if (!hasArtists && !hasCoverArtists) return '';
+    return language.formatString(
+      'trackPage.socialEmbed.body' +
+        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
+          .filter(Boolean)
+          .join(''),
+      Object.fromEntries(
+        [
+          hasArtists && ['artists', getArtistString(track.artistContribs)],
+          hasCoverArtists && [
+            'coverArtists',
+            getArtistString(track.coverArtistContribs),
+          ],
+        ].filter(Boolean)
+      )
+    );
+  };
+
+  const page = {
+    type: 'page',
+    path: ['track', track.directory],
+    page: ({
+      absoluteTo,
+      fancifyURL,
+      generateAdditionalFilesList,
+      generateAdditionalFilesShortcut,
+      generateChronologyLinks,
+      generateContentHeading,
+      generateNavigationLinks,
+      generateTrackListDividedByGroups,
+      getAlbumStylesheet,
+      getArtistString,
+      getLinkThemeString,
+      getSizeOfAdditionalFile,
+      getThemeString,
+      getTrackCover,
+      html,
+      link,
+      language,
+      transformLyrics,
+      transformMultiline,
+      to,
+      urls,
+    }) => {
+      const getTrackItem = bindOpts(unbound_getTrackItem, {
+        getArtistString,
+        html,
+        language,
+        link,
+      });
+
+      const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, {
+        [bindOpts.bindIndex]: 2,
+        generateAdditionalFilesList,
+        getSizeOfAdditionalFile,
+        link,
+        urls,
+      });
+
+      return {
+        title: language.$('trackPage.title', {track: track.name}),
+        stylesheet: getAlbumStylesheet(album, {to}),
+
+        themeColor: track.color,
+        theme:
+          getThemeString(track.color, {
+            additionalVariables: [
+              `--album-directory: ${album.directory}`,
+              `--track-directory: ${track.directory}`,
+            ]
+          }),
+
+        socialEmbed: {
+          heading: language.$('trackPage.socialEmbed.heading', {
+            album: track.album.name,
+          }),
+          headingLink: absoluteTo('localized.album', album.directory),
+          title: language.$('trackPage.socialEmbed.title', {
+            track: track.name,
+          }),
+          description: getSocialEmbedDescription({getArtistString, language}),
+          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
+          color: track.color,
+        },
+
+        // disabled for now! shifting banner position per height of page is disorienting
+        /*
+        banner: !empty(album.bannerArtistContribs) && {
+          classes: ['dim'],
+          dimensions: album.bannerDimensions,
+          path: ['media.albumBanner', album.directory, album.bannerFileExtension],
+          alt: language.$('misc.alt.albumBanner'),
+          position: 'bottom'
+        },
+        * /
+
+        main: {
+          headingMode: 'sticky',
+
+          content: [
+            ...html.fragment(
+              !empty(contributorContribs) && [
+                generateContentHeading({
+                  id: 'contributors',
+                  title: language.$('releaseInfo.contributors'),
+                }),
+
+                html.tag('ul', contributorContribs.map(contrib =>
+                  html.tag('li', getArtistString([contrib], {
+                    showContrib: true,
+                    showIcons: true,
+                  })))),
+              ]),
+
+            ...html.fragment(
+              !empty(referencedTracks) && [
+                generateContentHeading({
+                  id: 'references',
+                  title:
+                    language.$('releaseInfo.tracksReferenced', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
+
+                html.tag('ul', referencedTracks.map(getTrackItem)),
+              ]),
+
+            ...html.fragment(
+              !empty(referencedByTracks) && [
+                generateContentHeading({
+                  id: 'referenced-by',
+                  title:
+                    language.$('releaseInfo.tracksThatReference', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
+
+                generateTrackListDividedByGroups(referencedByTracks, {
+                  getTrackItem,
+                  wikiData,
+                }),
+              ]),
+
+            ...html.fragment(
+              !empty(sampledTracks) && [
+                generateContentHeading({
+                  id: 'samples',
+                  title:
+                    language.$('releaseInfo.tracksSampled', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
+
+                html.tag('ul', sampledTracks.map(getTrackItem)),
+              ]),
+
+            ...html.fragment(
+              !empty(sampledByTracks) && [
+                generateContentHeading({
+                  id: 'sampled-by',
+                  title:
+                    language.$('releaseInfo.tracksThatSample', {
+                      track: html.tag('i', track.name),
+                    })
+                }),
+
+                html.tag('ul', sampledByTracks.map(getTrackItem)),
+              ]),
+
+            ...html.fragment(
+              wikiInfo.enableFlashesAndGames &&
+              !empty(flashesThatFeature) && [
+                generateContentHeading({
+                  id: 'featured-in',
+                  title:
+                    language.$('releaseInfo.flashesThatFeature', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
+
+                html.tag('ul', flashesThatFeature.map(({flash, as}) =>
+                  html.tag('li',
+                    {class: as !== track && 'rerelease'},
+                    (as === track
+                      ? language.$('releaseInfo.flashesThatFeature.item', {
+                        flash: link.flash(flash),
+                      })
+                      : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
+                        flash: link.flash(flash),
+                        track: link.track(as),
+                      }))))),
+              ]),
+
+            ...html.fragment(
+              track.lyrics && [
+                generateContentHeading({
+                  id: 'lyrics',
+                  title: language.$('releaseInfo.lyrics'),
+                }),
+
+                html.tag('blockquote', transformLyrics(track.lyrics)),
+              ]),
+
+            ...html.fragment(
+              hasSheetMusicFiles && [
+                generateContentHeading({
+                  id: 'sheet-music-files',
+                  title: language.$('releaseInfo.sheetMusicFiles.heading'),
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, {
+                  fileSize: false,
+                }),
+              ]),
+
+            ...html.fragment(
+              hasMidiProjectFiles && [
+                generateContentHeading({
+                  id: 'midi-project-files',
+                  title: language.$('releaseInfo.midiProjectFiles.heading'),
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.midiProjectFiles),
+              ]),
+
+            ...html.fragment(
+              hasAdditionalFiles && [
+                generateContentHeading({
+                  id: 'additional-files',
+                  title: language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
+                      unit: true,
+                    }),
+                  })
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.additionalFiles),
+              ]),
+
+            ...html.fragment(
+              hasCommentary && [
+                generateContentHeading({
+                  id: 'artist-commentary',
+                  title: language.$('releaseInfo.artistCommentary'),
+                }),
+
+                html.tag('blockquote', generateCommentary({
+                  link,
+                  language,
+                  transformMultiline,
+                })),
+              ]),
+          ],
+        },
+
+        sidebarLeft: generateAlbumSidebar(album, track, {
+          fancifyURL,
+          getLinkThemeString,
+          html,
+          language,
+          link,
+          transformMultiline,
+          wikiData,
+        }),
+
+        nav: {
+          linkContainerClasses: ['nav-links-hierarchy'],
+          links: [
+            {toHome: true},
+            {
+              path: ['localized.album', album.directory],
+              title: album.name,
+            },
+            listTag === 'ol' &&
+              {
+                html: language.$('trackPage.nav.track.withNumber', {
+                  number: album.tracks.indexOf(track) + 1,
+                  track: link.track(track, {class: 'current', to}),
+                }),
+              },
+            listTag === 'ul' &&
+              {
+                html: language.$('trackPage.nav.track', {
+                  track: link.track(track, {class: 'current', to}),
+                }),
+              },
+          ].filter(Boolean),
+
+          content: generateAlbumChronologyLinks(album, track, {
+            generateChronologyLinks,
+            html,
+          }),
+
+          bottomRowContent:
+            album.tracks.length > 1 &&
+              generateAlbumNavLinks(album, track, {
+                generateNavigationLinks,
+                html,
+                language,
+              }),
+        },
+
+        secondaryNav: generateAlbumSecondaryNav(album, track, {
+          getLinkThemeString,
+          html,
+          language,
+          link,
+        }),
+      };
+    },
+  };
+
+  return [data, page];
+}
+*/
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
new file mode 100644
index 00000000..36b0d13a
--- /dev/null
+++ b/src/content/dependencies/linkAlbum.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.album', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
new file mode 100644
index 00000000..1d0e2d6a
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,74 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkExternalAsIcon',
+  ],
+
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  relations(relation, artist) {
+    const relations = {};
+
+    relations.artistLink = relation('linkArtist', artist);
+
+    relations.artistIcons =
+      (artist.urls ?? []).map(url =>
+        relation('linkExternalAsIcon', url));
+
+    return relations;
+  },
+
+  data(artist, contribution) {
+    return {contribution};
+  },
+
+  generate(data, relations, {
+    html,
+    language,
+  }) {
+    return html.template({
+      annotation: 'linkContribution',
+
+      slots: {
+        showContribution: {type: 'boolean', default: false},
+        showIcons: {type: 'boolean', default: false},
+      },
+
+      content(slots) {
+        const hasContributionPart = !!(slots.showContribution && data.contribution);
+        const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons));
+
+        const externalLinks = hasExternalPart &&
+          html.tag('span',
+            {[html.noEdgeWhitespace]: true, class: 'icons'},
+            language.formatUnitList(relations.artistIcons));
+
+        return (
+          (hasContributionPart
+            ? (hasExternalPart
+                ? language.$('misc.artistLink.withContribution.withExternalLinks', {
+                    artist: relations.artistLink,
+                    contrib: data.contribution,
+                    links: externalLinks,
+                  })
+                : language.$('misc.artistLink.withContribution', {
+                    artist: relations.artistLink,
+                    contrib: data.contribution,
+                  }))
+            : (hasExternalPart
+                ? language.$('misc.artistLink.withExternalLinks', {
+                    artist: relations.artistLink,
+                    links: externalLinks,
+                  })
+                : language.$('misc.artistLink', {
+                    artist: relations.artistLink,
+                  }))));
+      },
+    });
+  },
+};
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 303f33f3..f144b21f 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -27,9 +27,8 @@ export class Artist extends Thing {
 
     aliasNames: {
       flags: {update: true, expose: true},
-      update: {
-        validate: validateArrayItems(isName),
-      },
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
     },
 
     isAlias: Thing.common.flag(),
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 5ab15c0e..f0065b55 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -63,6 +63,7 @@ export default class Thing extends CacheableObject {
     urls: () => ({
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isURL)},
+      expose: {transform: (value) => value ?? []},
     }),
 
     // A file extension! Or the default, if provided when calling this.
diff --git a/src/page/index.js b/src/page/index.js
index 8cf1d965..3cbddbfb 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -120,4 +120,4 @@ export * as album from './album.js';
 // export * as news from './news.js';
 // export * as static from './static.js';
 // export * as tag from './tag.js';
-// export * as track from './track.js';
+export * as track from './track.js';
diff --git a/src/page/track.js b/src/page/track.js
index 7f0d1cf2..e75b6958 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,553 +1,21 @@
 // Track page specification.
 
-import {
-  generateAlbumChronologyLinks,
-  generateAlbumNavLinks,
-  generateAlbumSecondaryNav,
-  generateAlbumSidebar,
-  generateAlbumAdditionalFilesList as unbound_generateAlbumAdditionalFilesList,
-} from './album.js';
-
-import {
-  bindOpts,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getTrackCover,
-  getAlbumListTag,
-  sortChronologically,
-} from '../util/wiki-data.js';
-
 export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
   return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-  const {wikiInfo} = wikiData;
-
-  const {
-    album,
-    contributorContribs,
-    referencedByTracks,
-    referencedTracks,
-    sampledByTracks,
-    sampledTracks,
-    otherReleases,
-  } = track;
-
-  const listTag = getAlbumListTag(album);
-
-  let flashesThatFeature;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashesThatFeature = sortChronologically(
-      [track, ...otherReleases].flatMap((track) =>
-        track.featuredInFlashes.map((flash) => ({
-          flash,
-          as: track,
-          directory: flash.directory,
-          name: flash.name,
-          date: flash.date,
-        }))
-      )
-    );
-  }
-
-  const unbound_getTrackItem = (track, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('li',
-      language.$('trackList.item.withArtists', {
-        track: link.track(track),
-        by: html.tag('span',
-          {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: getArtistString(track.artistContribs),
-          })),
-      }));
-
-  const hasCommentary =
-    track.commentary || otherReleases.some((t) => t.commentary);
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
 
-  const hasAdditionalFiles = !empty(track.additionalFiles);
-  const hasSheetMusicFiles = !empty(track.sheetMusicFiles);
-  const hasMidiProjectFiles = !empty(track.midiProjectFiles);
-  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
-
-  const generateCommentary = ({language, link, transformMultiline}) =>
-    transformMultiline([
-      track.commentary,
-      ...otherReleases.map((track) =>
-        track.commentary
-          ?.split('\n')
-          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
-          .flatMap(line => [
-            line,
-            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
-              original: link.track(track),
-            }),
-          ])
-          .join('\n')
-      ),
-    ].filter(Boolean).join('\n'));
-
-  const data = {
-    type: 'data',
-    path: ['track', track.directory],
-    data: ({
-      serializeContribs,
-      serializeCover,
-      serializeGroupsForTrack,
-      serializeLink,
-    }) => ({
-      name: track.name,
-      directory: track.directory,
-      dates: {
-        released: track.date,
-        originallyReleased: track.originalDate,
-        coverArtAdded: track.coverArtDate,
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        args: [track],
       },
-      duration: track.duration,
-      color: track.color,
-      cover: serializeCover(track, getTrackCover),
-      artistsContribs: serializeContribs(track.artistContribs),
-      contributorContribs: serializeContribs(track.contributorContribs),
-      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
-      album: serializeLink(track.album),
-      groups: serializeGroupsForTrack(track),
-      references: track.references.map(serializeLink),
-      referencedBy: track.referencedBy.map(serializeLink),
-      alsoReleasedAs: otherReleases.map((track) => ({
-        track: serializeLink(track),
-        album: serializeLink(track.album),
-      })),
-    }),
-  };
-
-  const getSocialEmbedDescription = ({
-    getArtistString: _getArtistString,
-    language,
-  }) => {
-    const hasArtists = !empty(track.artistContribs);
-    const hasCoverArtists = !empty(track.coverArtistContribs);
-    const getArtistString = (contribs) =>
-      _getArtistString(contribs, {
-        // We don't want to put actual HTML tags in social embeds (sadly
-        // they don't get parsed and displayed, generally speaking), so
-        // override the link argument so that artist "links" just show
-        // their names.
-        link: {artist: (artist) => artist.name},
-      });
-    if (!hasArtists && !hasCoverArtists) return '';
-    return language.formatString(
-      'trackPage.socialEmbed.body' +
-        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
-          .filter(Boolean)
-          .join(''),
-      Object.fromEntries(
-        [
-          hasArtists && ['artists', getArtistString(track.artistContribs)],
-          hasCoverArtists && [
-            'coverArtists',
-            getArtistString(track.coverArtistContribs),
-          ],
-        ].filter(Boolean)
-      )
-    );
-  };
-
-  const page = {
-    type: 'page',
-    path: ['track', track.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateAdditionalFilesList,
-      generateAdditionalFilesShortcut,
-      generateChronologyLinks,
-      generateContentHeading,
-      generateNavigationLinks,
-      generateTrackListDividedByGroups,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      getTrackCover,
-      html,
-      link,
-      language,
-      transformLyrics,
-      transformMultiline,
-      to,
-      urls,
-    }) => {
-      const getTrackItem = bindOpts(unbound_getTrackItem, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, {
-        [bindOpts.bindIndex]: 2,
-        generateAdditionalFilesList,
-        getSizeOfAdditionalFile,
-        link,
-        urls,
-      });
-
-      return {
-        title: language.$('trackPage.title', {track: track.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-
-        themeColor: track.color,
-        theme:
-          getThemeString(track.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-              `--track-directory: ${track.directory}`,
-            ]
-          }),
-
-        socialEmbed: {
-          heading: language.$('trackPage.socialEmbed.heading', {
-            album: track.album.name,
-          }),
-          headingLink: absoluteTo('localized.album', album.directory),
-          title: language.$('trackPage.socialEmbed.title', {
-            track: track.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-          color: track.color,
-        },
-
-        // disabled for now! shifting banner position per height of page is disorienting
-        /*
-        banner: !empty(album.bannerArtistContribs) && {
-          classes: ['dim'],
-          dimensions: album.bannerDimensions,
-          path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'bottom'
-        },
-        */
-
-        cover: {
-          src: getTrackCover(track),
-          alt: language.$('misc.alt.trackCover'),
-          artTags: track.artTags,
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(track.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(track.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(track.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(track.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                track.date &&
-                  language.$('releaseInfo.released', {
-                    date: language.formatDate(track.date),
-                  }),
-
-                track.hasCoverArt &&
-                track.coverArtDate &&
-                +track.coverArtDate !== +track.date &&
-                  language.$('releaseInfo.artReleased', {
-                    date: language.formatDate(track.coverArtDate),
-                  }),
-
-                track.duration &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(
-                      track.duration
-                    ),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasSheetMusicFiles &&
-                  language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#sheet-music-files'},
-                      language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
-                  }),
-
-                hasMidiProjectFiles &&
-                  language.$('releaseInfo.midiProjectFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#midi-project-files'},
-                      language.$('releaseInfo.midiProjectFiles.shortcut.link')),
-                  }),
-
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(track.additionalFiles),
-              ]),
-
-            html.tag('p',
-              (empty(track.urls)
-                ? language.$('releaseInfo.listenOn.noLinks')
-                : language.$('releaseInfo.listenOn', {
-                    links: language.formatDisjunctionList(
-                      track.urls.map(url => fancifyURL(url, {language}))),
-                  }))),
-
-            ...html.fragment(
-              !empty(otherReleases) && [
-                generateContentHeading({
-                  id: 'also-released-as',
-                  title: language.$('releaseInfo.alsoReleasedAs'),
-                }),
-
-                html.tag('ul', otherReleases.map(track =>
-                  html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(contributorContribs) && [
-                generateContentHeading({
-                  id: 'contributors',
-                  title: language.$('releaseInfo.contributors'),
-                }),
-
-                html.tag('ul', contributorContribs.map(contrib =>
-                  html.tag('li', getArtistString([contrib], {
-                    showContrib: true,
-                    showIcons: true,
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedTracks) && [
-                generateContentHeading({
-                  id: 'references',
-                  title:
-                    language.$('releaseInfo.tracksReferenced', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', referencedTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedByTracks) && [
-                generateContentHeading({
-                  id: 'referenced-by',
-                  title:
-                    language.$('releaseInfo.tracksThatReference', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                generateTrackListDividedByGroups(referencedByTracks, {
-                  getTrackItem,
-                  wikiData,
-                }),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledTracks) && [
-                generateContentHeading({
-                  id: 'samples',
-                  title:
-                    language.$('releaseInfo.tracksSampled', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', sampledTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledByTracks) && [
-                generateContentHeading({
-                  id: 'sampled-by',
-                  title:
-                    language.$('releaseInfo.tracksThatSample', {
-                      track: html.tag('i', track.name),
-                    })
-                }),
-
-                html.tag('ul', sampledByTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashesThatFeature) && [
-                generateContentHeading({
-                  id: 'featured-in',
-                  title:
-                    language.$('releaseInfo.flashesThatFeature', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', flashesThatFeature.map(({flash, as}) =>
-                  html.tag('li',
-                    {class: as !== track && 'rerelease'},
-                    (as === track
-                      ? language.$('releaseInfo.flashesThatFeature.item', {
-                        flash: link.flash(flash),
-                      })
-                      : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                        flash: link.flash(flash),
-                        track: link.track(as),
-                      }))))),
-              ]),
-
-            ...html.fragment(
-              track.lyrics && [
-                generateContentHeading({
-                  id: 'lyrics',
-                  title: language.$('releaseInfo.lyrics'),
-                }),
-
-                html.tag('blockquote', transformLyrics(track.lyrics)),
-              ]),
-
-            ...html.fragment(
-              hasSheetMusicFiles && [
-                generateContentHeading({
-                  id: 'sheet-music-files',
-                  title: language.$('releaseInfo.sheetMusicFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, {
-                  fileSize: false,
-                }),
-              ]),
-
-            ...html.fragment(
-              hasMidiProjectFiles && [
-                generateContentHeading({
-                  id: 'midi-project-files',
-                  title: language.$('releaseInfo.midiProjectFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.midiProjectFiles),
-              ]),
-
-            ...html.fragment(
-              hasAdditionalFiles && [
-                generateContentHeading({
-                  id: 'additional-files',
-                  title: language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
-                      unit: true,
-                    }),
-                  })
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.additionalFiles),
-              ]),
-
-            ...html.fragment(
-              hasCommentary && [
-                generateContentHeading({
-                  id: 'artist-commentary',
-                  title: language.$('releaseInfo.artistCommentary'),
-                }),
-
-                html.tag('blockquote', generateCommentary({
-                  link,
-                  language,
-                  transformMultiline,
-                })),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, track, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              path: ['localized.album', album.directory],
-              title: album.name,
-            },
-            listTag === 'ol' &&
-              {
-                html: language.$('trackPage.nav.track.withNumber', {
-                  number: album.tracks.indexOf(track) + 1,
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-            listTag === 'ul' &&
-              {
-                html: language.$('trackPage.nav.track', {
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-          ].filter(Boolean),
-
-          content: generateAlbumChronologyLinks(album, track, {
-            generateChronologyLinks,
-            html,
-          }),
-
-          bottomRowContent:
-            album.tracks.length > 1 &&
-              generateAlbumNavLinks(album, track, {
-                generateNavigationLinks,
-                html,
-                language,
-              }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, track, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
     },
-  };
-
-  return [data, page];
+  ];
 }
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 6dbcf3ee..1e72e5a8 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -433,6 +433,11 @@ export async function go({
 
       function runContentFunction({name, args, relations}) {
         const contentFunction = fulfilledContentDependencies[name];
+
+        if (!contentFunction) {
+          throw new Error(`Content function ${name} unfulfilled or not listed`);
+        }
+
         const filledRelations =
           fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, relations);
 
diff --git a/test/snapshot/generateContributionLinks.js b/test/snapshot/generateContributionLinks.js
index deecf9ef..3283d3b2 100644
--- a/test/snapshot/generateContributionLinks.js
+++ b/test/snapshot/generateContributionLinks.js
@@ -1,7 +1,12 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+// remove generateContributionLinks.js.test.cjs snapshot file too!
+
 import t from 'tap';
 import {testContentFunctions} from '../lib/content-function.js';
 
-testContentFunctions(t, 'generateContributionLinks (snapshot)', async (t, evaluate) => {
+t.skip('generateContributionLinks (snapshot)');
+
+void (() => testContentFunctions(t, 'generateContributionLinks (snapshot)', async (t, evaluate) => {
   const artist1 = {
     name: 'Clark Powell',
     directory: 'clark-powell',
@@ -47,4 +52,4 @@ testContentFunctions(t, 'generateContributionLinks (snapshot)', async (t, evalua
     name: 'generateContributionLinks',
     args: [contributions, {showContribution: false, showIcons: false}],
   });
-});
+}));
diff --git a/test/unit/content/dependencies/generateContributionLinks.js b/test/unit/content/dependencies/generateContributionLinks.js
index a2f02ac7..328adc0b 100644
--- a/test/unit/content/dependencies/generateContributionLinks.js
+++ b/test/unit/content/dependencies/generateContributionLinks.js
@@ -1,7 +1,9 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+
 import t from 'tap';
 import {testContentFunctions} from '../../../lib/content-function.js';
 
-t.test('generateContributionLinks (unit)', async t => {
+t.skip('generateContributionLinks (unit)', async t => {
   const artist1 = {
     name: 'Clark Powell',
     urls: ['https://soundcloud.com/plazmataz'],