« get me outta code hell

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