« 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/generateAbsoluteDatetimestamp.js103
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js2
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js78
-rw-r--r--src/content/dependencies/generateCoverArtwork.js24
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js17
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js2
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js2
-rw-r--r--src/content/dependencies/generateNearbyTrackList.js44
-rw-r--r--src/content/dependencies/generateNewsEntryReadAnotherLinks.js10
-rw-r--r--src/content/dependencies/generateReferencedTracksList.js29
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js27
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js17
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js80
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js83
-rw-r--r--src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js37
-rw-r--r--src/content/dependencies/generateTrackList.js29
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js6
-rw-r--r--src/content/dependencies/generateTrackListItem.js62
-rw-r--r--src/content/dependencies/image.js78
-rw-r--r--src/content/dependencies/linkThing.js5
-rw-r--r--src/content/dependencies/transformContent.js4
21 files changed, 484 insertions, 255 deletions
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
index 2250ded3..d006374a 100644
--- a/src/content/dependencies/generateAbsoluteDatetimestamp.js
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -1,8 +1,12 @@
 export default {
-  data: (date) =>
-    ({date}),
+  data: (date, contextDate) => ({
+    date,
 
-  relations: (relation) => ({
+    contextDate:
+      contextDate ?? null,
+  }),
+
+  relations: (relation, _date, _contextDate) => ({
     template:
       relation('generateDatetimestampTemplate'),
 
@@ -12,35 +16,74 @@ export default {
 
   slots: {
     style: {
-      validate: v => v.is('full', 'year'),
+      validate: v => v.is(...[
+        'full',
+        'year',
+        'minimal-difference',
+        'year-difference',
+      ]),
       default: 'full',
     },
-
-    // Only has an effect for 'year' style.
-    tooltip: {
-      type: 'boolean',
-      default: false,
-    },
   },
 
-  generate: (data, relations, slots, {language}) =>
-    relations.template.slots({
-      mainContent:
-        (slots.style === 'full'
-          ? language.formatDate(data.date)
-       : slots.style === 'year'
-          ? data.date.getFullYear().toString()
-          : null),
-
-      tooltip:
-        slots.tooltip &&
-        slots.style === 'year' &&
-          relations.tooltip.slots({
-            content:
-              language.formatDate(data.date),
-          }),
-
-      datetime:
-        data.date.toISOString(),
-    }),
+  generate(data, relations, slots, {html, language}) {
+    if (!data.date) {
+      return html.blank();
+    }
+
+    relations.template.setSlots({
+      tooltip: relations.tooltip,
+      datetime: data.date.toISOString(),
+    });
+
+    let label = null;
+    let tooltip = null;
+
+    switch (slots.style) {
+      case 'full': {
+        label = language.formatDate(data.date);
+        break;
+      }
+
+      case 'year': {
+        label = language.formatYear(data.date);
+        tooltip = language.formatDate(data.date);
+        break;
+      }
+
+      case 'minimal-difference': {
+        if (data.date.toDateString() === data.contextDate?.toDateString()) {
+          return html.blank();
+        }
+
+        if (data.date.getFullYear() === data.contextDate?.getFullYear()) {
+          label = language.formatMonthDay(data.date);
+          tooltip = language.formatDate(data.date);
+        } else {
+          label = language.formatYear(data.date);
+          tooltip = language.formatDate(data.date);
+        }
+
+        break;
+      }
+
+      case 'year-difference': {
+        if (data.date.toDateString() === data.contextDate?.toDateString()) {
+          return html.blank();
+        }
+
+        if (data.date.getFullYear() === data.contextDate?.getFullYear()) {
+          label = language.formatDate(data.date);
+        } else {
+          label = language.formatYear(data.date);
+          tooltip = language.formatDate(data.date);
+        }
+      }
+    }
+
+    relations.template.setSlot('mainContent', label);
+    relations.tooltip.setSlot('content', tooltip);
+
+    return relations.template;
+  },
 };
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index ab8d477d..68722a83 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -40,7 +40,7 @@ export default {
 
   generate: (data, relations, slots) =>
     relations.item.slots({
-      showArtists: true,
+      showArtists: 'auto',
 
       showDuration:
         (slots.collapseDurationScope === 'track'
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 4da3ecb9..8cc30913 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -1,10 +1,11 @@
+import multilingualWordCount from 'word-count';
+
 import {sortChronologically} from '#sort';
 import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  sprawl({albumData}) {
-    return {albumData};
-  },
+  sprawl: ({albumData}) =>
+    ({albumData}),
 
   query(sprawl) {
     const query = {};
@@ -18,44 +19,52 @@ export default {
           .filter(({commentary}) => commentary)
           .flatMap(({commentary}) => commentary));
 
-    query.wordCounts =
-      entries.map(entries =>
-        accumulateSum(
-          entries,
-          entry => entry.body.split(' ').length));
+    query.bodies =
+      entries.map(entries => entries.map(entry => entry.body));
 
     query.entryCounts =
       entries.map(entries => entries.length);
 
-    filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts,
-      (album, wordCount, entryCount) => entryCount >= 1);
+    filterMultipleArrays(query.albums, query.bodies, query.entryCounts,
+      (album, bodies, entryCount) => entryCount >= 1);
 
     return query;
   },
 
-  relations(relation, query) {
-    return {
-      layout:
-        relation('generatePageLayout'),
+  relations: (relation, query) => ({
+    layout:
+      relation('generatePageLayout'),
 
-      albumLinks:
-        query.albums
-          .map(album => relation('linkAlbumCommentary', album)),
-    };
-  },
+    albumLinks:
+      query.albums
+        .map(album => relation('linkAlbumCommentary', album)),
 
-  data(query) {
-    return {
-      wordCounts: query.wordCounts,
-      entryCounts: query.entryCounts,
+    albumBodies:
+      query.bodies
+        .map(bodies => bodies
+          .map(body => relation('transformContent', body))),
+  }),
 
-      totalWordCount: accumulateSum(query.wordCounts),
-      totalEntryCount: accumulateSum(query.entryCounts),
-    };
-  },
+  data: (query) => ({
+    entryCounts: query.entryCounts,
+    totalEntryCount: accumulateSum(query.entryCounts),
+  }),
 
-  generate: (data, relations, {html, language}) =>
-    language.encapsulate('commentaryIndex', pageCapsule =>
+  generate(data, relations, {html, language}) {
+    const wordCounts =
+      relations.albumBodies.map(bodies =>
+        accumulateSum(bodies, body =>
+          multilingualWordCount(
+            html.resolve(
+              body.slot('mode', 'multiline'),
+              {normalize: 'plain'}))));
+
+    const totalWordCount =
+      accumulateSum(wordCounts);
+
+    const {entryCounts, totalEntryCount} = data;
+
+    return language.encapsulate('commentaryIndex', pageCapsule =>
       relations.layout.slots({
         title: language.$(pageCapsule, 'title'),
 
@@ -66,11 +75,11 @@ export default {
           html.tag('p', language.$(pageCapsule, 'infoLine', {
             words:
               html.tag('b',
-                language.formatWordCount(data.totalWordCount, {unit: true})),
+                language.formatWordCount(totalWordCount, {unit: true})),
 
             entries:
               html.tag('b',
-                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+                language.countCommentaryEntries(totalEntryCount, {unit: true})),
           })),
 
           language.encapsulate(pageCapsule, 'albumList', listCapsule => [
@@ -80,8 +89,8 @@ export default {
             html.tag('ul',
               stitchArrays({
                 albumLink: relations.albumLinks,
-                wordCount: data.wordCounts,
-                entryCount: data.entryCounts,
+                wordCount: wordCounts,
+                entryCount: entryCounts,
               }).map(({albumLink, wordCount, entryCount}) =>
                 html.tag('li',
                   language.$(listCapsule, 'item', {
@@ -97,5 +106,6 @@ export default {
           {auto: 'home'},
           {auto: 'current'},
         ],
-      })),
+      }));
+  },
 };
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 89b66ce0..616b3c95 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -122,6 +122,30 @@ export default {
                 thumb: 'medium',
                 reveal: true,
                 link: true,
+
+                responsiveThumb: true,
+                responsiveSizes:
+                  // No clamp(), min(), or max() here because Safari.
+                  // The boundaries here are mostly experimental, apart from
+                  // the ones which flat-out switch layouts.
+
+                  // Layout - Thin (phones)
+                  // Most of viewport width
+                  '(max-width: 600px) 90vw,\n' +
+
+                  // Layout - Medium
+                  // Sidebar is hidden; content area is by definition
+                  // most of the viewport
+                  '(max-width: 640px) 220px,\n' +
+                  '(max-width: 800px) 36vw,\n' +
+                  '(max-width: 850px) 280px,\n' +
+
+                  // Layout - Wide
+                  // Sidebar is visible; content area has its own maximum
+                  // Assume the sidebar is at minimum width
+                  '(max-width: 880px) 220px,\n' +
+                  '(max-width: 1050pz) calc(0.40 * (90vw - 150px - 10px)),\n' +
+                  '280px',
               }),
 
               slots.showOriginDetails &&
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index db18e9e4..e489eea6 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -24,9 +24,9 @@ export default {
         : null),
 
     datetimestamp:
-      (artwork.date && artwork.date !== artwork.thing.date
-        ? relation('generateAbsoluteDatetimestamp', artwork.date)
-        : null),
+      relation('generateAbsoluteDatetimestamp',
+        artwork.date,
+        artwork.thing.date),
   }),
 
 
@@ -53,10 +53,7 @@ export default {
         {class: 'origin-details'},
 
         (() => {
-          relations.datetimestamp?.setSlots({
-            style: 'year',
-            tooltip: true,
-          });
+          relations.datetimestamp.setSlot('style', 'year-difference');
 
           const artworkBy =
             language.encapsulate(capsule, 'artworkBy', workingCapsule => {
@@ -67,7 +64,7 @@ export default {
                 workingOptions.label = data.label;
               }
 
-              if (relations.datetimestamp) {
+              if (!html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
@@ -108,7 +105,7 @@ export default {
                 workingOptions.label = data.label;
               }
 
-              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+              if (html.isBlank(artworkBy) && !html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
@@ -125,7 +122,7 @@ export default {
                 label: data.label,
               };
 
-              if (relations.datetimestamp) {
+              if (!html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 86ec6648..935ffdc6 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -63,7 +63,7 @@ export default {
       relation('generateFlashNavAccent', flash),
 
     featuredTracksList:
-      relation('generateTrackList', flash.featuredTracks),
+      relation('generateTrackList', flash.featuredTracks, []),
 
     contributorContributionList:
       relation('generateContributionList', flash.contributorContribs),
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 09b0a542..1211dfb8 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -66,7 +66,7 @@ export default {
             workingOptions.yearAccent =
               language.$(yearCapsule, 'accent', {
                 year:
-                  relations.datetimestamp.slots({style: 'year', tooltip: true}),
+                  relations.datetimestamp.slot('style', 'year'),
               });
           }
 
diff --git a/src/content/dependencies/generateNearbyTrackList.js b/src/content/dependencies/generateNearbyTrackList.js
new file mode 100644
index 00000000..56ab2df5
--- /dev/null
+++ b/src/content/dependencies/generateNearbyTrackList.js
@@ -0,0 +1,44 @@
+export default {
+  query: (tracks, contextTrack, _contextContributions) => ({
+    presentedTracks:
+      (contextTrack
+        ? tracks.map(track =>
+            track.otherReleases.find(({album}) => album === contextTrack.album) ??
+            track)
+        : tracks),
+  }),
+
+  relations: (relation, query, _tracks, _contextTrack, contextContributions) => ({
+    items:
+      query.presentedTracks
+        .map(track => relation('generateTrackListItem', track, contextContributions)),
+  }),
+
+  slots: {
+    showArtists: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    showDuration: {
+      type: 'boolean',
+      default: false,
+    },
+
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.items.map(item =>
+        item.slots({
+          showArtists: slots.showArtists,
+          showDuration: slots.showDuration,
+          colorMode: slots.colorMode,
+        }))),
+};
diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
index a985742b..1f6ee6d4 100644
--- a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
+++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
@@ -49,10 +49,7 @@ export default {
       if (relations.previousEntryDatetimestamp) {
         parts.push('withDate');
         options.date =
-          relations.previousEntryDatetimestamp.slots({
-            style: 'full',
-            tooltip: true,
-          });
+          relations.previousEntryDatetimestamp.slot('style', 'full');
       }
 
       entryLines.push(language.$(...parts, options));
@@ -67,10 +64,7 @@ export default {
       if (relations.nextEntryDatetimestamp) {
         parts.push('withDate');
         options.date =
-          relations.nextEntryDatetimestamp.slots({
-            style: 'full',
-            tooltip: true,
-          });
+          relations.nextEntryDatetimestamp.slot('style', 'full');
       }
 
       entryLines.push(language.$(...parts, options));
diff --git a/src/content/dependencies/generateReferencedTracksList.js b/src/content/dependencies/generateReferencedTracksList.js
new file mode 100644
index 00000000..1d566ce9
--- /dev/null
+++ b/src/content/dependencies/generateReferencedTracksList.js
@@ -0,0 +1,29 @@
+export default {
+  relations: (relation, track) => ({
+    previousProductionTrackList:
+      relation('generateNearbyTrackList',
+        track.previousProductionTracks,
+        track,
+        track.artistContribs),
+
+    referencedTrackList:
+      relation('generateNearbyTrackList',
+        track.referencedTracks,
+        track,
+        []),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul', {[html.onlyIfContent]: true}, [
+      html.inside(relations.previousProductionTrackList)
+        .map(li => html.inside(li))
+        .map(label =>
+          html.tag('li',
+            language.$('trackList.item.previousProduction',
+              {track: label}))),
+
+      html.inside(relations.referencedTrackList),
+    ]),
+};
+
+
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
index b3fe6239..1415564e 100644
--- a/src/content/dependencies/generateRelativeDatetimestamp.js
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -20,19 +20,11 @@ export default {
       validate: v => v.is('full', 'year'),
       default: 'full',
     },
-
-    tooltip: {
-      type: 'boolean',
-      default: false,
-    },
   },
 
   generate(data, relations, slots, {language}) {
     if (data.equal) {
-      return relations.fallback.slots({
-        style: slots.style,
-        tooltip: slots.tooltip,
-      });
+      return relations.fallback.slot('style', slots.style);
     }
 
     return relations.template.slots({
@@ -44,15 +36,14 @@ export default {
           : null),
 
       tooltip:
-        slots.tooltip &&
-          relations.tooltip.slots({
-            content:
-              language.formatRelativeDate(data.currentDate, data.referenceDate, {
-                considerRoundingDays: true,
-                approximate: true,
-                absolute: slots.style === 'year',
-              }),
-          }),
+        relations.tooltip.slots({
+          content:
+            language.formatRelativeDate(data.currentDate, data.referenceDate, {
+              considerRoundingDays: true,
+              approximate: true,
+              absolute: slots.style === 'year',
+            }),
+        }),
 
       datetime:
         data.currentDate.toISOString(),
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 92e00a41..d3c2d766 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -80,17 +80,20 @@ export default {
     readCommentaryLine:
       relation('generateReadCommentaryLine', track),
 
-    otherReleasesList:
-      relation('generateTrackInfoPageOtherReleasesList', track),
+    otherReleasesLine:
+      relation('generateTrackInfoPageOtherReleasesLine', track),
+
+    previousProductionLine:
+      relation('generateTrackInfoPagePreviousProductionLine', track),
 
     contributorContributionList:
       relation('generateContributionList', track.contributorContribs),
 
     referencedTracksList:
-      relation('generateTrackList', track.referencedTracks, track),
+      relation('generateReferencedTracksList', track),
 
     sampledTracksList:
-      relation('generateTrackList', track.sampledTracks, track),
+      relation('generateNearbyTrackList', track.sampledTracks, track, []),
 
     referencedByTracksList:
       relation('generateTrackListDividedByGroups',
@@ -228,7 +231,11 @@ export default {
                   })),
             ])),
 
-          relations.otherReleasesList,
+          html.tag('p', {[html.onlyIfContent]: true},
+            relations.otherReleasesLine),
+
+          html.tag('p', {[html.onlyIfContent]: true},
+            relations.previousProductionLine),
 
           html.tags([
             relations.contentHeading.clone()
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js
new file mode 100644
index 00000000..1793b73f
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js
@@ -0,0 +1,80 @@
+import {onlyItem, stitchArrays} from '#sugar';
+
+export default {
+  query(track) {
+    const query = {};
+
+    query.singleSingle =
+      onlyItem(
+        track.otherReleases.filter(track => track.album.style === 'single'));
+
+    query.regularReleases =
+      (query.singleSingle
+        ? track.otherReleases.filter(track => track !== query.singleSingle)
+        : track.otherReleases);
+
+    return query;
+  },
+
+  relations: (relation, query, _track) => ({
+    singleLink:
+      (query.singleSingle
+        ? relation('linkTrack', query.singleSingle)
+        : null),
+
+    trackLinks:
+      query.regularReleases
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (query, _track) => ({
+    albumNames:
+      query.regularReleases
+        .map(track => track.album.name),
+
+    albumColors:
+      query.regularReleases
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.alsoReleased', capsule =>
+      language.encapsulate(capsule, workingCapsule => {
+        const workingOptions = {};
+
+        let any = false;
+
+        const albumList =
+          language.formatConjunctionList(
+            stitchArrays({
+              trackLink: relations.trackLinks,
+              albumName: data.albumNames,
+              albumColor: data.albumColors,
+            }).map(({trackLink, albumName, albumColor}) =>
+                trackLink.slots({
+                  content: language.sanitize(albumName),
+                  color: albumColor,
+                })));
+
+        if (!html.isBlank(albumList)) {
+          any = true;
+          workingCapsule += '.onAlbums';
+          workingOptions.albums = albumList;
+        }
+
+        if (relations.singleLink) {
+          any = true;
+          workingCapsule += '.asSingle';
+          workingOptions.single =
+            relations.singleLink.slots({
+              content: language.$(capsule, 'single'),
+            });
+        }
+
+        if (any) {
+          return language.$(workingCapsule, workingOptions);
+        } else {
+          return html.blank();
+        }
+      })),
+};
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
deleted file mode 100644
index ca6c3fb7..00000000
--- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import {onlyItem, stitchArrays} from '#sugar';
-
-export default {
-  query(track) {
-    const query = {};
-
-    query.singleSingle =
-      onlyItem(
-        track.otherReleases.filter(track => track.album.style === 'single'));
-
-    query.regularReleases =
-      (query.singleSingle
-        ? track.otherReleases.filter(track => track !== query.singleSingle)
-        : track.otherReleases);
-
-    return query;
-  },
-
-  relations: (relation, query, _track) => ({
-    singleLink:
-      (query.singleSingle
-        ? relation('linkTrack', query.singleSingle)
-        : null),
-
-    trackLinks:
-      query.regularReleases
-        .map(track => relation('linkTrack', track)),
-  }),
-
-  data: (query, _track) => ({
-    albumNames:
-      query.regularReleases
-        .map(track => track.album.name),
-
-    albumColors:
-      query.regularReleases
-        .map(track => track.album.color),
-  }),
-
-  generate: (data, relations, {html, language}) =>
-    html.tag('p',
-      {[html.onlyIfContent]: true},
-
-      language.encapsulate('releaseInfo.alsoReleased', capsule =>
-        language.encapsulate(capsule, workingCapsule => {
-          const workingOptions = {};
-
-          let any = false;
-
-          const albumList =
-            language.formatConjunctionList(
-              stitchArrays({
-                trackLink: relations.trackLinks,
-                albumName: data.albumNames,
-                albumColor: data.albumColors,
-              }).map(({trackLink, albumName, albumColor}) =>
-                  trackLink.slots({
-                    content: language.sanitize(albumName),
-                    color: albumColor,
-                  })));
-
-          if (!html.isBlank(albumList)) {
-            any = true;
-            workingCapsule += '.onAlbums';
-            workingOptions.albums = albumList;
-          }
-
-          if (relations.singleLink) {
-            any = true;
-            workingCapsule += '.asSingle';
-            workingOptions.single =
-              relations.singleLink.slots({
-                content: language.$(capsule, 'single'),
-              });
-          }
-
-          if (any) {
-            return language.$(workingCapsule, workingOptions);
-          } else {
-            return html.blank();
-          }
-        }))),
-};
diff --git a/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js
new file mode 100644
index 00000000..b2f50cf3
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js
@@ -0,0 +1,37 @@
+import {stitchArrays} from '#sugar';
+import {getKebabCase} from '#wiki-data';
+
+export default {
+  relations: (relation, track) => ({
+    trackLinks:
+      track.followingProductionTracks
+        .map(track => relation('linkTrack', track)),
+
+    albumLinks:
+      track.followingProductionTracks
+        .map(following =>
+          (following.album !== track.album &&
+           getKebabCase(following.name) === getKebabCase(track.name)
+
+            ? relation('linkAlbum', following.album)
+            : null)),
+  }),
+
+  generate: (relations, {language}) =>
+    language.encapsulate('releaseInfo.previousProduction', capsule =>
+      language.$(capsule, {
+        [language.onlyIfOptions]: ['tracks'],
+
+        tracks:
+          stitchArrays({
+            trackLink: relations.trackLinks,
+            albumLink: relations.albumLinks,
+          }).map(({trackLink, albumLink}) =>
+              (albumLink
+                ? language.$(capsule, 'trackOnAlbum', {
+                    track: trackLink,
+                    album: albumLink,
+                  })
+                : trackLink)),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index e30feb23..c259c914 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -1,20 +1,21 @@
 export default {
-  query: (tracks, contextTrack) => ({
-    presentedTracks:
-      (contextTrack
-        ? tracks.map(track =>
-            track.otherReleases.find(({album}) => album === contextTrack.album) ??
-            track)
-        : tracks),
-  }),
-
-  relations: (relation, query, _tracks, _contextTrack) => ({
+  relations: (relation, tracks, contextContributions) => ({
     items:
-      query.presentedTracks
-        .map(track => relation('generateTrackListItem', track, [])),
+      tracks.map(track =>
+        relation('generateTrackListItem', track, contextContributions)),
   }),
 
   slots: {
+    showArtists: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    showDuration: {
+      type: 'boolean',
+      default: false,
+    },
+
     colorMode: {
       validate: v => v.is('none', 'track', 'line'),
       default: 'track',
@@ -27,8 +28,8 @@ export default {
 
       relations.items.map(item =>
         item.slots({
-          showArtists: true,
-          showDuration: false,
+          showArtists: slots.showArtists,
+          showDuration: slots.showDuration,
           colorMode: slots.colorMode,
         }))),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index d7342891..419d7c0f 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -45,7 +45,7 @@ export default {
   relations: (relation, query, sprawl, tracks, contextTrack) => ({
     flatList:
       (empty(sprawl.divideTrackListsByGroups)
-        ? relation('generateTrackList', tracks, contextTrack)
+        ? relation('generateNearbyTrackList', tracks, contextTrack, [])
         : null),
 
     contentHeading:
@@ -57,12 +57,12 @@ export default {
 
     groupedTrackLists:
       query.groupedTracks
-        .map(tracks => relation('generateTrackList', tracks, contextTrack)),
+        .map(tracks => relation('generateNearbyTrackList', tracks, contextTrack, [])),
 
     ungroupedTrackList:
       (empty(query.ungroupedTracks)
         ? null
-        : relation('generateTrackList', query.ungroupedTracks, contextTrack)),
+        : relation('generateNearbyTrackList', query.ungroupedTracks, contextTrack, [])),
   }),
 
   data: (query, _sprawl, _tracks) => ({
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 9de9c3a6..c4b7c21e 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -3,12 +3,18 @@ export default {
     trackLink:
       relation('linkTrack', track),
 
-    credit:
+    contextualCredit:
       relation('generateArtistCredit',
         track.artistContribs,
         contextContributions,
         track.artistText),
 
+    acontextualCredit:
+      relation('generateArtistCredit',
+        track.artistContribs,
+        [],
+        track.artistText),
+
     colorStyle:
       relation('generateColorStyleAttribute', track.color),
 
@@ -27,12 +33,11 @@ export default {
   }),
 
   slots: {
-    // showArtists enables showing artists *at all.* It doesn't take precedence
-    // over behavior which automatically collapses (certain) artists because of
-    // provided context contributions.
+    // true always shows artists, false never does; 'auto' shows only if
+    // the track's artists differ from the given context contributions.
     showArtists: {
-      type: 'boolean',
-      default: true,
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
     },
 
     // If true and the track doesn't have a duration, a missing-duration cue
@@ -72,24 +77,33 @@ export default {
                 : relations.missingDuration);
           }
 
-          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
-
-          relations.credit.setSlots({
-            normalStringKey:
-              artistCapsule + '.by',
-
-            featuringStringKey:
-              artistCapsule + '.featuring',
-
-            normalFeaturingStringKey:
-              artistCapsule + '.by.featuring',
-          });
-
-          if (!html.isBlank(relations.credit)) {
-            workingCapsule += '.withArtists';
-            workingOptions.by =
-              html.tag('span', {class: 'by'},
-                relations.credit);
+          const chosenCredit =
+            (slots.showArtists === true
+              ? relations.acontextualCredit
+           : slots.showArtists === 'auto'
+              ? relations.contextualCredit
+              : null);
+
+          if (chosenCredit) {
+            const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+
+            chosenCredit.setSlots({
+              normalStringKey:
+                artistCapsule + '.by',
+
+              featuringStringKey:
+                artistCapsule + '.featuring',
+
+              normalFeaturingStringKey:
+                artistCapsule + '.by.featuring',
+            });
+
+            if (!html.isBlank(chosenCredit)) {
+              workingCapsule += '.withArtists';
+              workingOptions.by =
+                html.tag('span', {class: 'by'},
+                  chosenCredit);
+            }
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 1b6b08dd..d979b0bc 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -28,6 +28,8 @@ export default {
 
   slots: {
     thumb: {type: 'string'},
+    responsiveThumb: {type: 'boolean', default: false},
+    responsiveSizes: {type: 'string'},
 
     reveal: {type: 'boolean', default: true},
     lazy: {type: 'boolean', default: false},
@@ -199,31 +201,29 @@ export default {
     // so it won't be set if thumbnails aren't available.
     let revealSrc = null;
 
+    let originalDimensions;
+    let availableThumbs;
+    let selectedThumbtack;
+
+    const getThumbSrc = (thumbtack) =>
+      to('thumb.path', mediaSrc.replace(/\.(png|jpg)$/, `.${thumbtack}.jpg`));
+
     // If thumbnails are available *and* being used, calculate thumbSrc,
     // and provide some attributes relevant to the large image overlay.
     if (hasThumbnails && slots.thumb) {
-      const selectedSize =
+      selectedThumbtack =
         getThumbnailEqualOrSmaller(slots.thumb, mediaSrc);
 
-      const mediaSrcJpeg =
-        mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
-
       displaySrc =
-        to('thumb.path', mediaSrcJpeg);
+        getThumbSrc(selectedThumbtack);
 
       if (willReveal) {
-        const miniSize =
-          getThumbnailEqualOrSmaller('mini', mediaSrc);
-
-        const mediaSrcJpeg =
-          mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`);
-
         revealSrc =
-          to('thumb.path', mediaSrcJpeg);
+          getThumbSrc(getThumbnailEqualOrSmaller('mini', mediaSrc));
       }
 
-      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
-      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
+      originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
 
       const fileSize =
         (willLink && mediaSrc
@@ -239,11 +239,54 @@ export default {
         !empty(availableThumbs) &&
           {'data-thumbs':
               availableThumbs
-                .map(([name, size]) => `${name}:${size}`)
+                .map(([tack, size]) => `${tack}:${size}`)
                 .join(' ')},
       ]);
     }
 
+    let displayStaticImg =
+      html.tag('img',
+        imgAttributes,
+        {src: displaySrc});
+
+    if (hasThumbnails && slots.responsiveThumb) responsive: {
+      if (slots.lazy) {
+        logWarn`${'responsiveThumb'} and ${'lazy'} are used together, but not compatible`;
+        break responsive;
+      }
+
+      if (!slots.thumb) {
+        logWarn`${'responsiveThumb'} must be used alongside a default ${'thumb'}`;
+        break responsive;
+      }
+
+      const srcset = [
+        // Never load the original source, which might be a very large
+        // uncompressed file. Bah!
+        /* [originalSrc, `${Math.min(...originalDimensions)}w`], */
+
+        ...availableThumbs.map(([tack, size]) =>
+          [getThumbSrc(tack), `${Math.floor(0.95 * size)}w`]),
+
+        // fallback
+        [displaySrc],
+      ].map(line => line.join(' ')).join(',\n');
+
+      displayStaticImg =
+        html.tag('img',
+          imgAttributes,
+
+          {sizes:
+            (slots.responsiveSizes.match(/(?=(?:,|^))\s*\S/)
+                // slot provided fallback size
+              ? slots.responsiveSizes
+                // default fallback size
+              : slots.responsiveSizes + ',\n' +
+                new Map(availableThumbs).get(selectedThumbtack) + 'px')},
+
+          {srcset});
+    }
+
     if (!displaySrc) {
       return (
         prepare(
@@ -252,10 +295,7 @@ export default {
     }
 
     const images = {
-      displayStatic:
-        html.tag('img',
-          imgAttributes,
-          {src: displaySrc}),
+      displayStatic: displayStaticImg,
 
       displayLazy:
         slots.lazy &&
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
index 7784afe7..166a857d 100644
--- a/src/content/dependencies/linkThing.js
+++ b/src/content/dependencies/linkThing.js
@@ -77,14 +77,15 @@ export default {
     const linkAttributes = slots.attributes;
     const wrapperAttributes = html.attributes();
 
+    const name =
+      relations.name.slot('preferShortName', slots.preferShortName);
+
     const showShortName =
       slots.preferShortName &&
      !data.nameText &&
       data.nameShort &&
       data.nameShort !== data.name;
 
-    const name = relations.name;
-
     const showWikiTooltip =
       (slots.tooltipStyle === 'auto'
         ? showShortName
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 73452cfa..db9f5d99 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -35,8 +35,8 @@ const inlineMarked = new Marked({
   ...commonMarkedOptions,
 
   renderer: {
-    paragraph(text) {
-      return text;
+    paragraph({tokens}) {
+      return this.parser.parseInline(tokens);
     },
   },
 });