« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/content/dependencies/generateListingPage.js37
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js89
-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/page/listing.js265
-rw-r--r--src/strings-default.json34
17 files changed, 785 insertions, 514 deletions
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index c01d3b35..c1666599 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -65,12 +65,35 @@ export default {
     chunkTitles: {validate: v => v.strictArrayOf(v.isObject)},
     chunkRows: {validate: v => v.strictArrayOf(v.isObject)},
 
+    listStyle: {
+      validate: v => v.is('ordered', 'unordered'),
+      default: 'unordered',
+    },
+
     content: {type: 'html'},
   },
 
   generate(data, relations, slots, {html, language}) {
+    const listTag =
+      (slots.listStyle === 'ordered'
+        ? 'ol'
+        : 'ul');
+
+    const formatListingString = (contextStringsKey, options = {}) => {
+      const baseStringsKey = `listingPage.${data.stringsKey}`;
+
+      const parts = [baseStringsKey, contextStringsKey];
+
+      if (options.stringsKey) {
+        parts.push(options.stringsKey);
+        delete options.stringsKey;
+      }
+
+      return language.formatString(parts.join('.'), options);
+    };
+
     return relations.layout.slots({
-      title: language.$(`listingPage.${data.stringsKey}.title`),
+      title: formatListingString('title'),
       headingMode: 'sticky',
 
       mainContent: [
@@ -99,10 +122,10 @@ export default {
             })),
 
         slots.type === 'rows' &&
-          html.tag('ul',
+          html.tag(listTag,
             slots.rows.map(row =>
               html.tag('li',
-                language.$(`listingPage.${data.stringsKey}.item`, row)))),
+                formatListingString('item', row)))),
 
         slots.type === 'chunks' &&
           html.tag('dl',
@@ -114,15 +137,15 @@ export default {
                   .clone()
                   .slots({
                     tag: 'dt',
-                    title:
-                      language.$(`listingPage.${data.stringsKey}.chunk.title`, title),
+                    title: formatListingString('chunk.title', title),
                   }),
 
                 html.tag('dd',
-                  html.tag('ul',
+                  html.tag(listTag,
                     rows.map(row =>
                       html.tag('li',
-                        language.$(`listingPage.${data.stringsKey}.chunk.item`, row))))),
+                        {class: row.stringsKey === 'rerelease' && 'rerelease'},
+                        formatListingString('chunk.item', row))))),
               ])),
 
         slots.type === 'custom' &&
diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js
new file mode 100644
index 00000000..6887c6c2
--- /dev/null
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -0,0 +1,89 @@
+import {getTotalDuration} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generateListingSidebar',
+    'generatePageLayout',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData, trackData, wikiInfo}) {
+    return {
+      wikiName: wikiInfo.name,
+      numTracks: trackData.length,
+      numAlbums: albumData.length,
+      totalDuration: getTotalDuration(trackData),
+    };
+  },
+
+  relations(relation) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', null);
+
+    relations.list =
+      relation('generateListingIndexList', null);
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      wikiName: sprawl.wikiName,
+      numTracks: sprawl.numTracks,
+      numAlbums: sprawl.numAlbums,
+      totalDuration: sprawl.totalDuration,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title: language.$('listingIndex.title'),
+
+      headingMode: 'static',
+
+      mainContent: [
+        html.tag('p',
+          language.$('listingIndex.infoLine', {
+            wiki: data.wikiName,
+
+            tracks:
+              html.tag('b',
+                language.countTracks(data.numTracks, {unit: true})),
+
+            albums:
+              html.tag('b',
+                language.countAlbums(data.numAlbums, {unit: true})),
+
+            duration:
+              html.tag('b',
+                language.formatDuration(data.totalDuration, {
+                  approximate: true,
+                  unit: true,
+                })),
+          })),
+
+        html.tag('hr'),
+
+        html.tag('p',
+          language.$('listingIndex.exploreList')),
+
+        relations.list.slot('mode', 'content'),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+
+      ...relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js
new file mode 100644
index 00000000..b2405034
--- /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 00000000..25039c3e
--- /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 00000000..329ada5b
--- /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 00000000..b5a80a71
--- /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 00000000..dd989e98
--- /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 00000000..64d762df
--- /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 00000000..f2340eab
--- /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 00000000..70edcd32
--- /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 00000000..62878132
--- /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 00000000..a13a76f0
--- /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 00000000..418af4c2
--- /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 00000000..0c6761eb
--- /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 4dea3b3e..9ca75747 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/page/listing.js b/src/page/listing.js
index 1db7aa7b..64db413d 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -36,265 +36,12 @@ export function pathsForTarget(listing) {
   ];
 }
 
-/*
-export function condition({wikiData}) {
-  return wikiData.wikiInfo.enableListings;
-}
-
-export function targets({wikiData}) {
-  return wikiData.listingSpec;
-}
-
-export function write(listing, {wikiData}) {
-  if (listing.condition && !listing.condition({wikiData})) {
-    return null;
-  }
-
-  const {listingSpec, listingTargetSpec} = wikiData;
-
-  const getTitleKey = l => `listingPage.${l.stringsKey}.title`;
-
-  const data = listing.data ? listing.data({wikiData}) : null;
-
-  // TODO: Invalid listing directories filtered here aren't warned about anywhere.
-  const seeAlso =
-    listing.seeAlso
-     ?.map(directory => listingSpec.find(l => l.directory === directory))
-      .filter(Boolean)
-    ?? null;
-
-  const currentTarget = listingTargetSpec.find(({listings}) => listings.includes(listing));
-  const currentListing = listing;
-
-  const page = {
-    type: 'page',
-    path: ['listing', listing.directory],
-    page: (opts) => {
-      const {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-      } = opts;
-
-      return {
-        title: language.$(getTitleKey(listing)),
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            currentTarget.listings.length > 1 &&
-              html.tag('p',
-                language.$('listingPage.listingsFor', {
-                  target: currentTarget.title({language}),
-                  listings:
-                    language.formatUnitList(
-                      currentTarget.listings.map(listing =>
-                        html.tag('span',
-                          {class: listing === currentListing ? 'current' : ''},
-                          link.listing(listing, {
-                            class: 'nowrap',
-                            text: language.$(getTitleKey(listing) + '.short'),
-                          })))),
-                })),
-
-            !empty(seeAlso) &&
-              html.tag('p',
-                language.$('listingPage.seeAlso', {
-                  listings:
-                    language.formatUnitList(
-                      seeAlso.map(listing =>
-                        link.listing(listing, {
-                          text: language.$(getTitleKey(listing)),
-                        }))),
-                })),
-
-            ...html.fragment(
-              listing.html &&
-                (listing.data
-                  ? listing.html(data, opts)
-                  : listing.html(opts))),
-
-            listing.row &&
-              html.tag('ul',
-                data.map((item) =>
-                  html.tag('li',
-                    listing.row(item, opts)))),
-          ],
-        },
-
-        sidebarLeft: {
-          content: generateSidebarForListings(listing, {
-            getLinkThemeString,
-            html,
-            language,
-            link,
-            wikiData,
-          }),
-        },
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-            {toCurrentPage: true},
-          ],
-        },
-      };
-    },
-  };
-
-  return [page];
-}
-
-export function writeTargetless({wikiData}) {
-  const {albumData, trackData, wikiInfo} = wikiData;
-
-  const totalDuration = getTotalDuration(trackData);
-
-  const page = {
-    type: 'page',
-    path: ['listingIndex'],
-    page: ({
-      getLinkThemeString,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('listingIndex.title'),
-
-      main: {
-        headingMode: 'static',
-
-        content: [
-          html.tag('p',
-            language.$('listingIndex.infoLine', {
-              wiki: wikiInfo.name,
-              tracks: html.tag('b',
-                language.countTracks(trackData.length, {
-                  unit: true,
-                })),
-              albums: html.tag('b',
-                language.countAlbums(albumData.length, {
-                  unit: true,
-                })),
-              duration: html.tag('b',
-                language.formatDuration(totalDuration, {
-                  approximate: true,
-                  unit: true,
-                })),
-            })),
-
-          html.tag('hr'),
-
-          html.tag('p',
-            language.$('listingIndex.exploreList')),
-
-          ...html.fragment(
-            generateLinkIndexForListings(null, false, {
-              html,
-              link,
-              language,
-              wikiData,
-            })),
-        ],
-      },
-
-      sidebarLeft: {
-        content: generateSidebarForListings(null, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          wikiData,
-        }),
-      },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
-}
-
-// Utility functions
-
-function generateSidebarForListings(currentListing, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
+export function pathsTargetless() {
   return [
-    html.tag('h1',
-      link.listingIndex('', {
-        text: language.$('listingIndex.title'),
-      })),
-
-    ...html.fragment(
-      generateLinkIndexForListings(currentListing, true, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      })),
+    {
+      type: 'page',
+      path: ['listingIndex'],
+      contentFunction: {name: 'generateListingsIndexPage'},
+    },
   ];
 }
-
-function generateLinkIndexForListings(currentListing, forSidebar, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
-  const {listingTargetSpec, wikiInfo} = wikiData;
-
-  const filteredByCondition = listingTargetSpec
-    .map(({listings, ...rest}) => ({
-      ...rest,
-      listings: listings.filter(({condition: c}) => !c || c({wikiData})),
-    }))
-    .filter(({listings}) => !empty(listings));
-
-  const genUL = (listings) =>
-    html.tag('ul',
-      listings.map((listing) =>
-        html.tag('li',
-          {class: [listing === currentListing && 'current']},
-          link.listing(listing, {
-            text: language.$(`listingPage.${listing.stringsKey}.title.short`),
-          }))));
-
-  return forSidebar
-    ? filteredByCondition.map(({title, listings}) =>
-        html.tag('details',
-          {
-            open: listings.includes(currentListing),
-            class: listings.includes(currentListing) && 'current',
-          },
-          [
-            html.tag('summary',
-              {style: getLinkThemeString(wikiInfo.color)},
-              html.tag('span',
-                {class: 'group-name'},
-                title({language}))),
-            genUL(listings),
-          ]))
-    : html.tag('dl',
-        filteredByCondition.flatMap(({title, listings}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            title({language})),
-          html.tag('dd',
-            genUL(listings)),
-        ]));
-}
-*/
diff --git a/src/strings-default.json b/src/strings-default.json
index df9945cf..2d829388 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})",