« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js44
-rw-r--r--src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js7
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js89
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js4
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js16
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js6
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js48
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js73
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js20
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js25
-rw-r--r--src/content/dependencies/generateCoverArtwork.js15
-rw-r--r--src/content/dependencies/generateCoverGrid.js12
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js91
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js74
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js194
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js21
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js17
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js7
-rw-r--r--src/content/dependencies/generateFlashSidebar.js236
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js2
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js35
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js6
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js44
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js99
-rw-r--r--src/content/dependencies/generatePageLayout.js53
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js9
-rw-r--r--src/content/dependencies/generateTrackList.js59
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js2
-rw-r--r--src/content/dependencies/image.js134
-rw-r--r--src/content/dependencies/index.js24
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js14
-rw-r--r--src/content/dependencies/linkFlashAct.js14
-rw-r--r--src/content/dependencies/linkGroupDynamically.js14
-rw-r--r--src/content/dependencies/linkTemplate.js39
-rw-r--r--src/content/dependencies/linkThing.js10
-rw-r--r--src/content/dependencies/listArtTagNetwork.js1
-rw-r--r--src/content/dependencies/listTracksWithExtra.js12
-rw-r--r--src/content/dependencies/transformContent.js5
39 files changed, 1033 insertions, 546 deletions
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index de619251..3ad1549e 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,10 +2,13 @@ import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
+    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateColorStyleVariables',
     'generateContentHeading',
+    'generateTrackCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
     'linkTrack',
@@ -21,7 +24,7 @@ export default {
       relation('generatePageLayout');
 
     relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album);
+      relation('generateAlbumStyleRules', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -30,6 +33,11 @@ export default {
       relation('generateAlbumNavAccent', album, null);
 
     if (album.commentary) {
+      if (album.hasCoverArt) {
+        relations.albumCommentaryCover =
+          relation('generateAlbumCoverArtwork', album);
+      }
+
       relations.albumCommentaryContent =
         relation('transformContent', album.commentary);
     }
@@ -46,6 +54,13 @@ export default {
       tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
+    relations.trackCommentaryCovers =
+      tracksWithCommentary
+        .map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('generateTrackCoverArtwork', track)
+            : null));
+
     relations.trackCommentaryContent =
       tracksWithCommentary
         .map(track => relation('transformContent', track.commentary));
@@ -57,6 +72,13 @@ export default {
             ? null
             : relation('generateColorStyleVariables')));
 
+    relations.sidebarAlbumLink =
+      relation('linkAlbum', album);
+
+    relations.sidebarTrackSections =
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
+
     return relations;
   },
 
@@ -129,6 +151,9 @@ export default {
               {class: ['content-heading']},
               language.$('albumCommentaryPage.entry.title.albumCommentary')),
 
+            relations.albumCommentaryCover
+              ?.slots({mode: 'commentary'}),
+
             html.tag('blockquote',
               relations.albumCommentaryContent),
           ],
@@ -137,15 +162,19 @@ export default {
             heading: relations.trackCommentaryHeadings,
             link: relations.trackCommentaryLinks,
             directory: data.trackCommentaryDirectories,
+            cover: relations.trackCommentaryCovers,
             content: relations.trackCommentaryContent,
             colorVariables: relations.trackCommentaryColorVariables,
             color: data.trackCommentaryColors,
-          }).map(({heading, link, directory, content, colorVariables, color}) => [
+          }).map(({heading, link, directory, cover, content, colorVariables, color}) => [
               heading.slots({
                 tag: 'h3',
                 id: directory,
                 title: link,
               }),
+
+              cover?.slots({mode: 'commentary'}),
+
               html.tag('blockquote',
                 (color
                   ? {style: colorVariables.slot('color', color).content}
@@ -170,6 +199,17 @@ export default {
               }),
           },
         ],
+
+        leftSidebarStickyMode: 'column',
+        leftSidebarContent: [
+          html.tag('h1', relations.sidebarAlbumLink),
+          relations.sidebarTrackSections.map(section =>
+            section.slots({
+              anchor: true,
+              open: true,
+              mode: 'commentary',
+            })),
+        ],
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
new file mode 100644
index 00000000..ad99cb87
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
@@ -0,0 +1,7 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('p', {class: 'quick-info'},
+      language.$('albumGalleryPage.noTrackArtworksLine')),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index 68b56bd9..f61b1983 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -3,8 +3,10 @@ import {compareArrays, stitchArrays} from '#sugar';
 export default {
   contentDependencies: [
     'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryNoTrackArtworksLine',
     'generateAlbumGalleryStatsLine',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumStyleRules',
     'generateCoverGrid',
     'generatePageLayout',
@@ -51,7 +53,7 @@ export default {
       relation('generatePageLayout');
 
     relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album);
+      relation('generateAlbumStyleRules', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -59,9 +61,17 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', album, null);
 
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
     relations.statsLine =
       relation('generateAlbumGalleryStatsLine', album);
 
+    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
+      relations.noTrackArtworksLine =
+        relation('generateAlbumGalleryNoTrackArtworksLine');
+    }
+
     if (query.coverArtistsForAllTracks) {
       relations.coverArtistsLine =
         relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
@@ -70,15 +80,25 @@ export default {
     relations.coverGrid =
       relation('generateCoverGrid');
 
-    relations.links =
-      album.tracks.map(track =>
-        relation('linkTrack', track));
+    relations.links = [
+      relation('linkAlbum', album),
 
-    relations.images =
-      album.tracks.map(track =>
-        (track.hasUniqueCoverArt
-          ? relation('image', track.artTags)
-          : relation('image')));
+      ...
+        album.tracks
+          .map(track => relation('linkTrack', track)),
+    ];
+
+    relations.images = [
+      (album.hasCoverArt
+        ? relation('image', album.artTags)
+        : relation('image')),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('image', track.artTags)
+            : relation('image'))),
+    ];
 
     return relations;
   },
@@ -89,27 +109,41 @@ export default {
     data.name = album.name;
     data.color = album.color;
 
-    data.names =
-      album.tracks.map(track => track.name);
+    data.names = [
+      album.name,
+      ...album.tracks.map(track => track.name),
+    ];
 
-    data.coverArtists =
-      album.tracks.map(track => {
-        if (query.coverArtistsForAllTracks) {
-          return null;
-        }
+    data.coverArtists = [
+      (album.hasCoverArt
+        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        : null),
 
-        if (track.hasUniqueCoverArt) {
-          return track.coverArtistContribs.map(({who: artist}) => artist.name);
-        }
+      ...
+        album.tracks.map(track => {
+          if (query.coverArtistsForAllTracks) {
+            return null;
+          }
 
-        return null;
-      });
+          if (track.hasUniqueCoverArt) {
+            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+          }
+
+          return null;
+        }),
+    ];
 
-    data.paths =
-      album.tracks.map(track =>
-        (track.hasUniqueCoverArt
-          ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-          : null));
+    data.paths = [
+      (album.hasCoverArt
+        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+        : null),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+            : null)),
+    ];
 
     return data;
   },
@@ -131,6 +165,7 @@ export default {
         mainContent: [
           relations.statsLine,
           relations.coverArtistsLine,
+          relations.noTrackArtworksLine,
 
           relations.coverGrid
             .slots({
@@ -172,6 +207,8 @@ export default {
               }),
           },
         ],
+
+        secondaryNav: relations.secondaryNav,
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index ce17ab21..5fe27caf 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -37,14 +37,14 @@ export default {
       relation('generatePageLayout');
 
     relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album);
+      relation('generateAlbumStyleRules', album, null);
 
     relations.socialEmbed =
       relation('generateAlbumSocialEmbed', album);
 
     relations.coverArtistChronologyContributions =
       getChronologyRelations(album, {
-        contributions: album.coverArtistContribs,
+        contributions: album.coverArtistContribs ?? [],
 
         linkArtist: artist => relation('linkArtist', artist),
 
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index c79219bb..7eb1dac0 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -33,10 +33,8 @@ export default {
       }
     }
 
-    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
-      relations.albumGalleryLink =
-        relation('linkAlbumGallery', album);
-    }
+    relations.albumGalleryLink =
+      relation('linkAlbumGallery', album);
 
     if (album.commentary || album.tracks.some(t => t.commentary)) {
       relations.albumCommentaryLink =
@@ -49,6 +47,7 @@ export default {
   data(album, track) {
     return {
       hasMultipleTracks: album.tracks.length > 1,
+      galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt),
       isTrackPage: !!track,
     };
   },
@@ -66,10 +65,11 @@ export default {
     const {content: extraLinks = []} =
       slots.showExtraLinks &&
         {content: [
-          relations.albumGalleryLink?.slots({
-            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-            content: language.$('albumPage.nav.gallery'),
-          }),
+          (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+            relations.albumGalleryLink?.slots({
+              attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+              content: language.$('albumPage.nav.gallery'),
+            }),
 
           relations.albumCommentaryLink?.slots({
             attributes: {class: slots.currentExtra === 'commentary' && 'current'},
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 705dec51..8cf36fa4 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -5,7 +5,7 @@ export default {
     'generateColorStyleVariables',
     'generatePreviousNextLinks',
     'generateSecondaryNav',
-    'linkAlbum',
+    'linkAlbumDynamically',
     'linkGroup',
     'linkTrack',
   ],
@@ -64,14 +64,14 @@ export default {
         query.adjacentGroupInfo
           .map(({previousAlbum}) =>
             (previousAlbum
-              ? relation('linkAlbum', previousAlbum)
+              ? relation('linkAlbumDynamically', previousAlbum)
               : null));
 
       relations.nextAlbumLinks =
         query.adjacentGroupInfo
           .map(({nextAlbum}) =>
             (nextAlbum
-              ? relation('linkAlbum', nextAlbum)
+              ? relation('linkAlbumDynamically', nextAlbum)
               : null));
     }
 
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index 2aca6da1..d3cd37f0 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -33,10 +33,28 @@ export default {
       }
     }
 
+    data.trackDirectories =
+      trackSection.tracks
+        .map(track => track.directory);
+
+    data.tracksAreMissingCommentary =
+      trackSection.tracks
+        .map(track => !track.commentary);
+
     return data;
   },
 
-  generate(data, relations, {getColors, html, language}) {
+  slots: {
+    anchor: {type: 'boolean'},
+    open: {type: 'boolean'},
+
+    mode: {
+      validate: v => v.is('info', 'commentary'),
+      default: 'info',
+    },
+  },
+
+  generate(data, relations, slots, {getColors, html, language}) {
     const sectionName =
       html.tag('span', {class: 'group-name'},
         (data.isDefaultTrackSection
@@ -53,13 +71,28 @@ export default {
       relations.trackLinks.map((trackLink, index) =>
         html.tag('li',
           {
-            class:
+            class: [
               data.includesCurrentTrack &&
               index === data.currentTrackIndex &&
-              'current',
+                'current',
+
+              slots.mode === 'commentary' &&
+              data.tracksAreMissingCommentary[index] &&
+                'no-commentary',
+            ],
           },
           language.$('albumSidebar.trackList.item', {
-            track: trackLink,
+            track:
+              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
+                ? trackLink.slots({
+                    linkless: true,
+                  })
+             : slots.anchor
+                ? trackLink.slots({
+                    anchor: true,
+                    hash: data.trackDirectories[index],
+                  })
+                : trackLink),
           })));
 
     return html.tag('details',
@@ -67,6 +100,11 @@ export default {
         class: data.includesCurrentTrack && 'current',
 
         open: (
+          // Allow forcing open via a template slot.
+          // This isn't exactly janky, but the rest of this function
+          // kind of is when you contextualize it in a template...
+          slots.open ||
+
           // Leave sidebar track sections collapsed on album info page,
           // since there's already a view of the full track listing
           // in the main content area.
@@ -82,7 +120,7 @@ export default {
             (data.hasTrackNumbers
               ? language.$('albumSidebar.trackList.group.withRange', {
                   group: sectionName,
-                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
+                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
                 })
               : language.$('albumSidebar.trackList.group', {
                   group: sectionName,
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
index 1acaea17..c5acf374 100644
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -3,14 +3,13 @@ import {empty} from '#sugar';
 export default {
   extraDependencies: ['to'],
 
-  data(album) {
+  data(album, track) {
     const data = {};
 
     data.hasWallpaper = !empty(album.wallpaperArtistContribs);
     data.hasBanner = !empty(album.bannerArtistContribs);
 
     if (data.hasWallpaper) {
-      data.hasWallpaperStyle = !!album.wallpaperStyle;
       data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
       data.wallpaperStyle = album.wallpaperStyle;
     }
@@ -20,40 +19,54 @@ export default {
       data.bannerStyle = album.bannerStyle;
     }
 
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
     return data;
   },
 
   generate(data, {to}) {
-    const wallpaperPart =
-      (data.hasWallpaper
-        ? [
-            `body::before {`,
-            `    background-image: url("${to(...data.wallpaperPath)}");`,
-            ...(data.hasWallpaperStyle
-              ? data.wallpaperStyle
-                  .split('\n')
-                  .map(line => `    ${line}`)
-              : []),
-            `}`,
-          ]
-        : []);
+    const indent = parts =>
+      (parts ?? [])
+        .filter(Boolean)
+        .join('\n')
+        .split('\n')
+        .map(line => ' '.repeat(4) + line)
+        .join('\n');
 
-    const bannerPart =
-      (data.hasBannerStyle
-        ? [
-            `#banner img {`,
-            ...data.bannerStyle
-              .split('\n')
-              .map(line => `    ${line}`),
-            `}`,
-          ]
+    const rule = (selector, parts) =>
+      (!empty(parts.filter(Boolean))
+        ? [`${selector} {`, indent(parts), `}`]
         : []);
 
-    return [
-      ...wallpaperPart,
-      ...bannerPart,
-    ]
-      .filter(Boolean)
-      .join('\n');
+    const wallpaperRule =
+      data.hasWallpaper &&
+        rule(`body::before`, [
+          `background-image: url("${to(...data.wallpaperPath)}");`,
+          data.wallpaperStyle,
+        ]);
+
+    const bannerRule =
+      data.hasBanner &&
+        rule(`#banner img`, [
+          data.bannerStyle,
+        ]);
+
+    const dataRule =
+      rule(`:root`, [
+        data.albumDirectory &&
+          `--album-directory: ${data.albumDirectory};`,
+        data.trackDirectory &&
+          `--track-directory: ${data.trackDirectory};`,
+      ]);
+
+    return (
+      [wallpaperRule, bannerRule, dataRule]
+        .filter(Boolean)
+        .flat()
+        .join('\n'));
   },
 };
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index f65b47c9..f92712f9 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -1,4 +1,4 @@
-import {compareArrays} from '#sugar';
+import {compareArrays, empty} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -11,9 +11,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.contributionLinks =
-      track.artistContribs
-        .map(contrib => relation('linkContribution', contrib));
+    if (!empty(track.artistContribs)) {
+      relations.contributionLinks =
+        track.artistContribs
+          .map(contrib => relation('linkContribution', contrib));
+    }
 
     relations.trackLink =
       relation('linkTrack', track);
@@ -31,10 +33,12 @@ export default {
     }
 
     data.showArtists =
-      !compareArrays(
-        track.artistContribs.map(c => c.who),
-        album.artistContribs.map(c => c.who),
-        {checkOrder: false});
+      !empty(track.artistContribs) &&
+       (empty(album.artistContribs) ||
+        !compareArrays(
+          track.artistContribs.map(c => c.who),
+          album.artistContribs.map(c => c.who),
+          {checkOrder: false}));
 
     return data;
   },
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index 36f0ebcc..9f99513d 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -5,7 +5,7 @@ export default {
     content: {type: 'html'},
 
     otherArtistLinks: {validate: v => v.strictArrayOf(v.isHTML)},
-    contribution: {type: 'string'},
+    contribution: {type: 'html'},
     rerelease: {type: 'boolean'},
   },
 
@@ -30,7 +30,7 @@ export default {
         options.artists = language.formatConjunctionList(slots.otherArtistLinks);
       }
 
-      if (slots.contribution) {
+      if (!html.isBlank(slots.contribution)) {
         parts.push('withContribution');
         options.contribution = slots.contribution;
       }
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index 0566f713..654f759c 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -1,4 +1,4 @@
-import {accumulateSum, stitchArrays} from '#sugar';
+import {accumulateSum, empty, stitchArrays} from '#sugar';
 
 import {
   chunkByProperties,
@@ -16,7 +16,7 @@ export default {
     'linkTrack',
   ],
 
-  extraDependencies: ['language'],
+  extraDependencies: ['html', 'language'],
 
   query(artist) {
     const tracksAsArtistAndContributor =
@@ -122,11 +122,16 @@ export default {
 
       trackContributions:
         query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .filter(({who}) => who === artist)
-              .filter(({what}) => what)
-              .map(({what}) => what))),
+          chunk
+            .map(({contribs}) =>
+              contribs
+                .filter(({who}) => who === artist)
+                .filter(({what}) => what)
+                .map(({what}) => what))
+            .map(contributions =>
+              (empty(contributions)
+                ? null
+                : contributions))),
 
       trackRereleases:
         query.chunks.map(({chunk}) =>
@@ -134,7 +139,7 @@ export default {
     };
   },
 
-  generate(data, relations, {language}) {
+  generate(data, relations, {html, language}) {
     return relations.chunkedList.slots({
       chunks:
         stitchArrays({
@@ -192,7 +197,9 @@ export default {
                       rerelease,
 
                       contribution:
-                        language.formatUnitList(contribution),
+                        (contribution
+                          ? language.formatUnitList(contribution)
+                          : html.blank()),
 
                       content:
                         (duration
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 4060c6b0..aeba97de 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -32,7 +32,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('primary', 'thumbnail'),
+      validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
     },
   },
@@ -73,6 +73,19 @@ export default {
             square: true,
           });
 
+      case 'commentary':
+        return relations.image
+          .slots({
+            path: slots.path,
+            alt: slots.alt,
+            thumb: 'medium',
+            class: 'commentary-art',
+            reveal: true,
+            link: true,
+            square: true,
+            lazy: true,
+          });
+
       default:
         return html.blank();
     }
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 9822e1ae..5636e4f3 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -2,7 +2,7 @@ import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['generateGridActionLinks'],
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation) {
     return {
@@ -20,7 +20,7 @@ export default {
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html}) {
+  generate(relations, slots, {html, language}) {
     return (
       html.tag('div', {class: 'grid-listing'}, [
         stitchArrays({
@@ -42,8 +42,12 @@ export default {
                       ? slots.lazy
                       : false),
                 }),
-                html.tag('span', {[html.onlyIfContent]: true}, name),
-                html.tag('span', {[html.onlyIfContent]: true}, info),
+
+                html.tag('span', {[html.onlyIfContent]: true},
+                  language.sanitize(name)),
+
+                html.tag('span', {[html.onlyIfContent]: true},
+                  language.sanitize(info)),
               ],
             })),
 
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
new file mode 100644
index 00000000..8eea58bb
--- /dev/null
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -0,0 +1,91 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'generateFlashActNavAccent',
+    'generateFlashActSidebar',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    flashActNavAccent:
+      relation('generateFlashActNavAccent', act),
+
+    sidebar:
+      relation('generateFlashActSidebar', act, null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    coverGridImages:
+      act.flashes
+        .map(_flash => relation('image')),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act) => ({
+    name: act.name,
+    color: act.color,
+
+    flashNames:
+      act.flashes.map(flash => flash.name),
+
+    flashCoverPaths:
+      act.flashes.map(flash =>
+        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
+  }),
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title:
+        language.$('flashPage.title', {
+          flash: new html.Tag(null, null, data.name),
+        }),
+
+      color: data.color,
+      headingMode: 'static',
+
+      mainClasses: ['flash-index'],
+      mainContent: [
+        relations.coverGrid.slots({
+          links: relations.flashLinks,
+          names: data.flashNames,
+          lazy: 6,
+
+          images:
+            stitchArrays({
+              image: relations.coverGridImages,
+              path: data.flashCoverPaths,
+            }).map(({image, path}) =>
+                image.slot('path', path)),
+        }),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.flashIndexLink},
+        {auto: 'current'},
+      ],
+
+      navBottomRowContent: relations.flashActNavAccent,
+
+      ...relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js
new file mode 100644
index 00000000..98504385
--- /dev/null
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -0,0 +1,74 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({flashActData}) {
+    return {flashActData};
+  },
+
+  query(sprawl, flashAct) {
+    // Like with generateFlashNavAccent, don't sort chronologically here.
+    const flashActs =
+      sprawl.flashActData;
+
+    const index = flashActs.indexOf(flashAct);
+
+    const previousFlashAct =
+      (index > 0
+        ? flashActs[index - 1]
+        : null);
+
+    const nextFlashAct =
+      (index < flashActs.length - 1
+        ? flashActs[index + 1]
+        : null);
+
+    return {previousFlashAct, nextFlashAct};
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    if (query.previousFlashAct || query.nextFlashAct) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+
+      relations.previousFlashActLink =
+        (query.previousFlashAct
+          ? relation('linkFlashAct', query.previousFlashAct)
+          : null);
+
+      relations.nextFlashActLink =
+        (query.nextFlashAct
+          ? relation('linkFlashAct', query.nextFlashAct)
+          : null);
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    const {content: previousNextLinks = []} =
+      relations.previousNextLinks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousFlashActLink,
+          nextLink: relations.nextFlashActLink,
+        });
+
+    const allLinks = [
+      ...previousNextLinks,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
+  },
+};
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
new file mode 100644
index 00000000..bd6063c9
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -0,0 +1,194 @@
+import find from '#find';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
+  extraDependencies: ['getColors', 'html', 'language', 'wikiData'],
+
+  // So help me Gog, the flash sidebar is heavily hard-coded.
+
+  sprawl: ({flashActData}) => ({flashActData}),
+
+  query(sprawl, act, flash) {
+    const findFlashAct = directory =>
+      find.flashAct(directory, sprawl.flashActData, {mode: 'error'});
+
+    const sideFirstActs = [
+      findFlashAct('flash-act:a1'),
+      findFlashAct('flash-act:a6a1'),
+      findFlashAct('flash-act:hiveswap'),
+      findFlashAct('flash-act:cool-and-new-web-comic'),
+      findFlashAct('flash-act:sunday-night-strifin'),
+    ];
+
+    const sideNames = [
+      `Side 1 (Acts 1-5)`,
+      `Side 2 (Acts 6-7)`,
+      `Additional Canon`,
+      `Fan Adventures`,
+      `Fan Games & More`,
+    ];
+
+    const sideColors = [
+      '#4ac925',
+      '#3796c6',
+      '#f2a400',
+      '#c466ff',
+      '#32c7fe',
+    ];
+
+    const sideFirstActIndexes =
+      sideFirstActs
+        .map(act => sprawl.flashActData.indexOf(act));
+
+    const actSideIndexes =
+      sprawl.flashActData
+        .map((act, actIndex) => actIndex)
+        .map(actIndex =>
+          sideFirstActIndexes
+            .findIndex((firstActIndex, i) =>
+              i === sideFirstActs.length - 1 ||
+                firstActIndex <= actIndex &&
+                sideFirstActIndexes[i + 1] > actIndex));
+
+    const sideActs =
+      sideNames
+        .map((name, sideIndex) =>
+          stitchArrays({
+            act: sprawl.flashActData,
+            actSideIndex: actSideIndexes,
+          }).filter(({actSideIndex}) => actSideIndex === sideIndex)
+            .map(({act}) => act));
+
+    const currentActFlashes =
+      act.flashes;
+
+    const currentFlashIndex =
+      currentActFlashes.indexOf(flash);
+
+    const currentSideIndex =
+      actSideIndexes[sprawl.flashActData.indexOf(act)];
+
+    const currentSideActs =
+      sideActs[currentSideIndex];
+
+    const currentActIndex =
+      currentSideActs.indexOf(act);
+
+    const fallbackListTerminology =
+      (currentSideIndex <= 1
+        ? 'flashesInThisAct'
+        : 'entriesInThisSection');
+
+    return {
+      sideNames,
+      sideColors,
+      sideActs,
+
+      currentSideIndex,
+      currentSideActs,
+      currentActIndex,
+      currentActFlashes,
+      currentFlashIndex,
+
+      fallbackListTerminology,
+    };
+  },
+
+  relations: (relation, query, sprawl, act, _flash) => ({
+    currentActLink:
+      relation('linkFlashAct', act),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    sideActLinks:
+      query.sideActs
+        .map(acts => acts
+          .map(act => relation('linkFlashAct', act))),
+
+    currentActFlashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (query, sprawl, act, flash) => ({
+    isFlashActPage: !flash,
+
+    sideColors: query.sideColors,
+    sideNames: query.sideNames,
+
+    currentSideIndex: query.currentSideIndex,
+    currentActIndex: query.currentActIndex,
+    currentFlashIndex: query.currentFlashIndex,
+
+    customListTerminology: act.listTerminology,
+    fallbackListTerminology: query.fallbackListTerminology,
+  }),
+
+  generate(data, relations, {getColors, html, language}) {
+    const currentActBox = html.tags([
+      html.tag('h1', relations.currentActLink),
+
+      html.tag('details',
+        (data.isFlashActPage
+          ? {}
+          : {class: 'current', open: true}),
+        [
+          html.tag('summary',
+            html.tag('span', {class: 'group-name'},
+              (data.customListTerminology
+                ? language.sanitize(data.customListTerminology)
+                : language.$('flashSidebar.flashList', data.fallbackListTerminology)))),
+
+          html.tag('ul',
+            relations.currentActFlashLinks
+              .map((flashLink, index) =>
+                html.tag('li',
+                  {class: index === data.currentFlashIndex && 'current'},
+                  flashLink))),
+        ]),
+    ]);
+
+    const sideMapBox = html.tags([
+      html.tag('h1', relations.flashIndexLink),
+
+      stitchArrays({
+        sideName: data.sideNames,
+        sideColor: data.sideColors,
+        actLinks: relations.sideActLinks,
+      }).map(({sideName, sideColor, actLinks}, sideIndex) =>
+          html.tag('details', {
+            class: sideIndex === data.currentSideIndex && 'current',
+            open: data.isFlashActPage && sideIndex === data.currentSideIndex,
+            style: sideColor && `--primary-color: ${getColors(sideColor).primary}`
+          }, [
+            html.tag('summary',
+              html.tag('span', {class: 'group-name'},
+                sideName)),
+
+            html.tag('ul',
+              actLinks.map((actLink, actIndex) =>
+                html.tag('li',
+                  {class:
+                    sideIndex === data.currentSideIndex &&
+                    actIndex === data.currentActIndex &&
+                      'current'},
+                  actLink))),
+          ])),
+    ]);
+
+    return {
+      leftSidebarMultiple:
+        (data.isFlashActPage
+          ? [
+              {content: sideMapBox},
+              {content: currentActBox},
+            ]
+          : [
+              {content: currentActBox},
+              {content: sideMapBox},
+            ]),
+    };
+  },
+};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 66588fdb..ad1dab94 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -7,6 +7,7 @@ export default {
     'generatePageLayout',
     'image',
     'linkFlash',
+    'linkFlashAct',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -36,9 +37,9 @@ export default {
       query.flashActs
         .map(() => relation('generateColorStyleVariables')),
 
-    actFirstFlashLinks:
+    actLinks:
       query.flashActs
-        .map(act => relation('linkFlash', act.flashes[0])),
+        .map(act => relation('linkFlashAct', act)),
 
     actCoverGrids:
       query.flashActs
@@ -58,7 +59,7 @@ export default {
   data: (query) => ({
     jumpLinkAnchors:
       query.jumpActs
-        .map(act => act.anchor),
+        .map(act => act.directory),
 
     jumpLinkColors:
       query.jumpActs
@@ -70,16 +71,12 @@ export default {
 
     actAnchors:
       query.flashActs
-        .map(act => act.anchor),
+        .map(act => act.directory),
 
     actColors:
       query.flashActs
         .map(act => act.color),
 
-    actNames:
-      query.flashActs
-        .map(act => act.name),
-
     actCoverGridNames:
       query.flashActs
         .map(act => act.flashes
@@ -118,10 +115,9 @@ export default {
 
         stitchArrays({
           colorVariables: relations.actColorVariables,
-          firstFlashLink: relations.actFirstFlashLinks,
+          actLink: relations.actLinks,
           anchor: data.actAnchors,
           color: data.actColors,
-          name: data.actNames,
 
           coverGrid: relations.actCoverGrids,
           coverGridImages: relations.actCoverGridImages,
@@ -132,8 +128,7 @@ export default {
             colorVariables,
             anchor,
             color,
-            name,
-            firstFlashLink,
+            actLink,
 
             coverGrid,
             coverGridImages,
@@ -146,7 +141,7 @@ export default {
                 id: anchor,
                 style: colorVariables.slot('color', color).content,
               },
-              firstFlashLink.slot('content', name)),
+              actLink),
 
             coverGrid.slots({
               links: coverGridLinks,
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 553d2f54..09c6b37c 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -4,13 +4,13 @@ export default {
   contentDependencies: [
     'generateContentHeading',
     'generateContributionList',
+    'generateFlashActSidebar',
     'generateFlashCoverArtwork',
     'generateFlashNavAccent',
-    'generateFlashSidebar',
     'generatePageLayout',
     'generateTrackList',
     'linkExternal',
-    'linkFlashIndex',
+    'linkFlashAct',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -41,7 +41,7 @@ export default {
       relation('generatePageLayout');
 
     relations.sidebar =
-      relation('generateFlashSidebar', flash);
+      relation('generateFlashActSidebar', flash.act, flash);
 
     if (query.urls) {
       relations.externalLinks =
@@ -59,8 +59,8 @@ export default {
 
     const nav = sections.nav = {};
 
-    nav.flashIndexLink =
-      relation('linkFlashIndex');
+    nav.flashActLink =
+      relation('linkFlashAct', flash.act);
 
     nav.flashNavAccent =
       relation('generateFlashNavAccent', flash);
@@ -163,14 +163,11 @@ export default {
       navLinkStyle: 'hierarchical',
       navLinks: [
         {auto: 'home'},
-        {html: sec.nav.flashIndexLink},
+        {html: sec.nav.flashActLink.slot('color', false)},
         {auto: 'current'},
       ],
 
-      navBottomRowContent:
-        sec.nav.flashNavAccent.slots({
-          showFlashNavigation: true,
-        }),
+      navBottomRowContent: sec.nav.flashNavAccent,
 
       ...relations.sidebar,
     });
diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
index 2c8205d3..57196d06 100644
--- a/src/content/dependencies/generateFlashNavAccent.js
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -55,13 +55,8 @@ export default {
     return relations;
   },
 
-  slots: {
-    showFlashNavigation: {type: 'boolean', default: false},
-  },
-
-  generate(relations, slots, {html, language}) {
+  generate(relations, {html, language}) {
     const {content: previousNextLinks = []} =
-      slots.showFlashNavigation &&
       relations.previousNextLinks &&
         relations.previousNextLinks.slots({
           previousLink: relations.previousFlashLink,
diff --git a/src/content/dependencies/generateFlashSidebar.js b/src/content/dependencies/generateFlashSidebar.js
deleted file mode 100644
index ba761922..00000000
--- a/src/content/dependencies/generateFlashSidebar.js
+++ /dev/null
@@ -1,236 +0,0 @@
-import {stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: ['linkFlash', 'linkFlashIndex'],
-  extraDependencies: ['html', 'wikiData'],
-
-  // So help me Gog, the flash sidebar is heavily hard-coded.
-
-  sprawl: ({flashActData}) => ({flashActData}),
-
-  query(sprawl, flash) {
-    const flashActs =
-      sprawl.flashActData.slice();
-
-    const act6 =
-      flashActs
-        .findIndex(act => act.name.startsWith('Act 6'));
-
-    const postCanon =
-      flashActs
-        .findIndex(act => act.name.includes('Post Canon'));
-
-    const outsideCanon =
-      postCanon +
-      flashActs
-        .slice(postCanon)
-        .findIndex(act => !act.name.includes('Post Canon'));
-
-    const currentAct = flash.act;
-
-    const actIndex =
-      flashActs
-        .indexOf(currentAct);
-
-    const side =
-      (actIndex < 0
-        ? 0
-     : actIndex < act6
-        ? 1
-     : actIndex < outsideCanon
-        ? 2
-        : 3);
-
-    const sideActs =
-      flashActs
-        .filter((act, index) =>
-          act.name.startsWith('Act 1') ||
-          act.name.startsWith('Act 6 Act 1') ||
-          act.name.startsWith('Hiveswap') ||
-          index >= outsideCanon);
-
-    const currentSideIndex =
-      sideActs
-        .findIndex(act => {
-          if (act.name.startsWith('Act 1')) {
-            return side === 1;
-          } else if (act.name.startsWith('Act 6 Act 1')) {
-            return side === 2;
-          } else if (act.name.startsWith('Hiveswap Act 1')) {
-            return side === 3;
-          } else {
-            return act === currentAct;
-          }
-        })
-
-    const sideNames =
-      sideActs
-        .map(act => {
-          if (act.name.startsWith('Act 1')) {
-            return `Side 1 (Acts 1-5)`;
-          } else if (act.name.startsWith('Act 6 Act 1')) {
-            return `Side 2 (Acts 6-7)`;
-          } else if (act.name.startsWith('Hiveswap Act 1')) {
-            return `Outside Canon (Misc. Games)`;
-          } else {
-            return act.name;
-          }
-        });
-
-    const sideColors =
-      sideActs
-        .map(act => {
-          if (act.name.startsWith('Act 1')) {
-            return '#4ac925';
-          } else if (act.name.startsWith('Act 6 Act 1')) {
-            return '#1076a2';
-          } else if (act.name.startsWith('Hiveswap Act 1')) {
-            return '#008282';
-          } else {
-            return act.color;
-          }
-        });
-
-    const sideFirstFlashes =
-      sideActs
-        .map(act => act.flashes[0]);
-
-    const scopeActs =
-      flashActs
-        .filter((act, index) => {
-          if (index < act6) {
-            return side === 1;
-          } else if (index < outsideCanon) {
-            return side === 2;
-          } else {
-            return false;
-          }
-        });
-
-    const currentScopeActIndex =
-      scopeActs.indexOf(currentAct);
-
-    const scopeActNames =
-      scopeActs
-        .map(act => act.name);
-
-    const scopeActFirstFlashes =
-      scopeActs
-        .map(act => act.flashes[0]);
-
-    const currentActFlashes =
-      currentAct.flashes;
-
-    const currentFlashIndex =
-      currentActFlashes
-        .indexOf(flash);
-
-    return {
-      currentSideIndex,
-      sideNames,
-      sideColors,
-      sideFirstFlashes,
-
-      currentScopeActIndex,
-      scopeActNames,
-      scopeActFirstFlashes,
-
-      currentActFlashes,
-      currentFlashIndex,
-    };
-  },
-
-  relations: (relation, query) => ({
-    flashIndexLink:
-      relation('linkFlashIndex'),
-
-    sideFirstFlashLinks:
-      query.sideFirstFlashes
-        .map(flash => relation('linkFlash', flash)),
-
-    scopeActFirstFlashLinks:
-      query.scopeActFirstFlashes
-        .map(flash => relation('linkFlash', flash)),
-
-    currentActFlashLinks:
-      query.currentActFlashes
-        .map(flash => relation('linkFlash', flash)),
-  }),
-
-  data: (query) => ({
-    currentSideIndex: query.currentSideIndex,
-    sideColors: query.sideColors,
-    sideNames: query.sideNames,
-
-    currentScopeActIndex: query.currentScopeActIndex,
-    scopeActNames: query.scopeActNames,
-
-    currentFlashIndex: query.currentFlashIndex,
-  }),
-
-  generate(data, relations, {html}) {
-    const currentActFlashList =
-      html.tag('ul',
-        relations.currentActFlashLinks
-          .map((flashLink, index) =>
-            html.tag('li',
-              {class: index === data.currentFlashIndex && 'current'},
-              flashLink)));
-
-    return {
-      leftSidebarContent: html.tags([
-        html.tag('h1', relations.flashIndexLink),
-
-        html.tag('dl',
-          stitchArrays({
-            sideFirstFlashLink: relations.sideFirstFlashLinks,
-            sideColor: data.sideColors,
-            sideName: data.sideNames,
-          }).map(({sideFirstFlashLink, sideColor, sideName}, index) => [
-              // Side acts are displayed whether part of Homestuck proper or
-              // not, and they're always the same regardless the current flash
-              // page. Scope acts, if applicable, and the list of flashes
-              // belonging to the current act, will be inserted after the
-              // heading of the current side.
-              html.tag('dt',
-                {class: [
-                  'side',
-                  index === data.currentSideIndex && 'current',
-                ]},
-                sideFirstFlashLink.slots({
-                  color: sideColor,
-                  content: sideName,
-                })),
-
-              // Scope acts are only applicable when inside Homestuck proper.
-              // Hiveswap and all acts beyond are each considered to be its
-              // own "side".
-              index === data.currentSideIndex &&
-              data.currentScopeActIndex !== -1 &&
-                stitchArrays({
-                  scopeActFirstFlashLink: relations.scopeActFirstFlashLinks,
-                  scopeActName: data.scopeActNames,
-                }).map(({scopeActFirstFlashLink, scopeActName}, index) => [
-                    html.tag('dt',
-                      {class: index === data.currentScopeActIndex && 'current'},
-                      scopeActFirstFlashLink.slot('content', scopeActName)),
-
-                    // Inside Homestuck proper, the flash list of the current
-                    // act should show after the heading for the relevant
-                    // scope act.
-                    index === data.currentScopeActIndex &&
-                      html.tag('dd', currentActFlashList),
-                  ]),
-
-              // Outside of Homestuck proper, the current act is represented
-              // by a side instead of a scope act, so place its flash list
-              // after the heading for the relevant side.
-              index === data.currentSideIndex &&
-              data.currentScopeActIndex === -1 &&
-                html.tag('dd', currentActFlashList),
-            ])),
-
-      ]),
-    };
-  },
-};
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
index b4970b17..5df83566 100644
--- a/src/content/dependencies/generateFooterLocalizationLinks.js
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -38,7 +38,7 @@ export default {
 
     return html.tag('div', {class: 'footer-localization-links'},
       language.$('misc.uiLanguage', {
-        languages: links.join('\n'),
+        languages: language.formatListWithoutSeparator(links),
       }));
   },
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 47239f55..259f5dce 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -11,6 +11,7 @@ export default {
     'generateCoverCarousel',
     'generateCoverGrid',
     'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
     'image',
@@ -20,18 +21,8 @@ export default {
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({listingSpec, wikiInfo}) {
-    const sprawl = {};
-    sprawl.enableGroupUI = wikiInfo.enableGroupUI;
-
-    if (wikiInfo.enableListings && wikiInfo.enableGroupUI) {
-      sprawl.groupsByCategoryListing =
-        listingSpec
-          .find(l => l.directory === 'groups/by-category');
-    }
-
-    return sprawl;
-  },
+  sprawl: ({wikiInfo}) =>
+    ({enableGroupUI: wikiInfo.enableGroupUI}),
 
   relations(relation, sprawl, group) {
     const relations = {};
@@ -46,15 +37,13 @@ export default {
       relation('generateGroupNavLinks', group);
 
     if (sprawl.enableGroupUI) {
+      relations.secondaryNav =
+        relation('generateGroupSecondaryNav', group);
+
       relations.sidebar =
         relation('generateGroupSidebar', group);
     }
 
-    if (sprawl.groupsByCategoryListing) {
-      relations.groupListingLink =
-        relation('linkListing', sprawl.groupsByCategoryListing);
-    }
-
     const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
 
     if (!empty(carouselAlbums)) {
@@ -160,15 +149,6 @@ export default {
                 })),
             })),
 
-          relations.groupListingLink &&
-            html.tag('p',
-              {class: 'quick-info'},
-              language.$('groupGalleryPage.anotherGroupLine', {
-                link:
-                  relations.groupListingLink
-                    .slot('content', language.$('groupGalleryPage.anotherGroupLine.link')),
-              })),
-
           relations.coverGrid
             .slots({
               links: relations.gridLinks,
@@ -208,6 +188,9 @@ export default {
           relations.navLinks
             .slot('currentExtra', 'gallery')
             .content,
+
+        secondaryNav:
+          relations.secondaryNav ?? null,
       });
   },
 };
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index e162a26a..0583755e 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -4,6 +4,7 @@ export default {
   contentDependencies: [
     'generateContentHeading',
     'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
     'linkAlbum',
@@ -32,6 +33,9 @@ export default {
       relation('generateGroupNavLinks', group);
 
     if (sprawl.enableGroupUI) {
+      relations.secondaryNav =
+        relation('generateGroupSecondaryNav', group);
+
       relations.sidebar =
         relation('generateGroupSidebar', group);
     }
@@ -161,6 +165,8 @@ export default {
 
         navLinkStyle: 'hierarchical',
         navLinks: relations.navLinks.content,
+
+        secondaryNav: relations.secondaryNav ?? null,
       });
   },
 };
diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
index 68341e0a..5cde2ab4 100644
--- a/src/content/dependencies/generateGroupNavLinks.js
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -2,10 +2,8 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
     'linkGroup',
     'linkGroupGallery',
-    'linkGroupExtra',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -28,24 +26,6 @@ export default {
     relations.mainLink =
       relation('linkGroup', group);
 
-    relations.previousNextLinks =
-      relation('generatePreviousNextLinks');
-
-    const groups = sprawl.groupCategoryData
-      .flatMap(category => category.groups);
-
-    const index = groups.indexOf(group);
-
-    if (index > 0) {
-      relations.previousLink =
-        relation('linkGroupExtra', groups[index - 1]);
-    }
-
-    if (index < groups.length - 1) {
-      relations.nextLink =
-        relation('linkGroupExtra', groups[index + 1]);
-    }
-
     relations.infoLink =
       relation('linkGroup', group);
 
@@ -80,26 +60,6 @@ export default {
       ];
     }
 
-    const previousNextLinks =
-      (relations.previousLink || relations.nextLink) &&
-        relations.previousNextLinks.slots({
-          previousLink:
-            relations.previousLink
-              ?.slot('extra', slots.currentExtra)
-              ?.content
-            ?? null,
-          nextLink:
-            relations.nextLink
-              ?.slot('extra', slots.currentExtra)
-              ?.content
-            ?? null,
-        });
-
-    const previousNextPart =
-      previousNextLinks &&
-        language.formatUnitList(
-          previousNextLinks.content.filter(Boolean));
-
     const infoLink =
       relations.infoLink.slots({
         attributes: {class: slots.currentExtra === null && 'current'},
@@ -119,7 +79,9 @@ export default {
         : language.formatUnitList([infoLink, ...extraLinks]));
 
     const accent =
-      `(${[extrasPart, previousNextPart].filter(Boolean).join('; ')})`;
+      (extrasPart
+        ? `(${extrasPart})`
+        : null);
 
     return [
       {auto: 'home'},
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
new file mode 100644
index 00000000..e3b28099
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -0,0 +1,99 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+    'generatePreviousNextLinks',
+    'generateSecondaryNav',
+    'linkGroupDynamically',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({listingSpec, wikiInfo}) => ({
+    groupsByCategoryListing:
+      (wikiInfo.enableListings
+        ? listingSpec
+            .find(l => l.directory === 'groups/by-category')
+        : null),
+  }),
+
+  query(sprawl, group) {
+    const groups = group.category.groups;
+    const index = groups.indexOf(group);
+
+    return {
+      previousGroup:
+        (index > 0
+          ? groups[index - 1]
+          : null),
+
+      nextGroup:
+        (index < groups.length - 1
+          ? groups[index + 1]
+          : null),
+    };
+  },
+
+  relations(relation, query, sprawl, _group) {
+    const relations = {};
+
+    relations.secondaryNav =
+      relation('generateSecondaryNav');
+
+    if (sprawl.groupsByCategoryListing) {
+      relations.categoryLink =
+        relation('linkListing', sprawl.groupsByCategoryListing);
+    }
+
+    relations.colorVariables =
+      relation('generateColorStyleVariables');
+
+    if (query.previousGroup || query.nextGroup) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+    }
+
+    relations.previousGroupLink =
+      (query.previousGroup
+        ? relation('linkGroupDynamically', query.previousGroup)
+        : null);
+
+    relations.nextGroupLink =
+      (query.nextGroup
+        ? relation('linkGroupDynamically', query.nextGroup)
+        : null);
+
+    return relations;
+  },
+
+  data: (query, sprawl, group) => ({
+    categoryName: group.category.name,
+    categoryColor: group.category.color,
+  }),
+
+  generate(data, relations, {html, language}) {
+    const {content: previousNextPart} =
+      relations.previousNextLinks.slots({
+        previousLink: relations.previousGroupLink,
+        nextLink: relations.nextGroupLink,
+        id: true,
+      });
+
+    const {categoryLink} = relations;
+
+    categoryLink?.setSlot('content', data.categoryName);
+
+    return relations.secondaryNav.slots({
+      class: 'nav-links-groups',
+      content:
+        (!relations.previousGroupLink && !relations.nextGroupLink
+          ? categoryLink
+          : html.tag('span',
+              {style: relations.colorVariables.slot('color', data.categoryColor).content},
+              [
+                categoryLink.slot('color', false),
+                `(${language.formatUnitList(previousNextPart)})`,
+              ])),
+    });
+  },
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 95a5dbec..cd831ba7 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -105,7 +105,7 @@ export default {
     color: {validate: v => v.isColor},
 
     styleRules: {
-      validate: v => v.sparseArrayOf(v.isString),
+      validate: v => v.sparseArrayOf(v.isHTML),
       default: [],
     },
 
@@ -183,7 +183,7 @@ export default {
           } else {
             aggregate.call(v.validateProperties({
               path: v.strictArrayOf(v.isString),
-              title: v.isString,
+              title: v.isHTML,
             }), {
               path: object.path,
               title: object.title,
@@ -394,6 +394,10 @@ export default {
 
     const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
     const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
+
+    const hasSidebarLeft = !html.isBlank(sidebarLeftHTML);
+    const hasSidebarRight = !html.isBlank(sidebarRightHTML);
+
     const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
 
     const hasID = (() => {
@@ -422,20 +426,20 @@ export default {
             processSkippers([
               {condition: true, id: 'content', string: 'content'},
               {
-                condition: !html.isBlank(sidebarLeftHTML),
+                condition: hasSidebarLeft,
                 id: 'sidebar-left',
                 string:
-                  (html.isBlank(sidebarRightHTML)
-                    ? 'sidebar'
-                    : 'sidebar.left'),
+                  (hasSidebarRight
+                    ? 'sidebar.left'
+                    : 'sidebar'),
               },
               {
-                condition: !html.isBlank(sidebarRightHTML),
+                condition: hasSidebarRight,
                 id: 'sidebar-right',
                 string:
-                  (html.isBlank(sidebarLeftHTML)
-                    ? 'sidebar'
-                    : 'sidebar.right'),
+                  (hasSidebarLeft
+                    ? 'sidebar.right'
+                    : 'sidebar'),
               },
               {condition: navHTML, id: 'header', string: 'header'},
               {condition: footerHTML, id: 'footer', string: 'footer'},
@@ -507,11 +511,6 @@ export default {
           class: [
             'layout-columns',
             !collapseSidebars && 'vertical-when-thin',
-            (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
-            (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
-            !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
-            sidebarLeftHTML && 'has-sidebar-left',
-            sidebarRightHTML && 'has-sidebar-right',
           ],
         },
         [
@@ -521,7 +520,7 @@ export default {
         ]),
       slots.bannerPosition === 'bottom' && slots.banner,
       footerHTML,
-    ].filter(Boolean).join('\n');
+    ];
 
     const pageHTML = html.tags([
       `<!DOCTYPE html>`,
@@ -609,7 +608,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site4.css', cachebust),
+              href: to('shared.staticFile', 'site5.css', cachebust),
             }),
 
             html.tag('style', [
@@ -624,12 +623,22 @@ export default {
           ]),
 
           html.tag('body',
-            // {style: body.style || ''},
             [
-              html.tag('div', {id: 'page-container'}, [
-                skippersHTML,
-                layoutHTML,
-              ]),
+              html.tag('div',
+                {
+                  id: 'page-container',
+                  class: [
+                    (hasSidebarLeft || hasSidebarRight) && 'has-one-sidebar',
+                    (hasSidebarLeft && hasSidebarRight) && 'has-two-sidebars',
+                    !(hasSidebarLeft || hasSidebarRight) && 'has-zero-sidebars',
+                    hasSidebarLeft && 'has-sidebar-left',
+                    hasSidebarRight && 'has-sidebar-right',
+                  ],
+                },
+                [
+                  skippersHTML,
+                  layoutHTML,
+                ]),
 
               // infoCardHTML,
               imageOverlayHTML,
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 334c5422..1083d863 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -44,14 +44,17 @@ export default {
       relation('generatePageLayout');
 
     relations.albumStyleRules =
-      relation('generateAlbumStyleRules', track.album);
+      relation('generateAlbumStyleRules', track.album, track);
 
     relations.socialEmbed =
       relation('generateTrackSocialEmbed', track);
 
     relations.artistChronologyContributions =
       getChronologyRelations(track, {
-        contributions: [...track.artistContribs, ...track.contributorContribs],
+        contributions: [
+          ...track.artistContribs ?? [],
+          ...track.contributorContribs ?? [],
+        ],
 
         linkArtist: artist => relation('linkArtist', artist),
         linkThing: track => relation('linkTrack', track),
@@ -65,7 +68,7 @@ export default {
 
     relations.coverArtistChronologyContributions =
       getChronologyRelations(track, {
-        contributions: track.coverArtistContribs,
+        contributions: track.coverArtistContribs ?? [],
 
         linkArtist: artist => relation('linkArtist', artist),
 
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index f001c3b3..65f5552b 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['linkTrack', 'linkContribution'],
@@ -11,14 +11,17 @@ export default {
     }
 
     return {
-      items: tracks.map(track => ({
-        trackLink:
-          relation('linkTrack', track),
+      trackLinks:
+        tracks
+          .map(track => relation('linkTrack', track)),
 
-        contributionLinks:
-          track.artistContribs
-            .map(contrib => relation('linkContribution', contrib)),
-      })),
+      contributionLinks:
+        tracks
+          .map(track =>
+            (empty(track.artistContribs)
+              ? null
+              : track.artistContribs
+                  .map(contrib => relation('linkContribution', contrib)))),
     };
   },
 
@@ -28,22 +31,28 @@ export default {
   },
 
   generate(relations, slots, {html, language}) {
-    return html.tag('ul',
-      relations.items.map(({trackLink, contributionLinks}) =>
-        html.tag('li',
-          language.$('trackList.item.withArtists', {
-            track: trackLink,
-            by:
-              html.tag('span', {class: 'by'},
-                language.$('trackList.item.withArtists.by', {
-                  artists:
-                    language.formatConjunctionList(
-                      contributionLinks.map(link =>
-                        link.slots({
-                          showContribution: slots.showContribution,
-                          showIcons: slots.showIcons,
-                        }))),
-                })),
-          }))));
+    return (
+      html.tag('ul',
+        stitchArrays({
+          trackLink: relations.trackLinks,
+          contributionLinks: relations.contributionLinks,
+        }).map(({trackLink, contributionLinks}) =>
+            html.tag('li',
+              (empty(contributionLinks)
+                ? trackLink
+                : language.$('trackList.item.withArtists', {
+                    track: trackLink,
+                    by:
+                      html.tag('span', {class: 'by'},
+                        language.$('trackList.item.withArtists.by', {
+                          artists:
+                            language.formatConjunctionList(
+                              contributionLinks.map(link =>
+                                link.slots({
+                                  showContribution: slots.showContribution,
+                                  showIcons: slots.showIcons,
+                                }))),
+                        })),
+                  }))))));
   },
 };
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index 99c1be55..cb0860f5 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -16,7 +16,7 @@ export default {
   sprawl({albumData}, row) {
     const sprawl = {};
 
-    switch (row.sourceGroupByRef) {
+    switch (row.sourceGroup) {
       case 'new-releases':
         sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
         break;
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 71b905f7..6c0aeecd 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,11 +1,16 @@
+import {logInfo, logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'getSizeOfImageFile',
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfImagePath',
+    'getThumbnailEqualOrSmaller',
+    'getThumbnailsAvailableForDimensions',
     'html',
     'language',
-    'thumb',
+    'missingImagePaths',
     'to',
   ],
 
@@ -52,10 +57,14 @@ export default {
   },
 
   generate(data, slots, {
-    getSizeOfImageFile,
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfImagePath,
+    getThumbnailEqualOrSmaller,
+    getThumbnailsAvailableForDimensions,
     html,
     language,
-    thumb,
+    missingImagePaths,
     to,
   }) {
     let originalSrc;
@@ -68,43 +77,48 @@ export default {
       originalSrc = '';
     }
 
-    const thumbSrc =
-      originalSrc &&
-        (slots.thumb
-          ? thumb[slots.thumb](originalSrc)
-          : originalSrc);
+    let mediaSrc = null;
+    if (originalSrc.startsWith(to('media.root'))) {
+      mediaSrc =
+        originalSrc
+          .slice(to('media.root').length)
+          .replace(/^\//, '');
+    }
 
-    const willLink = typeof slots.link === 'string' || slots.link;
-    const customLink = typeof slots.link === 'string';
+    const isMissingImageFile =
+      missingImagePaths.includes(mediaSrc);
+
+    if (isMissingImageFile) {
+      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
+    }
+
+    const willLink =
+      !isMissingImageFile &&
+      (typeof slots.link === 'string' || slots.link);
+
+    const customLink =
+      typeof slots.link === 'string';
 
     const willReveal =
       slots.reveal &&
       originalSrc &&
+      !isMissingImageFile &&
       !empty(data.contentWarnings);
 
     const willSquare = slots.square;
 
     const idOnImg = willLink ? null : slots.id;
     const idOnLink = willLink ? slots.id : null;
+
     const classOnImg = willLink ? null : slots.class;
     const classOnLink = willLink ? slots.class : null;
 
-    if (!originalSrc) {
+    if (!originalSrc || isMissingImageFile) {
       return prepare(
         html.tag('div', {class: 'image-text-area'},
-          slots.missingSourceContent));
-    }
-
-    let fileSize = null;
-    if (willLink) {
-      const mediaRoot = to('media.root');
-      if (originalSrc.startsWith(mediaRoot)) {
-        fileSize =
-          getSizeOfImageFile(
-            originalSrc
-              .slice(mediaRoot.length)
-              .replace(/^\//, ''));
-      }
+          (html.isBlank(slots.missingSourceContent)
+            ? language.$(`misc.missingImage`)
+            : slots.missingSourceContent)));
     }
 
     let reveal = null;
@@ -119,22 +133,84 @@ export default {
       ];
     }
 
+    const hasThumbnails =
+      mediaSrc &&
+      checkIfImagePathHasCachedThumbnails(mediaSrc);
+
+    // Warn for images that *should* have cached thumbnail information but are
+    // missing from the thumbs cache.
+    if (
+      slots.thumb &&
+      !hasThumbnails &&
+      !mediaSrc.endsWith('.gif')
+    ) {
+      logWarn`No thumbnail info cached: ${mediaSrc} - displaying original image here (instead of ${slots.thumb})`;
+    }
+
+    // Important to note that these might not be set at all, even if
+    // slots.thumb was provided.
+    let thumbSrc = null;
+    let availableThumbs = null;
+    let originalLength = null;
+
+    if (hasThumbnails && slots.thumb) {
+      // Note: This provides mediaSrc to getThumbnailEqualOrSmaller, since
+      // it's the identifier which thumbnail utilities use to query from the
+      // thumbnail cache. But we use the result to operate on originalSrc,
+      // which is the HTML output-appropriate path including `../../` or
+      // another alternate base path.
+      const selectedSize = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc);
+      thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${selectedSize}.jpg`);
+
+      const dimensions = getDimensionsOfImagePath(mediaSrc);
+      availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
+
+      const [width, height] = dimensions;
+      originalLength = Math.max(width, height)
+    }
+
+    let fileSize = null;
+    if (willLink && mediaSrc) {
+      fileSize = getSizeOfImagePath(mediaSrc);
+    }
+
     const imgAttributes = {
       id: idOnImg,
       class: classOnImg,
       alt: slots.alt,
       width: slots.width,
       height: slots.height,
-      'data-original-size': fileSize,
-      'data-no-image-preview': customLink,
     };
 
+    if (customLink) {
+      imgAttributes['data-no-image-preview'] = true;
+    }
+
+    // These attributes are only relevant when a thumbnail are available *and*
+    // being used.
+    if (hasThumbnails && slots.thumb) {
+      if (fileSize) {
+        imgAttributes['data-original-size'] = fileSize;
+      }
+
+      if (originalLength) {
+        imgAttributes['data-original-length'] = originalLength;
+      }
+
+      if (!empty(availableThumbs)) {
+        imgAttributes['data-thumbs'] =
+          availableThumbs
+            .map(([name, size]) => `${name}:${size}`)
+            .join(' ');
+      }
+    }
+
     const nonlazyHTML =
       originalSrc &&
         prepare(
           html.tag('img', {
             ...imgAttributes,
-            src: thumbSrc,
+            src: thumbSrc ?? originalSrc,
           }));
 
     if (slots.lazy) {
@@ -145,7 +221,7 @@ export default {
             {
               ...imgAttributes,
               class: 'lazy',
-              'data-original': thumbSrc,
+              'data-original': thumbSrc ?? originalSrc,
             }),
           true),
       ]);
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index 3bc34845..58bac0d2 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url';
 import chokidar from 'chokidar';
 import {ESLint} from 'eslint';
 
-import {color, logWarn} from '#cli';
+import {colors, logWarn} from '#cli';
 import contentFunction, {ContentFunctionSpecError} from '#content-function';
 import {annotateFunction} from '#sugar';
 
@@ -30,7 +30,6 @@ export function watchContentDependencies({
   const contentDependencies = {};
 
   let emittedReady = false;
-  let allDependenciesFulfilled = false;
   let closed = false;
 
   let _close = () => {};
@@ -77,12 +76,12 @@ export function watchContentDependencies({
   // prematurely find out there aren't any nulls - before the nulls have
   // been entered at all!).
 
-  readdir(metaDirname).then(files => {
+  readdir(watchPath).then(files => {
     if (closed) {
       return;
     }
 
-    const filePaths = files.map(file => path.join(metaDirname, file));
+    const filePaths = files.map(file => path.join(watchPath, file));
     for (const filePath of filePaths) {
       if (filePath === metaPath) continue;
       const functionName = getFunctionName(filePath);
@@ -91,7 +90,7 @@ export function watchContentDependencies({
       }
     }
 
-    const watcher = chokidar.watch(metaDirname);
+    const watcher = chokidar.watch(watchPath);
 
     watcher.on('all', (event, filePath) => {
       if (!['add', 'change'].includes(event)) return;
@@ -178,7 +177,14 @@ export function watchContentDependencies({
       // Just skip newly created files. They'll be processed again when
       // written.
       if (spec === undefined) {
-        contentDependencies[functionName] = null;
+        // For practical purposes the file is treated as though it doesn't
+        // even exist (undefined), rather than not being ready yet (null).
+        // Apart from if existing contents of the file were erased (but not
+        // the file itself), this value might already be set (to null!) by
+        // the readdir performed at the beginning to evaluate which files
+        // should be read and processed at least once before reporting all
+        // dependencies as ready.
+        delete contentDependencies[functionName];
         return;
       }
 
@@ -192,7 +198,7 @@ export function watchContentDependencies({
 
       if (logging && emittedReady) {
         const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
-        console.log(color.green(`[${timestamp}] Updated ${functionName}`));
+        console.log(colors.green(`[${timestamp}] Updated ${functionName}`));
       }
 
       contentDependencies[functionName] = fn;
@@ -219,9 +225,9 @@ export function watchContentDependencies({
       }
 
       if (typeof error === 'string') {
-        console.error(color.yellow(error));
+        console.error(colors.yellow(error));
       } else if (error instanceof ContentFunctionSpecError) {
-        console.error(color.yellow(error.message));
+        console.error(colors.yellow(error.message));
       } else {
         console.error(error);
       }
diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
new file mode 100644
index 00000000..3adc64df
--- /dev/null
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkAlbumGallery', 'linkAlbum'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, album) => ({
+    galleryLink: relation('linkAlbumGallery', album),
+    infoLink: relation('linkAlbum', album),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'albumGallery'
+      ? relations.galleryLink
+      : relations.infoLink),
+};
diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js
new file mode 100644
index 00000000..fbb819ed
--- /dev/null
+++ b/src/content/dependencies/linkFlashAct.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkThing'],
+  extraDependencies: ['html'],
+
+  relations: (relation, flashAct) =>
+    ({link: relation('linkThing', 'localized.flashActGallery', flashAct)}),
+
+  data: (flashAct) =>
+    ({name: flashAct.name}),
+
+  generate: (data, relations, {html}) =>
+    relations.link
+      .slot('content', new html.Tag(null, null, data.name)),
+};
diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js
new file mode 100644
index 00000000..90303ed1
--- /dev/null
+++ b/src/content/dependencies/linkGroupDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkGroupGallery', 'linkGroup'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, group) => ({
+    galleryLink: relation('linkGroupGallery', group),
+    infoLink: relation('linkGroup', group),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'groupGallery'
+      ? relations.galleryLink
+      : relations.infoLink),
+};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index 1cf64c59..d9af726c 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -15,8 +15,9 @@ export default {
     href: {type: 'string'},
     path: {validate: v => v.validateArrayItems(v.isString)},
     hash: {type: 'string'},
+    linkless: {type: 'boolean', default: false},
 
-    tooltip: {validate: v => v.isString},
+    tooltip: {type: 'string'},
     attributes: {validate: v => v.isAttributes},
     color: {validate: v => v.isColor},
     content: {type: 'html'},
@@ -29,27 +30,33 @@ export default {
     language,
     to,
   }) {
-    let href = slots.href;
+    let href;
     let style;
     let title;
 
-    if (href) {
-      href = encodeURI(href);
-    } else if (!empty(slots.path)) {
-      href = to(...slots.path);
-    }
+    if (slots.linkless) {
+      href = null;
+    } else {
+      if (slots.href) {
+        href = encodeURI(slots.href);
+      } else if (!empty(slots.path)) {
+        href = to(...slots.path);
+      } else {
+        href = '';
+      }
 
-    if (appendIndexHTML) {
-      if (
-        /^(?!https?:\/\/).+\/$/.test(href) &&
-        href.endsWith('/')
-      ) {
-        href += 'index.html';
+      if (appendIndexHTML) {
+        if (
+          /^(?!https?:\/\/).+\/$/.test(href) &&
+          href.endsWith('/')
+        ) {
+          href += 'index.html';
+        }
       }
-    }
 
-    if (slots.hash) {
-      href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+      if (slots.hash) {
+        href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+      }
     }
 
     if (slots.color) {
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
index e3e2608f..b20b132b 100644
--- a/src/content/dependencies/linkThing.js
+++ b/src/content/dependencies/linkThing.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: ['linkTemplate'],
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation) {
     return {
@@ -26,7 +26,7 @@ export default {
     preferShortName: {type: 'boolean', default: false},
 
     tooltip: {
-      validate: v => v.oneOf(v.isBoolean, v.isString),
+      validate: v => v.oneOf(v.isBoolean, v.isHTML),
       default: false,
     },
 
@@ -36,12 +36,13 @@ export default {
     },
 
     anchor: {type: 'boolean', default: false},
+    linkless: {type: 'boolean', default: false},
 
     attributes: {validate: v => v.isAttributes},
     hash: {type: 'string'},
   },
 
-  generate(data, relations, slots, {html}) {
+  generate(data, relations, slots, {html, language}) {
     const path = [data.pathKey, data.directory];
 
     const name =
@@ -51,7 +52,7 @@ export default {
 
     const content =
       (html.isBlank(slots.content)
-        ? name
+        ? language.sanitize(name)
         : slots.content);
 
     let color = null;
@@ -78,6 +79,7 @@ export default {
 
         attributes: slots.attributes,
         hash: slots.hash,
+        linkless: slots.linkless,
       });
   },
 }
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
new file mode 100644
index 00000000..b3a54747
--- /dev/null
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -0,0 +1 @@
+export default {generate() {}};
diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js
index 73d25e3d..c9f80f35 100644
--- a/src/content/dependencies/listTracksWithExtra.js
+++ b/src/content/dependencies/listTracksWithExtra.js
@@ -65,10 +65,14 @@ export default {
         stitchArrays({
           albumLink: relations.albumLinks,
           date: data.dates,
-        }).map(({albumLink, date}) => ({
-            album: albumLink,
-            date: language.formatDate(date),
-          })),
+        }).map(({albumLink, date}) =>
+            (date
+              ? {
+                  stringsKey: 'withDate',
+                  album: albumLink,
+                  date: language.formatDate(date),
+                }
+              : {album: albumLink})),
 
       chunkRows:
         relations.trackLinks
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 9a5ac456..3c2c3521 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -53,6 +53,10 @@ export const replacerSpec = {
       }
     },
   },
+  'flash-act': {
+    find: 'flashAct',
+    link: 'flashAct',
+  },
   group: {
     find: 'group',
     link: 'groupInfo',
@@ -119,6 +123,7 @@ const linkThingRelationMap = {
   artist: 'linkArtist',
   artistGallery: 'linkArtistGallery',
   flash: 'linkFlash',
+  flashAct: 'linkFlashAct',
   groupInfo: 'linkGroup',
   groupGallery: 'linkGroupGallery',
   listing: 'linkListing',