« get me outta code hell

content: track listings - 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-07-27 16:32:37 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-07-27 16:32:37 -0300
commit4cbde0e670e5812254509f1f5da39241304dacc0 (patch)
tree911d8b95089cb60e48c68b400c9231f4bac28837
parent6712dd4d178af643e6961fdfad86a66339b722b5 (diff)
content: track listings
-rw-r--r--src/content/dependencies/listTracksByAlbum.js48
-rw-r--r--src/content/dependencies/listTracksByDate.js82
-rw-r--r--src/content/dependencies/listTracksByDuration.js51
-rw-r--r--src/content/dependencies/listTracksByDurationInAlbum.js93
-rw-r--r--src/content/dependencies/listTracksByName.js36
-rw-r--r--src/content/dependencies/listTracksByTimesReferenced.js57
-rw-r--r--src/content/dependencies/listTracksInFlashesByAlbum.js82
-rw-r--r--src/content/dependencies/listTracksInFlashesByFlash.js69
-rw-r--r--src/content/dependencies/listTracksWithExtra.js81
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js9
-rw-r--r--src/content/dependencies/listTracksWithMidiProjectFiles.js9
-rw-r--r--src/content/dependencies/listTracksWithSheetMusicFiles.js9
-rw-r--r--src/listing-spec.js248
-rw-r--r--src/strings-default.json34
14 files changed, 660 insertions, 248 deletions
diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js
new file mode 100644
index 0000000..b240503
--- /dev/null
+++ b/src/content/dependencies/listTracksByAlbum.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: albumData,
+      tracks: albumData.map(album => album.tracks),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      listStyle: 'ordered',
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({track: trackLink}))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
new file mode 100644
index 0000000..25039c3
--- /dev/null
+++ b/src/content/dependencies/listTracksByDate.js
@@ -0,0 +1,82 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  sortAlbumsTracksChronologically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    return {
+      spec,
+
+      chunks:
+        chunkByProperties(
+          sortAlbumsTracksChronologically(trackData.slice()),
+          ['album', 'date']),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.chunks
+          .map(({album}) => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.chunks
+          .map(({chunk}) => chunk
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.chunks
+          .map(({date}) => date),
+
+      rereleases:
+        query.chunks.map(({chunk}) =>
+          chunk.map(track =>
+            track.originalReleaseTrack !== null)),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) => ({
+            album: albumLink,
+            date: language.formatDate(date),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          rereleases: data.rereleases,
+        }).map(({trackLinks, rereleases}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              rerelease: rereleases,
+            }).map(({trackLink, rerelease}) =>
+                (rerelease
+                  ? {track: trackLink, stringsKey: 'rerelease'}
+                  : {track: trackLink}))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js
new file mode 100644
index 0000000..329ada5
--- /dev/null
+++ b/src/content/dependencies/listTracksByDuration.js
@@ -0,0 +1,51 @@
+import {stitchArrays} from '../../util/sugar.js';
+import {filterByCount, sortAlphabetically, sortByCount} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlphabetically(trackData.slice());
+    const durations = tracks.map(track => track.duration);
+
+    filterByCount(tracks, durations);
+    sortByCount(tracks, durations, {greatestFirst: true});
+
+    return {spec, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            track: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js
new file mode 100644
index 0000000..b5a80a7
--- /dev/null
+++ b/src/content/dependencies/listTracksByDurationInAlbum.js
@@ -0,0 +1,93 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  filterByCount,
+  filterMultipleArrays,
+  sortByCount,
+  sortChronologically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const durations =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.duration));
+
+    // Filter out tracks without any duration.
+    // Sort at the same time, to avoid redundantly stitching again later.
+    const stitched = stitchArrays({tracks, durations});
+    for (const {tracks, durations} of stitched) {
+      filterByCount(tracks, durations);
+      sortByCount(tracks, durations, {greatestFirst: true});
+    }
+
+    // Filter out albums which don't have at least two (remaining) tracks.
+    // If the album only has one track in the first place, or if only one
+    // has any duration, then there aren't any comparisons to be made and
+    // it just takes up space on the listing page.
+    const numTracks = tracks.map(tracks => tracks.length);
+    filterMultipleArrays(albums, tracks, durations, numTracks,
+      (album, tracks, durations, numTracks) =>
+        numTracks >= 2);
+
+    return {spec, albums, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          durations: data.durations,
+        }).map(({trackLinks, durations}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              duration: durations,
+            }).map(({trackLink, duration}) => ({
+                track: trackLink,
+                duration: language.formatDuration(duration),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js
new file mode 100644
index 0000000..dd989e9
--- /dev/null
+++ b/src/content/dependencies/listTracksByName.js
@@ -0,0 +1,36 @@
+import {sortAlphabetically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    return {
+      spec,
+      tracks: sortAlphabetically(trackData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        relations.trackLinks
+          .map(link => ({track: link})),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js
new file mode 100644
index 0000000..64d762d
--- /dev/null
+++ b/src/content/dependencies/listTracksByTimesReferenced.js
@@ -0,0 +1,57 @@
+import {stitchArrays} from '../../util/sugar.js';
+
+import {
+  filterByCount,
+  sortAlbumsTracksChronologically,
+  sortByCount,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlbumsTracksChronologically(trackData.slice());
+    const timesReferenced = tracks.map(track => track.referencedByTracks.length);
+
+    filterByCount(tracks, timesReferenced);
+    sortByCount(tracks, timesReferenced, {greatestFirst: true});
+
+    return {spec, tracks, timesReferenced};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      timesReferenced: query.timesReferenced,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          timesReferenced: data.timesReferenced,
+        }).map(({link, timesReferenced}) => ({
+            track: link,
+            timesReferenced:
+              language.countTimesReferenced(timesReferenced, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js
new file mode 100644
index 0000000..f2340ea
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByAlbum.js
@@ -0,0 +1,82 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+import {filterMultipleArrays, sortChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const flashes =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.featuredInFlashes));
+
+    // Filter out tracks that aren't featured in any flashes.
+    // This listing doesn't perform any sorting within albums.
+    const stitched = stitchArrays({tracks, flashes});
+    for (const {tracks, flashes} of stitched) {
+      filterMultipleArrays(tracks, flashes,
+        (tracks, flashes) => !empty(flashes));
+    }
+
+    // Filter out albums which don't have at least one remaining track.
+    filterMultipleArrays(albums, tracks, flashes,
+      (album, tracks, _flashes) => !empty(tracks));
+
+    return {spec, albums, tracks, flashes};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      flashLinks:
+        query.flashes
+          .map(flashesByAlbum => flashesByAlbum
+            .map(flashesByTrack => flashesByTrack
+              .map(flash => relation('linkFlash', flash)))),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          flashLinks: relations.flashLinks,
+        }).map(({trackLinks, flashLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              flashLinks: flashLinks,
+            }).map(({trackLink, flashLinks}) => ({
+                track: trackLink,
+                flashes: language.formatConjunctionList(flashLinks),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js
new file mode 100644
index 0000000..70edcd3
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByFlash.js
@@ -0,0 +1,69 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+import {sortFlashesChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({flashData}) {
+    return {flashData};
+  },
+
+  query({flashData}, spec) {
+    const flashes = sortFlashesChronologically(
+      flashData
+        .filter(flash => !empty(flash.featuredTracks)));
+
+    const tracks =
+      flashes.map(album => album.featuredTracks);
+
+    const albums =
+      tracks.map(tracks =>
+        tracks.map(track => track.album));
+
+    return {spec, flashes, tracks, albums};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      flashLinks:
+        query.flashes
+          .map(flash => relation('linkFlash', flash)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      albumLinks:
+        query.albums
+          .map(albums => albums
+            .map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.flashLinks
+          .map(flashLink => ({flash: flashLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          albumLinks: relations.albumLinks,
+        }).map(({trackLinks, albumLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              albumLink: albumLinks,
+            }).map(({trackLink, albumLink}) => ({
+                track: trackLink,
+                album: albumLink,
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js
new file mode 100644
index 0000000..6287813
--- /dev/null
+++ b/src/content/dependencies/listTracksWithExtra.js
@@ -0,0 +1,81 @@
+import {empty, stitchArrays} from '../../util/sugar.js';
+import {filterMultipleArrays, sortChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query(sprawl, spec, property, valueMode) {
+    const albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const tracks =
+      albums
+        .map(album =>
+          album.tracks
+            .filter(track => {
+              switch (valueMode) {
+                case 'truthy': return !!track[property];
+                case 'array': return !empty(track[property]);
+                default: return false;
+              }
+            }));
+
+    filterMultipleArrays(albums, tracks,
+      (album, tracks) => !empty(tracks));
+
+    return {spec, albums, tracks};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums.map(album => album.date),
+    };
+  },
+
+  slots: {
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) => ({
+            album: albumLink,
+            date: language.formatDate(date),
+          })),
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({
+              track: trackLink.slot('hash', slots.hash),
+            }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
new file mode 100644
index 0000000..a13a76f
--- /dev/null
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+
+  generate: (relations) =>
+    relations.page,
+};
diff --git a/src/content/dependencies/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js
new file mode 100644
index 0000000..418af4c
--- /dev/null
+++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'midi-project-files'),
+};
diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js
new file mode 100644
index 0000000..0c6761e
--- /dev/null
+++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'sheet-music-files'),
+};
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 4dea3b3..9ca7574 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -131,286 +131,72 @@ listingSpec.push({
 listingSpec.push({
   directory: 'tracks/by-name',
   stringsKey: 'listTracks.byName',
-
-  data: ({wikiData: {trackData}}) =>
-    sortAlphabetically(trackData.slice()),
-
-  row: (track, {language, link}) =>
-    language.$('listingPage.listTracks.byName.item', {
-      track: link.track(track),
-    }),
+  contentFunction: 'listTracksByName',
 });
 
 listingSpec.push({
   directory: 'tracks/by-album',
   stringsKey: 'listTracks.byAlbum',
-
-  data: ({wikiData: {albumData}}) =>
-    albumData.map(album => ({
-      album,
-      tracks: album.tracks,
-    })),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({album, tracks}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listTracks.byAlbum.album', {
-            album: link.album(album),
-          })),
-
-        html.tag('dd',
-          html.tag('ol',
-            tracks.map(track =>
-              html.tag('li',
-                language.$('listingPage.listTracks.byAlbum.track', {
-                  track: link.track(track),
-                }))))),
-      ])),
+  contentFunction: 'listTracksByAlbum',
 });
 
 listingSpec.push({
   directory: 'tracks/by-date',
   stringsKey: 'listTracks.byDate',
-
-  data: ({wikiData: {albumData}}) =>
-    chunkByProperties(
-      sortByDate(
-        sortChronologically(albumData)
-          .flatMap(album => album.tracks)
-          .filter(track => track.date)),
-      ['album', 'date']),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({album, date, chunk: tracks}) => [
-        html.tag('dt',
-          language.$('listingPage.listTracks.byDate.album', {
-            album: link.album(album),
-            date: language.formatDate(date),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            tracks.map(track =>
-              track.originalReleaseTrack
-                ? html.tag('li',
-                    {class: 'rerelease'},
-                    language.$('listingPage.listTracks.byDate.track.rerelease', {
-                      track: link.track(track),
-                    }))
-                : html.tag('li',
-                    language.$('listingPage.listTracks.byDate.track', {
-                      track: link.track(track),
-                    }))))),
-      ])),
+  contentFunction: 'listTracksByDate',
 });
 
 listingSpec.push({
   directory: 'tracks/by-duration',
   stringsKey: 'listTracks.byDuration',
-
-  data: ({wikiData: {trackData}}) =>
-    trackData
-      .map(track => ({
-        track,
-        duration: track.duration
-      }))
-      .filter(({duration}) => duration > 0)
-      .sort((a, b) => b.duration - a.duration),
-
-  row: ({track, duration}, {language, link}) =>
-    language.$('listingPage.listTracks.byDuration.item', {
-      track: link.track(track),
-      duration: language.formatDuration(duration),
-    }),
+  contentFunction: 'listTracksByDuration',
 });
 
 listingSpec.push({
   directory: 'tracks/by-duration-in-album',
   stringsKey: 'listTracks.byDurationInAlbum',
-
-  data: ({wikiData: {albumData}}) =>
-    albumData.map(album => ({
-      album,
-      tracks: album.tracks
-        .slice()
-        .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)),
-    })),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({album, tracks}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listTracks.byDurationInAlbum.album', {
-            album: link.album(album),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            tracks.map(track =>
-              html.tag('li',
-                language.$('listingPage.listTracks.byDurationInAlbum.track', {
-                  track: link.track(track),
-                  duration: language.formatDuration(track.duration ?? 0),
-                }))))),
-      ])),
+  contentFunction: 'listTracksByDurationInAlbum',
 });
 
 listingSpec.push({
   directory: 'tracks/by-times-referenced',
   stringsKey: 'listTracks.byTimesReferenced',
-
-  data: ({wikiData: {trackData}}) =>
-    trackData
-      .map(track => ({
-        track,
-        timesReferenced: track.referencedByTracks.length,
-      }))
-      .filter(({timesReferenced}) => timesReferenced)
-      .sort((a, b) => b.timesReferenced - a.timesReferenced),
-
-  row: ({track, timesReferenced}, {language, link}) =>
-    language.$('listingPage.listTracks.byTimesReferenced.item', {
-      track: link.track(track),
-      timesReferenced: language.countTimesReferenced(timesReferenced, {
-        unit: true,
-      }),
-    }),
+  contentFunction: 'listTracksByTimesReferenced',
 });
 
 listingSpec.push({
   directory: 'tracks/in-flashes/by-album',
   stringsKey: 'listTracks.inFlashes.byAlbum',
+  contentFunction: 'listTracksInFlashesByAlbum',
   featureFlag: 'enableFlashesAndGames',
-
-  data: ({wikiData: {trackData}}) =>
-    chunkByProperties(
-      trackData.filter(t => !empty(t.featuredInFlashes)),
-      ['album']),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({album, chunk: tracks}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
-            album: link.album(album),
-            date: language.formatDate(album.date),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            tracks.map(track =>
-              html.tag('li',
-                language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
-                  track: link.track(track),
-                  flashes: language.formatConjunctionList(
-                    track.featuredInFlashes.map(link.flash)),
-                }))))),
-      ])),
 });
 
 listingSpec.push({
   directory: 'tracks/in-flashes/by-flash',
   stringsKey: 'listTracks.inFlashes.byFlash',
+  contentFunction: 'listTracksInFlashesByFlash',
   featureFlag: 'enableFlashesAndGames',
-
-  data: ({wikiData: {flashData}}) =>
-    sortFlashesChronologically(flashData.slice())
-      .map(flash => ({
-        flash,
-        tracks: flash.featuredTracks,
-      })),
-
-  html: (data, {html, language, link}) =>
-    html.tag('dl',
-      data.flatMap(({flash, tracks}) => [
-        html.tag('dt',
-          {class: ['content-heading']},
-          language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
-            flash: link.flash(flash),
-            date: language.formatDate(flash.date),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            tracks.map(track =>
-              html.tag('li',
-                language.$('listingPage.listTracks.inFlashes.byFlash.track', {
-                  track: link.track(track),
-                  album: link.album(track.album),
-                }))))),
-      ])),
 });
 
-function listTracksWithProperty(property, {
-  directory,
-  stringsKey,
-  seeAlso,
-  hash = '',
-}) {
-  return {
-    directory,
-    stringsKey,
-    seeAlso,
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(track => {
-            const value = track[property];
-            if (!value) return false;
-            if (Array.isArray(value)) {
-              return !empty(value);
-            }
-            return true;
-          }),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$(`listingPage.${stringsKey}.album`, {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$(`listingPage.${stringsKey}.track`, {
-                    track: link.track(track, {hash}),
-                  }))))),
-        ])),
-  };
-}
-
-listingSpec.push(listTracksWithProperty('lyrics', {
+listingSpec.push({
   directory: 'tracks/with-lyrics',
   stringsKey: 'listTracks.withLyrics',
-}));
+  contentFunction: 'listTracksWithLyrics',
+});
 
-listingSpec.push(listTracksWithProperty('sheetMusicFiles', {
+listingSpec.push({
   directory: 'tracks/with-sheet-music-files',
   stringsKey: 'listTracks.withSheetMusicFiles',
-  hash: 'sheet-music-files',
+  contentFunction: 'listTracksWithSheetMusicFiles',
   seeAlso: ['all-sheet-music-files'],
-}));
+});
 
-listingSpec.push(listTracksWithProperty('midiProjectFiles', {
+listingSpec.push({
   directory: 'tracks/with-midi-project-files',
   stringsKey: 'listTracks.withMidiProjectFiles',
-  hash: 'midi-project-files',
+  contentFunction: 'listTracksWithMidiProjectFiles',
   seeAlso: ['all-midi-project-files'],
-}));
+});
 
 listingSpec.push({
   directory: 'tags/by-name',
diff --git a/src/strings-default.json b/src/strings-default.json
index df9945c..2d82938 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -407,43 +407,43 @@
   "listingPage.listTracks.byName.item": "{TRACK}",
   "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
   "listingPage.listTracks.byAlbum.title.short": "...by Album",
-  "listingPage.listTracks.byAlbum.album": "{ALBUM}",
-  "listingPage.listTracks.byAlbum.track": "{TRACK}",
+  "listingPage.listTracks.byAlbum.chunk.title": "{ALBUM}",
+  "listingPage.listTracks.byAlbum.chunk.item": "{TRACK}",
   "listingPage.listTracks.byDate.title": "Tracks - by Date",
   "listingPage.listTracks.byDate.title.short": "...by Date",
-  "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.byDate.track": "{TRACK}",
-  "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)",
+  "listingPage.listTracks.byDate.chunk.title": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.byDate.chunk.item": "{TRACK}",
+  "listingPage.listTracks.byDate.chunk.item.rerelease": "{TRACK} (re-release)",
   "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
   "listingPage.listTracks.byDuration.title.short": "...by Duration",
   "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
   "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
   "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
-  "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}",
-  "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})",
+  "listingPage.listTracks.byDurationInAlbum.chunk.title": "{ALBUM}",
+  "listingPage.listTracks.byDurationInAlbum.chunk.item": "{TRACK} ({DURATION})",
   "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
   "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced",
   "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
   "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
   "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)",
-  "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})",
+  "listingPage.listTracks.inFlashes.byAlbum.chunk.title": "{ALBUM}",
+  "listingPage.listTracks.inFlashes.byAlbum.chunk.item": "{TRACK} (in {FLASHES})",
   "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
   "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
-  "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})",
-  "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})",
+  "listingPage.listTracks.inFlashes.byFlash.chunk.title": "{FLASH}",
+  "listingPage.listTracks.inFlashes.byFlash.chunk.item": "{TRACK} (from {ALBUM})",
   "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
   "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
-  "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withLyrics.track": "{TRACK}",
+  "listingPage.listTracks.withLyrics.chunk.title": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withLyrics.chunk.item": "{TRACK}",
   "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files",
   "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files",
-  "listingPage.listTracks.withSheetMusicFiles.album": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withSheetMusicFiles.track": "{TRACK}",
+  "listingPage.listTracks.withSheetMusicFiles.chunk.title": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withSheetMusicFiles.chunk.item": "{TRACK}",
   "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files",
   "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files",
-  "listingPage.listTracks.withMidiProjectFiles.album": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withMidiProjectFiles.track": "{TRACK}",
+  "listingPage.listTracks.withMidiProjectFiles.chunk.title": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withMidiProjectFiles.chunk.item": "{TRACK}",
   "listingPage.listTags.byName.title": "Tags - by Name",
   "listingPage.listTags.byName.title.short": "...by Name",
   "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",