« 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/generateAdditionalFilesList.js101
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js127
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js24
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js47
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js4
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js22
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js15
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js19
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js11
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js104
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js72
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js12
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js13
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js15
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js45
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js7
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js271
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js6
-rw-r--r--src/content/dependencies/generateCoverArtwork.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js2
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js224
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js63
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js85
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js6
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js25
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js10
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js6
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js18
-rw-r--r--src/content/dependencies/generateGroupSidebar.js54
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js6
-rw-r--r--src/content/dependencies/generateListingPage.js2
-rw-r--r--src/content/dependencies/generateListingSidebar.js46
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js2
-rw-r--r--src/content/dependencies/generatePageLayout.js172
-rw-r--r--src/content/dependencies/generatePageSidebar.js77
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js28
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js42
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js6
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js26
-rw-r--r--src/content/dependencies/generateTrackList.js19
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js67
-rw-r--r--src/content/dependencies/generateWikiHomePage.js37
-rw-r--r--src/content/dependencies/image.js39
-rw-r--r--src/content/dependencies/linkContribution.js47
-rw-r--r--src/content/dependencies/linkExternal.js118
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js10
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js4
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js3
-rw-r--r--src/content/dependencies/transformContent.js168
-rw-r--r--src/content/util/getChronologyRelations.js12
57 files changed, 1503 insertions, 1010 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 92948c7..f504cf8 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,97 +1,24 @@
-import {empty} from '#sugar';
-
-function validateFileMapping(v, validateValue) {
-  return value => {
-    v.isObject(value);
-
-    const valueErrors = [];
-    for (const [fileKey, fileValue] of Object.entries(value)) {
-      if (fileValue === null) {
-        continue;
-      }
-
-      try {
-        validateValue(fileValue);
-      } catch (error) {
-        error.message = `(${fileKey}) ` + error.message;
-        valueErrors.push(error);
-      }
-    }
-
-    if (!empty(valueErrors)) {
-      throw new AggregateError(valueErrors, `Errors validating values`);
-    }
-  };
-}
+import {stitchArrays} from '#sugar';
 
 export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      // Additional files are already a serializable format.
-      additionalFiles,
-    };
-  },
+  extraDependencies: ['html'],
 
   slots: {
-    fileLinks: {
-      validate: v => validateFileMapping(v, v.isHTML),
+    chunks: {
+      validate: v => v.strictArrayOf(v.isHTML),
     },
 
-    fileSizes: {
-      validate: v => validateFileMapping(v, v.isWholeNumber),
+    chunkItems: {
+      validate: v => v.strictArrayOf(v.isHTML),
     },
   },
 
-  generate(data, slots, {html, language}) {
-    if (!slots.fileLinks) {
-      return html.blank();
-    }
-
-    const filesWithLinks = new Set(
-      Object.entries(slots.fileLinks)
-        .filter(([key, value]) => value)
-        .map(([key]) => key));
-
-    if (empty(filesWithLinks)) {
-      return html.blank();
-    }
-
-    const filteredFileGroups = data.additionalFiles
-      .map(({title, description, files}) => ({
-        title,
-        description,
-        files: files.filter(f => filesWithLinks.has(f)),
-      }))
-      .filter(({files}) => !empty(files));
-
-    if (empty(filteredFileGroups)) {
-      return html.blank();
-    }
-
-    return html.tag('dl',
-      filteredFileGroups.flatMap(({title, description, files}) => [
-        html.tag('dt',
-          (description
-            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                title,
-                description,
-              })
-            : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-        html.tag('dd',
-          html.tag('ul',
-            files.map(file =>
-              html.tag('li',
-                (slots.fileSizes?.[file]
-                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                      file: slots.fileLinks[file],
-                      size: language.formatFileSize(slots.fileSizes[file]),
-                    })
-                  : language.$('releaseInfo.additionalFiles.file', {
-                      file: slots.fileLinks[file],
-                    })))))),
-      ]));
-  },
+  generate: (slots, {html}) =>
+    html.tag('ul', {class: 'additional-files-list'},
+      stitchArrays({
+        chunk: slots.chunks,
+        items: slots.chunkItems,
+      }).map(({chunk, items}) =>
+          chunk.clone()
+            .slot('items', items))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
new file mode 100644
index 0000000..5804115
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,53 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    description: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      validate: v => v.looseArrayOf(v.isHTML),
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const summary =
+      html.tag('summary',
+        html.tag('span',
+          language.$('releaseInfo.additionalFiles.entry', {
+            title:
+              html.tag('span', {class: 'group-name'},
+                slots.title),
+          })));
+
+    const description =
+      html.tag('li', {class: 'entry-description'},
+        {[html.onlyIfContent]: true},
+        slots.description);
+
+    const items =
+      (html.isBlank(slots.items)
+        ? html.tag('li',
+            language.$('releaseInfo.additionalFiles.entry.noFilesAvailable'))
+        : slots.items);
+
+    const content =
+      html.tag('ul', [description, items]);
+
+    const details =
+      html.tag('details',
+        html.isBlank(slots.items) &&
+          {open: true},
+
+        [summary, content]);
+
+    return html.tag('li', details);
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
new file mode 100644
index 0000000..c37d6bb
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    fileLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    fileSize: {
+      validate: v => v.isWholeNumber,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const itemParts = ['releaseInfo.additionalFiles.file'];
+    const itemOptions = {file: slots.fileLink};
+
+    if (slots.fileSize) {
+      itemParts.push('withSize');
+      itemOptions.size = language.formatFileSize(slots.fileSize);
+    }
+
+    const li =
+      html.tag('li',
+        language.$(...itemParts, itemOptions));
+
+    return li;
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bc..0000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      titles: additionalFiles.map(fileGroup => fileGroup.title),
-    };
-  },
-
-  generate(data, {html, language}) {
-    if (empty(data.titles)) {
-      return html.blank();
-    }
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-      anchorLink:
-        html.tag('a',
-          {href: '#additional-files'},
-          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-
-      titles:
-        language.formatUnitList(data.titles),
-    });
-  },
-}
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
index 23f32bf..9818a43 100644
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -1,59 +1,96 @@
+import {stitchArrays} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateAdditionalFilesList',
+    'generateAdditionalFilesListChunk',
+    'generateAdditionalFilesListChunkItem',
     'linkAlbumAdditionalFile',
+    'transformContent',
   ],
 
-  extraDependencies: [
-    'getSizeOfAdditionalFile',
-    'html',
-    'urls',
-  ],
+  extraDependencies: ['getSizeOfAdditionalFile', 'html', 'urls'],
 
-  data(album, additionalFiles) {
-    return {
-      albumDirectory: album.directory,
-      fileLocations: additionalFiles.flatMap(({files}) => files),
-    };
-  },
+  relations: (relation, album, additionalFiles) => ({
+    list:
+      relation('generateAdditionalFilesList', additionalFiles),
 
-  relations(relation, album, additionalFiles) {
-    return {
-      additionalFilesList:
-        relation('generateAdditionalFilesList', additionalFiles),
-
-      additionalFileLinks:
-        Object.fromEntries(
-          additionalFiles
-            .flatMap(({files}) => files)
-            .map(file => [
-              file,
-              relation('linkAlbumAdditionalFile', album, file),
-            ])),
-    };
-  },
+    chunks:
+      additionalFiles
+        .map(() => relation('generateAdditionalFilesListChunk')),
+
+    chunkDescriptions:
+      additionalFiles
+        .map(({description}) =>
+          (description
+            ? relation('transformContent', description)
+            : null)),
+
+    chunkItems:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(() => relation('generateAdditionalFilesListChunkItem'))),
+
+    chunkItemFileLinks:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(file => relation('linkAlbumAdditionalFile', album, file))),
+  }),
+
+  data: (album, additionalFiles) => ({
+    albumDirectory: album.directory,
+
+    chunkTitles:
+      additionalFiles
+        .map(({title}) => title),
+
+    chunkItemLocations:
+      additionalFiles
+        .map(({files}) => files ?? []),
+  }),
 
   slots: {
     showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate(data, relations, slots, {
-    getSizeOfAdditionalFile,
-    urls,
-  }) {
-    return relations.additionalFilesList
-      .slots({
-        fileLinks: relations.additionalFileLinks,
-        fileSizes:
-          Object.fromEntries(data.fileLocations.map(file => [
-            file,
-            (slots.showFileSizes
-              ? getSizeOfAdditionalFile(
-                  urls
-                    .from('media.root')
-                    .to('media.albumAdditionalFile', data.albumDirectory, file))
-              : 0),
-          ])),
-      });
-  },
+  generate: (data, relations, slots, {getSizeOfAdditionalFile, urls}) =>
+    relations.list.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          description: relations.chunkDescriptions,
+          title: data.chunkTitles,
+        }).map(({chunk, title, description}) =>
+            chunk.slots({
+              title,
+              description:
+                (description
+                  ? description.slot('mode', 'inline')
+                  : null),
+            })),
+
+      chunkItems:
+        stitchArrays({
+          items: relations.chunkItems,
+          fileLinks: relations.chunkItemFileLinks,
+          locations: data.chunkItemLocations,
+        }).map(({items, fileLinks, locations}) =>
+            stitchArrays({
+              item: items,
+              fileLink: fileLinks,
+              location: locations,
+            }).map(({item, fileLink, location}) =>
+                item.slots({
+                  fileLink: fileLink,
+                  fileSize:
+                    (slots.showFileSizes
+                      ? getSizeOfAdditionalFile(
+                          urls
+                            .from('media.root')
+                            .to('media.albumAdditionalFile', data.albumDirectory, location))
+                      : 0),
+                }))),
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 5a7142e..7879269 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,9 +2,9 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCommentarySidebar',
     'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
@@ -23,6 +23,9 @@ export default {
     relations.layout =
       relation('generatePageLayout');
 
+    relations.sidebar =
+      relation('generateAlbumCommentarySidebar', album);
+
     relations.albumStyleRules =
       relation('generateAlbumStyleRules', album, null);
 
@@ -82,13 +85,6 @@ export default {
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
@@ -249,17 +245,7 @@ export default {
           },
         ],
 
-        leftSidebarStickyMode: 'column',
-        leftSidebarClass: 'commentary-track-list-sidebar-box',
-        leftSidebarContent: [
-          html.tag('h1', relations.sidebarAlbumLink),
-          relations.sidebarTrackSections.map(section =>
-            section.slots({
-              anchor: true,
-              open: true,
-              mode: 'commentary',
-            })),
-        ],
+        leftSidebar: relations.sidebar,
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 0000000..435860c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      stickyMode: 'column',
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'commentary-track-list-sidebar-box'},
+          content: [
+            html.tag('h1', relations.albumLink),
+            relations.trackSections.map(section =>
+              section.slots({
+                anchor: true,
+                open: true,
+                mode: 'commentary',
+              })),
+          ],
+        }),
+      ]
+    }),
+}
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
index ce8cde2..dbb22fe 100644
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -12,11 +12,15 @@ export default {
 
     color:
       album.color,
+
+    dimensions:
+      album.coverArtDimensions,
   }),
 
   generate: (data, relations) =>
     relations.coverArtwork.slots({
       path: data.path,
       color: data.color,
+      dimensions: data.dimensions,
     }),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index f61b198..aa02568 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -30,7 +30,7 @@ export default {
       const allCoverArtistArrays =
         tracksWithUniqueCoverArt
           .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
+          .map(contribs => contribs.map(contrib => contrib.artist));
 
       const allSameCoverArtists =
         allCoverArtistArrays
@@ -116,7 +116,7 @@ export default {
 
     data.coverArtists = [
       (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        ? album.coverArtistContribs.map(({artist}) => artist.name)
         : null),
 
       ...
@@ -126,7 +126,7 @@ export default {
           }
 
           if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+            return track.coverArtistContribs.map(({artist}) => artist.name);
           }
 
           return null;
@@ -145,6 +145,18 @@ export default {
             : null)),
     ];
 
+    data.dimensions = [
+      (album.hasCoverArt
+        ? album.coverArtDimensions
+        : null),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? track.coverArtDimensions
+            : null)),
+    ];
+
     return data;
   },
 
@@ -175,10 +187,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
+                  dimensions: data.dimensions,
                   name: data.names,
-                }).map(({image, path, name}) =>
+                }).map(({image, path, dimensions, name}) =>
                     image.slots({
                       path,
+                      dimensions,
                       missingSourceContent:
                         language.$('misc.albumGalleryGrid.noCoverArt', {name}),
                     })),
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5853f11..739a666 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -5,7 +5,6 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumBanner',
     'generateAlbumCoverArtwork',
@@ -107,11 +106,6 @@ export default {
         relation('linkAlbumCommentary', album);
     }
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
-
     // Section: Track list
 
     relations.trackList =
@@ -180,7 +174,12 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              sec.extra.additionalFilesShortcut,
+              sec.additionalFiles &&
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.extra.galleryLink && sec.extra.commentaryLink &&
                 language.$('releaseInfo.viewGalleryOrCommentary', {
@@ -265,7 +264,7 @@ export default {
 
         secondaryNav: relations.secondaryNav,
 
-        ...relations.sidebar,
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
       });
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 5128fba..6fc1375 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -96,17 +96,14 @@ export default {
               language.formatDisjunctionList(
                 relations.externalLinks
                   .map(link =>
-                    link.slots({
-                      context: [
-                        'album',
-                        (data.numTracks === 0
-                          ? 'albumNoTracks'
-                       : data.numTracks === 1
-                          ? 'albumOneTrack'
-                          : 'albumMultipleTracks'),
-                      ],
-                      style: 'normal',
-                    }))),
+                    link.slot('context', [
+                      'album',
+                      (data.numTracks === 0
+                        ? 'albumNoTracks'
+                     : data.numTracks === 1
+                        ? 'albumOneTrack'
+                        : 'albumMultipleTracks'),
+                    ]))),
           })),
     ]);
   },
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420b..d6ff8a0 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -59,11 +59,11 @@ export default {
       relation('generateSecondaryNav');
 
     relations.groupLinks =
-      album.groups
+      query.groups
         .map(group => relation('linkGroup', group));
 
     relations.colorStyles =
-      album.groups
+      query.groups
         .map(group => relation('generateColorStyleAttribute', group.color));
 
     if (album.date) {
@@ -102,7 +102,7 @@ export default {
   generate(relations, slots, {html, language}) {
     const navLinksShouldShowPreviousNext =
       (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
+        ? Array.from(relations.previousNextLinks ?? [], () => false)
         : stitchArrays({
             previousAlbumLink: relations.previousAlbumLinks ?? null,
             nextAlbumLink: relations.nextAlbumLinks ?? null,
@@ -151,11 +151,8 @@ export default {
       stitchArrays({
         content: navLinkContents,
         colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
+      }).map(({content, colorStyle}) =>
           html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
-
             colorStyle.slot('context', 'primary-only'),
 
             content));
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 5ef4501..355a9a9 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -1,79 +1,47 @@
 export default {
   contentDependencies: [
     'generateAlbumSidebarGroupBox',
-    'generateAlbumSidebarTrackSection',
-    'linkAlbum',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
   ],
 
-  extraDependencies: ['html'],
+  relations: (relation, album, track) => ({
+    sidebar:
+      relation('generatePageSidebar'),
 
-  relations(relation, album, track) {
-    const relations = {};
+    conjoinedBox:
+      relation('generatePageSidebarConjoinedBox'),
 
-    relations.albumLink =
-      relation('linkAlbum', album);
+    trackListBox:
+      relation('generateAlbumSidebarTrackListBox', album, track),
 
-    relations.groupBoxes =
+    groupBoxes:
       album.groups.map(group =>
-        relation('generateAlbumSidebarGroupBox', album, group));
-
-    relations.trackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, track, trackSection));
-
-    return relations;
-  },
-
-  data(album, track) {
-    return {isAlbumPage: !track};
-  },
-
-  generate(data, relations, {html}) {
-    const trackListBox = {
-      class: 'track-list-sidebar-box',
-      content:
-        html.tags([
-          html.tag('h1', relations.albumLink),
-          relations.trackSections,
-        ]),
-    };
-
-    if (data.isAlbumPage) {
-      const groupBoxes =
-        relations.groupBoxes
-          .map(content => ({
-            class: 'individual-group-sidebar-box',
-            content: content.slot('mode', 'album'),
-          }));
-
-      return {
-        leftSidebarMultiple: [
-          ...groupBoxes,
-          trackListBox,
-        ],
-      };
-    }
-
-    const conjoinedGroupBox = {
-      class: 'conjoined-group-sidebar-box',
-      content:
-        relations.groupBoxes
-          .flatMap((content, i, {length}) => [
-            content.slot('mode', 'track'),
-            i < length - 1 &&
-              html.tag('hr', {
-                style: `border-color: var(--primary-color); border-style: none none dotted none`
-              }),
-          ])
-          .filter(Boolean),
-    };
-
-    return {
-      // leftSidebarStickyMode: 'column',
-      leftSidebarMultiple: [
-        trackListBox,
-        conjoinedGroupBox,
+        relation('generateAlbumSidebarGroupBox', album, group)),
+  }),
+
+  data: (album, track) => ({
+    isAlbumPage: !track,
+  }),
+
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes: [
+        data.isAlbumPage &&
+          relations.groupBoxes
+            .map(box => box.slot('mode', 'album')),
+
+        relations.trackListBox,
+
+        !data.isAlbumPage &&
+          relations.conjoinedBox.slots({
+            attributes: {class: 'conjoined-group-sidebar-box'},
+            boxes:
+              relations.groupBoxes
+                .map(box => box.slot('mode', 'track'))
+                .map(box => box.content), /* TODO: Kludge. */
+          }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 93ebf5d..00a96c3 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -3,6 +3,7 @@ import {atOffset, empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generatePageSidebarBox',
     'linkAlbum',
     'linkExternal',
     'linkGroup',
@@ -40,6 +41,9 @@ export default {
   relations(relation, query, album, group) {
     const relations = {};
 
+    relations.box =
+      relation('generatePageSidebarBox');
+
     relations.groupLink =
       relation('linkGroup', group);
 
@@ -72,39 +76,41 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return html.tags([
-      html.tag('h1',
-        language.$('albumSidebar.groupBox.title', {
-          group: relations.groupLink,
-        })),
-
-      slots.mode === 'album' &&
-        relations.description
-          ?.slot('mode', 'multiline'),
-
-      !empty(relations.externalLinks) &&
-        html.tag('p',
-          language.$('releaseInfo.visitOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link => link.slot('context', 'group'))),
-          })),
-
-      slots.mode === 'album' &&
-      relations.nextAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.next', {
-            album: relations.nextAlbumLink,
+  generate: (relations, slots, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'individual-group-sidebar-box'},
+      content: [
+        html.tag('h1',
+          language.$('albumSidebar.groupBox.title', {
+            group: relations.groupLink,
           })),
 
-      slots.mode === 'album' &&
-      relations.previousAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.previous', {
-            album: relations.previousAlbumLink,
-          })),
-    ]);
-  },
+        slots.mode === 'album' &&
+          relations.description
+            ?.slot('mode', 'multiline'),
+
+        !empty(relations.externalLinks) &&
+          html.tag('p',
+            language.$('releaseInfo.visitOn', {
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+        slots.mode === 'album' &&
+        relations.nextAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.next', {
+              album: relations.nextAlbumLink,
+            })),
+
+        slots.mode === 'album' &&
+        relations.previousAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.previous', {
+              album: relations.previousAlbumLink,
+            })),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
new file mode 100644
index 0000000..3a244e3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'track-list-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.albumLink),
+        relations.trackSections,
+      ],
+    })
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 11b6a1b..7190fb4 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -64,8 +64,8 @@ export default {
       !empty(track.artistContribs) &&
        (empty(album.artistContribs) ||
         !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
+          track.artistContribs.map(contrib => contrib.artist),
+          album.artistContribs.map(contrib => contrib.artist),
           {checkOrder: false}));
 
     return data;
@@ -119,9 +119,11 @@ export default {
       parts.push('withArtists');
       options.by =
         html.tag('span', {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: language.formatConjunctionList(relations.contributionLinks),
-          }));
+          html.metatag('chunkwrap', {split: ','},
+            html.resolve(
+              language.$('trackList.item.withArtists.by', {
+                artists: language.formatConjunctionList(relations.contributionLinks),
+              }))));
     }
 
     return html.tag('li',
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 962f1b7..eae48f0 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -74,10 +74,13 @@ export default {
           ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
           : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
 
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
     data.coverArtists =
       query.things.map(thing =>
         thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+          .map(({artist}) => artist.name));
 
     return data;
   },
@@ -111,8 +114,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
 
               info:
                 data.coverArtists.map(names =>
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 1377915..db8f123 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -68,12 +68,15 @@ export default {
           ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
           : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
 
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
     data.otherCoverArtists =
       query.things.map(thing =>
         (thing.coverArtistContribs.length > 1
           ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
+              .filter(({artist: otherArtist}) => otherArtist !== artist)
+              .map(({artist: otherArtist}) => otherArtist.name)
           : null));
 
     return data;
@@ -107,8 +110,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
 
               info:
                 data.otherCoverArtists.map(names =>
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index a51f516..1725d4b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -45,6 +45,7 @@ export default {
 
     const groupsSortedByCount =
       allGroupsOrdered
+        .slice()
         .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
 
     // The filter here ensures all displayed groups have at least some duration
@@ -188,16 +189,16 @@ export default {
                     data.groupDurationsSortedByCount,
                     data.groupDurationsApproximateSortedByCount),
               }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // When sorting by count, duration details aren't necessarily
-                      // available for all items.
-                      (slots.showBothColumns && duration
-                        ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
-                        : language.$('artistPage.groupContributions.item.countAccent', {count}))),
-                  ])))
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // When sorting by count, duration details aren't necessarily
+                        // available for all items.
+                        (slots.showBothColumns && duration
+                          ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
+                          : language.$('artistPage.groupContributions.item.countAccent', {count}))),
+                    ])))
 
             : stitchArrays({
                 group: relations.groupLinksSortedByDuration,
@@ -205,19 +206,19 @@ export default {
                 duration:
                   getDurations(
                     data.groupDurationsSortedByDuration,
-                    data.groupDurationsApproximateSortedByCount),
+                    data.groupDurationsApproximateSortedByDuration),
               }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // Count details are always available, since they're just the
-                      // number of contributions directly. And duration details are
-                      // guaranteed for every item when sorting by duration.
-                      (slots.showBothColumns
-                        ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
-                        : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
-                  ])))))),
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // Count details are always available, since they're just the
+                        // number of contributions directly. And duration details are
+                        // guaranteed for every item when sorting by duration.
+                        (slots.showBothColumns
+                          ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
+                          : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
+                    ])))))),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 1b85680..ac9209a 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -163,11 +163,8 @@ export default {
               language.$('releaseInfo.visitOn', {
                 links:
                   language.formatDisjunctionList(
-                    sec.visit.externalLinks.map(link =>
-                      link.slots({
-                        context: 'artist',
-                        style: 'platform',
-                      }))),
+                    sec.visit.externalLinks
+                      .map(link => link.slot('context', 'artist'))),
               })),
 
           sec.artworks?.artistGalleryLink &&
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb27..44fb42f 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -171,8 +171,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index 0bcadc7..133095e 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -1,12 +1,19 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
 import {chunkByProperties, stitchArrays} from '#sugar';
 
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
     'linkTrack',
     'transformContent',
   ],
@@ -14,34 +21,68 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query(artist) {
-    const processEntry = ({thing, entry, type, track, album}) => ({
+    const processEntry = ({
+      thing,
+      entry,
+
+      chunkType,
+      itemType,
+
+      album = null,
+      track = null,
+      flashAct = null,
+      flash = null,
+    }) => ({
       thing: thing,
       entry: {
-        type: type,
-        track: track,
-        album: album,
+        chunkType,
+        itemType,
+
+        album,
+        track,
+        flashAct,
+        flash,
+
         annotation: entry.annotation,
       },
     });
 
-    const processAlbumEntry = ({type, album, entry}) =>
+    const processAlbumEntry = ({thing: album, entry}) =>
       processEntry({
         thing: album,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'album',
+
         album: album,
         track: null,
       });
 
-    const processTrackEntry = ({type, track, entry}) =>
+    const processTrackEntry = ({thing: track, entry}) =>
       processEntry({
         thing: track,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'track',
+
         album: track.album,
         track: track,
       });
 
+    const processFlashEntry = ({thing: flash, entry}) =>
+      processEntry({
+        thing: flash,
+        entry: entry,
+
+        chunkType: 'flash-act',
+        itemType: 'flash',
+
+        flashAct: flash.act,
+        flash: flash,
+      });
+
     const processEntries = ({things, processEntry}) =>
       things
         .flatMap(thing =>
@@ -49,136 +90,180 @@ export default {
             .filter(entry => entry.artists.includes(artist))
             .map(entry => processEntry({thing, entry})));
 
-    const processAlbumEntries = ({type, albums}) =>
+    const processAlbumEntries = ({albums}) =>
       processEntries({
         things: albums,
-        processEntry: ({thing, entry}) =>
-          processAlbumEntry({
-            type: type,
-            album: thing,
-            entry: entry,
-          }),
+        processEntry: processAlbumEntry,
       });
 
-    const processTrackEntries = ({type, tracks}) =>
+    const processTrackEntries = ({tracks}) =>
       processEntries({
         things: tracks,
-        processEntry: ({thing, entry}) =>
-          processTrackEntry({
-            type: type,
-            track: thing,
-            entry: entry,
-          }),
+        processEntry: processTrackEntry,
       });
 
-    const {albumsAsCommentator, tracksAsCommentator} = artist;
-
-    const trackEntries =
-      processTrackEntries({
-        type: 'track',
-        tracks: tracksAsCommentator,
+    const processFlashEntries = ({flashes}) =>
+      processEntries({
+        things: flashes,
+        processEntry: processFlashEntry,
       });
 
+    const {
+      albumsAsCommentator,
+      tracksAsCommentator,
+      flashesAsCommentator,
+    } = artist;
+
     const albumEntries =
       processAlbumEntries({
-        type: 'album',
         albums: albumsAsCommentator,
       });
 
-    const entries = [
-      ...albumEntries,
-      ...trackEntries,
-    ];
+    const trackEntries =
+      processTrackEntries({
+        tracks: tracksAsCommentator,
+      });
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+    const flashEntries =
+      processFlashEntries({
+        flashes: flashesAsCommentator,
+      })
+
+    const albumTrackEntries =
+      sortEntryThingPairs(
+        [...albumEntries, ...trackEntries],
+        sortAlbumsTracksChronologically);
+
+    const allEntries =
+      sortEntryThingPairs(
+        [...albumTrackEntries, ...flashEntries],
+        sortByDate);
 
     const chunks =
       chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album']);
+        allEntries.map(({entry}) => entry),
+        ['chunkType', 'album', 'flashAct']);
 
     return {chunks};
   },
 
-  relations(relation, query) {
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+  relations: (relation, query) => ({
+    chunks:
+      query.chunks
+        .map(() => relation('generateArtistInfoPageChunk')),
 
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
+    chunkLinks:
+      query.chunks
+        .map(({chunkType, album, flashAct}) =>
+          (chunkType === 'album'
+            ? relation('linkAlbum', album)
+         : chunkType === 'flash-act'
+            ? relation('linkFlashAct', flashAct)
+            : null)),
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    items:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(() => relation('generateArtistInfoPageChunkItem'))),
 
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) =>
+    itemLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({track, flash}) =>
             (track
               ? relation('linkTrack', track)
+           : flash
+              ? relation('linkFlash', flash)
               : null))),
 
-      itemAnnotations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({annotation}) =>
+    itemAnnotations:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({annotation}) =>
             (annotation
               ? relation('transformContent', annotation)
               : null))),
-    };
-  },
+  }),
 
-  data(query) {
-    return {
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
-    };
-  },
+  data: (query) => ({
+    chunkTypes:
+      query.chunks
+        .map(({chunkType}) => chunkType),
 
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
+    itemTypes:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({itemType}) => itemType)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('dl',
       stitchArrays({
         chunk: relations.chunks,
-        albumLink: relations.albumLinks,
+        chunkLink: relations.chunkLinks,
+        chunkType: data.chunkTypes,
 
         items: relations.items,
-        itemTrackLinks: relations.itemTrackLinks,
+        itemLinks: relations.itemLinks,
         itemAnnotations: relations.itemAnnotations,
         itemTypes: data.itemTypes,
       }).map(({
           chunk,
-          albumLink,
+          chunkLink,
+          chunkType,
 
           items,
-          itemTrackLinks,
+          itemLinks,
           itemAnnotations,
           itemTypes,
         }) =>
-          chunk.slots({
-            mode: 'album',
-            albumLink,
-            items:
-              stitchArrays({
-                item: items,
-                trackLink: itemTrackLinks,
-                annotation: itemAnnotations,
-                type: itemTypes,
-              }).map(({item, trackLink, annotation, type}) =>
-                item.slots({
-                  annotation:
-                    (annotation
-                      ? annotation.slot('mode', 'inline')
-                      : null),
-
-                  content:
-                    (type === 'album'
-                      ? html.tag('i',
-                          language.$('artistPage.creditList.entry.album.commentary'))
-                      : language.$('artistPage.creditList.entry.track', {
-                          track: trackLink,
-                        })),
-                })),
-          })));
-  },
+          (chunkType === 'album'
+            ? chunk.slots({
+                mode: 'album',
+                albumLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                    type: itemTypes,
+                  }).map(({item, link, annotation, type}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        (type === 'album'
+                          ? html.tag('i',
+                              language.$('artistPage.creditList.entry.album.commentary'))
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: link,
+                            })),
+                    })),
+              })
+         : chunkType === 'flash-act'
+            ? chunk.slots({
+                mode: 'flash',
+                flashActLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                  }).map(({item, link, annotation}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        language.$('artistPage.creditList.entry.flash', {
+                          flash: link,
+                        }),
+                    })),
+              })
+            : null))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af..447e697 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -92,8 +92,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742..471ee26 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -4,7 +4,8 @@ export default {
   contentDependencies: ['linkArtist'],
 
   relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+    const otherArtistContribs =
+      contribs.filter(contrib => contrib.artist !== artist);
 
     if (empty(otherArtistContribs)) {
       return {};
@@ -12,7 +13,7 @@ export default {
 
     const otherArtistLinks =
       otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
+        .map(contrib => relation('linkArtist', contrib.artist));
 
     return {otherArtistLinks};
   },
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779..bce6ced 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -150,7 +150,7 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk
             .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
+              contribs.filter(contrib => contrib.artist === artist))
             .map(ownContribs => ({
               creditedAsArtist:
                 ownContribs
@@ -162,7 +162,7 @@ export default {
 
               annotatedContribs:
                 ownContribs
-                  .filter(({what}) => what),
+                  .filter(({annotation}) => annotation),
             }))
             .map(({annotatedContribs, ...rest}) => ({
               ...rest,
@@ -203,7 +203,7 @@ export default {
               ];
             })
             .map(contribs =>
-              contribs.map(({what}) => what))
+              contribs.map(({annotation}) => annotation))
             .map(contributions =>
               (empty(contributions)
                 ? null
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index d0941d2..90c9db9 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -59,9 +59,23 @@ export default {
       validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
     },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {html}) {
+    const square =
+      (slots.dimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : true);
+
+    const sizeSlots =
+      (square
+        ? {square: true}
+        : {dimensions: slots.dimensions});
+
     switch (slots.mode) {
       case 'primary':
         return html.tags([
@@ -72,7 +86,7 @@ export default {
             thumb: 'medium',
             reveal: true,
             link: true,
-            square: true,
+            ...sizeSlots,
           }),
 
           !empty(relations.tagLinks) &&
@@ -93,7 +107,7 @@ export default {
           thumb: 'small',
           reveal: false,
           link: false,
-          square: true,
+          ...sizeSlots,
         });
 
       case 'commentary':
@@ -104,8 +118,8 @@ export default {
           thumb: 'medium',
           reveal: true,
           link: true,
-          square: true,
           lazy: true,
+          ...sizeSlots,
 
           attributes:
             {class: 'commentary-art'},
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 8eea58b..1707812 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -85,7 +85,7 @@ export default {
 
       navBottomRowContent: relations.flashActNavAccent,
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index 0bbfa1f..1421dde 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,216 +1,30 @@
-import find from '#find';
-import {filterMultipleArrays, 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: 'quiet'});
-
-    const homestuckSide1 = findFlashAct('flash-act:a1');
-
-    const sideFirstActs = [
-      sprawl.flashActData[0],
-      findFlashAct('flash-act:a6a1'),
-      findFlashAct('flash-act:hiveswap'),
-      findFlashAct('flash-act:cool-and-new-web-comic'),
-      findFlashAct('flash-act:sunday-night-strifin'),
-    ];
-
-    const sideNames = [
-      (homestuckSide1
-        ? `Side 1 (Acts 1-5)`
-        : `All flashes & games`),
-      `Side 2 (Acts 6-7)`,
-      `Additional Canon`,
-      `Fan Adventures`,
-      `Fan Games & More`,
-    ];
-
-    const sideColors = [
-      (homestuckSide1
-        ? '#4ac925'
-        : null),
-      '#3796c6',
-      '#f2a400',
-      '#c466ff',
-      '#32c7fe',
-    ];
-
-    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
-      firstAct => firstAct);
-
-    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,
+  contentDependencies: [
+    'generateFlashActSidebarCurrentActBox',
+    'generateFlashActSidebarSideMapBox',
+    'generatePageSidebar',
+  ],
 
-      fallbackListTerminology,
-    };
-  },
+  relations: (relation, act, flash) => ({
+    sidebar:
+      relation('generatePageSidebar'),
 
-  relations: (relation, query, sprawl, act, _flash) => ({
-    currentActLink:
-      relation('linkFlashAct', act),
+    currentActBox:
+      relation('generateFlashActSidebarCurrentActBox', act, flash),
 
-    flashIndexLink:
-      relation('linkFlashIndex'),
-
-    sideActLinks:
-      query.sideActs
-        .map(acts => acts
-          .map(act => relation('linkFlashAct', act))),
-
-    currentActFlashLinks:
-      act.flashes
-        .map(flash => relation('linkFlash', flash)),
+    sideMapBox:
+      relation('generateFlashActSidebarSideMapBox', act, flash),
   }),
 
-  data: (query, sprawl, act, flash) => ({
+  data: (_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 currentActBoxContent = 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',
-                  index === data.currentFlashIndex &&
-                    {class: 'current'},
-
-                  flashLink))),
-        ]),
-    ]);
-
-    const sideMapBoxContent = 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',
-            sideIndex === data.currentSideIndex &&
-              {class: 'current'},
-
-            data.isFlashActPage &&
-            sideIndex === data.currentSideIndex &&
-              {open: true},
-
-            sideColor &&
-              {style: `--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',
-                    sideIndex === data.currentSideIndex &&
-                    actIndex === data.currentActIndex &&
-                      {class: 'current'},
-
-                    actLink))),
-            ])),
-    ]);
-
-    const sideMapBox = {
-      class: 'flash-act-map-sidebar-box',
-      content: sideMapBoxContent,
-    };
-
-    const currentActBox = {
-      class: 'flash-current-act-sidebar-box',
-      content: currentActBoxContent,
-    };
-
-    return {
-      leftSidebarMultiple:
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes:
         (data.isFlashActPage
-          ? [sideMapBox, currentActBox]
-          : [currentActBox, sideMapBox]),
-    };
-  },
+          ? [relations.sideMapBox, relations.currentActBox]
+          : [relations.currentActBox, relations.sideMapBox]),
+    }),
 };
diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
new file mode 100644
index 0000000..c5426a4
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -0,0 +1,63 @@
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    actLink:
+      relation('linkFlashAct', act),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    currentFlashIndex:
+      act.flashes.indexOf(flash),
+
+    customListTerminology:
+      act.listTerminology,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.actLink),
+
+        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.entriesInThisSection')))),
+
+            html.tag('ul',
+              relations.flashLinks
+                .map((flashLink, index) =>
+                  html.tag('li',
+                    index === data.currentFlashIndex &&
+                      {class: 'current'},
+
+                    flashLink))),
+          ]),
+        ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
new file mode 100644
index 0000000..3d261ec
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
@@ -0,0 +1,85 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({flashSideData}) => ({flashSideData}),
+
+  relations: (relation, sprawl, _act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    sideColorStyles:
+      sprawl.flashSideData
+        .map(side => relation('generateColorStyleAttribute', side.color)),
+
+    sideActLinks:
+      sprawl.flashSideData
+        .map(side => side.acts
+          .map(act => relation('linkFlashAct', act))),
+  }),
+
+  data: (sprawl, act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    sideNames:
+      sprawl.flashSideData
+        .map(side => side.name),
+
+    currentSideIndex:
+      sprawl.flashSideData.indexOf(act.side),
+
+    currentActIndex:
+      act.side.acts.indexOf(act),
+  }),
+
+  generate: (data, relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.flashIndexLink),
+
+        stitchArrays({
+          sideName: data.sideNames,
+          sideColorStyle: relations.sideColorStyles,
+          actLinks: relations.sideActLinks,
+        }).map(({sideName, sideColorStyle, actLinks}, sideIndex) =>
+            html.tag('details',
+              sideIndex === data.currentSideIndex &&
+                {class: 'current'},
+
+              data.isFlashActPage &&
+              sideIndex === data.currentSideIndex &&
+                {open: true},
+
+              sideColorStyle.slot('context', 'primary-only'),
+
+              [
+                html.tag('summary',
+                  html.tag('span', {class: 'group-name'},
+                    sideName)),
+
+                html.tag('ul',
+                  actLinks.map((actLink, actIndex) =>
+                    html.tag('li',
+                      sideIndex === data.currentSideIndex &&
+                      actIndex === data.currentActIndex &&
+                        {class: 'current'},
+
+                      actLink))),
+              ])),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 57072a1..36bfaba 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -20,7 +20,7 @@ export default {
 
     const jumpActs =
       flashActs
-        .filter(act => act.jump);
+        .filter(act => act.side.acts.indexOf(act) === 0);
 
     return {flashActs, jumpActs};
   },
@@ -31,7 +31,7 @@ export default {
 
     jumpLinkColorStyles:
       query.jumpActs
-        .map(act => relation('generateColorStyleAttribute', act.jumpColor)),
+        .map(act => relation('generateColorStyleAttribute', act.side.color)),
 
     actColorStyles:
       query.flashActs
@@ -63,7 +63,7 @@ export default {
 
     jumpLinkLabels:
       query.jumpActs
-        .map(act => act.jump),
+        .map(act => act.side.name),
 
     actAnchors:
       query.flashActs
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index c60f969..0596493 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -2,6 +2,7 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
@@ -89,6 +90,13 @@ export default {
         relation('generateContributionList', flash.contributorContribs);
     }
 
+    // Section: Artist commentary
+
+    if (flash.commentary) {
+      sections.artistCommentary =
+        relation('generateCommentarySection', flash.commentary);
+    }
+
     return relations;
   },
 
@@ -136,6 +144,19 @@ export default {
                     .map(link => link.slot('context', 'flash'))),
             })),
 
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            sec.artistCommentary &&
+              language.$('releaseInfo.readCommentary', {
+                link: html.tag('a',
+                  {href: '#artist-commentary'},
+                  language.$('releaseInfo.readCommentary.link')),
+              }),
+          ]),
+
         sec.featuredTracks && [
           sec.featuredTracks.heading
             .slots({
@@ -158,6 +179,8 @@ export default {
 
           sec.contributors.list,
         ],
+
+        sec.artistCommentary,
       ],
 
       navLinkStyle: 'hierarchical',
@@ -169,7 +192,7 @@ export default {
 
       navBottomRowContent: sec.nav.flashNavAccent,
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index b29c586..d07847c 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -178,10 +178,12 @@ export default {
             }),
         ],
 
-        ...
-          relations.sidebar
-            ?.slot('currentExtra', 'gallery')
-            ?.content,
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .slot('currentExtra', 'gallery')
+                .content /* TODO: Kludge. */
+            : null),
 
         navLinkStyle: 'hierarchical',
         navLinks:
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 2e1d168..b5b456a 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -207,7 +207,11 @@ export default {
           ],
         ],
 
-        ...relations.sidebar?.content ?? {},
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .content /* TODO: Kludge. */
+            : null),
 
         navLinkStyle: 'hierarchical',
         navLinks: relations.navLinks.content,
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb508..a4f8131 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -69,12 +69,16 @@ export default {
   }),
 
   generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
+    const previousNextPart =
+      (relations.previousNextLinks
+        ? relations.previousNextLinks
+            .slots({
+              previousLink: relations.previousGroupLink,
+              nextLink: relations.nextGroupLink,
+              id: true,
+            })
+            .content /* TODO: Kludge. */
+        : null);
 
     const {categoryLink} = relations;
 
@@ -83,7 +87,7 @@ export default {
     return relations.secondaryNav.slots({
       class: 'nav-links-groups',
       content:
-        (relations.previousGroupLink || relations.nextGroupLink
+        (previousNextPart
           ? html.tag('span', {class: 'nav-link'},
               relations.colorStyle.slot('context', 'primary-only'),
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 98b288f..0888cbb 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -1,18 +1,25 @@
 export default {
-  contentDependencies: ['generateGroupSidebarCategoryDetails'],
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {groupCategoryData};
-  },
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
 
-  relations(relation, sprawl, group) {
-    return {
-      categoryDetails:
-        sprawl.groupCategoryData.map(category =>
-          relation('generateGroupSidebarCategoryDetails', category, group)),
-    };
-  },
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
 
   slots: {
     currentExtra: {
@@ -20,17 +27,20 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return {
-      leftSidebarClass: 'category-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
+  generate: (relations, slots, {html, language}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
 
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index b046cca..43a78cb 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -51,6 +51,12 @@ export default {
                         }),
                     }))
 
+             : additionalFileLinks.length === 0
+                ? html.tag('li',
+                    language.$('listingPage', slots.stringsKey, 'file.withNoFiles', {
+                      title: additionalFileTitle,
+                    }))
+
                 : html.tag('li', {class: 'has-details'},
                     html.tag('details', [
                       html.tag('summary',
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index aa661ab..23377af 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -276,7 +276,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1cdd236..aeac05c 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -1,21 +1,37 @@
 export default {
-  contentDependencies: ['generateListingIndexList', 'linkListingIndex'],
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkListingIndex',
+  ],
+
   extraDependencies: ['html'],
 
-  relations(relation, currentListing) {
-    return {
-      listingIndexLink: relation('linkListingIndex'),
-      listingIndexList: relation('generateListingIndexList', currentListing),
-    };
-  },
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
 
-  generate(relations, {html}) {
-    return {
-      leftSidebarClass: 'listing-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js
index 1b1c855..b57ebe1 100644
--- a/src/content/dependencies/generateListingsIndexPage.js
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -83,7 +83,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 9e9b461..51f9057 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,60 +1,6 @@
 import {openAggregate} from '#aggregate';
 import {empty} from '#sugar';
 
-function sidebarSlots(side) {
-  return {
-    // Content is a flat HTML array. It'll generate one sidebar section
-    // if specified.
-    [side + 'Content']: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // A single class to apply to the whole sidebar. If specifying multiple
-    // sections, this be added to the containing sidebar-column - specify a
-    // class on each section if that's more suitable.
-    [side + 'Class']: {type: 'string'},
-
-    // Multiple is an array of objects, each specifying content (HTML) and
-    // optionally class (a string). Each of these will generate one sidebar
-    // section.
-    [side + 'Multiple']: {
-      validate: v =>
-        v.sparseArrayOf(
-          v.validateProperties({
-            class: v.optional(v.isString),
-            content: v.isHTML,
-          })),
-    },
-
-    // Sticky mode controls which sidebar section(s), if any, follow the
-    // scroll position, "sticking" to the top of the browser viewport.
-    //
-    // 'last' - last or only sidebar box is sticky
-    // 'column' - entire column, incl. multiple boxes from top, is sticky
-    // 'none' - sidebar not sticky at all, stays at top of page
-    //
-    // Note: This doesn't affect the content of any sidebar section, only
-    // the whole section's containing box (or the sidebar column as a whole).
-    [side + 'StickyMode']: {
-      validate: v => v.is('last', 'column', 'static'),
-      default: 'static',
-    },
-
-    // Collapsing sidebars disappear when the viewport is sufficiently
-    // thin. (This is the default.) Override as false to make the sidebar
-    // stay visible in thinner viewports, where the page layout will be
-    // reflowed so the sidebar is as wide as the screen and appears below
-    // nav, above the main content.
-    [side + 'Collapse']: {type: 'boolean', default: true},
-
-    // Wide sidebars generally take up more horizontal space in the normal
-    // page layout, and should be used if the content of the sidebar has
-    // a greater than typical focus compared to main content.
-    [side + 'Wide']: {type: 'boolean', defualt: false},
-  };
-}
-
 export default {
   contentDependencies: [
     'generateColorStyleRules',
@@ -162,8 +108,15 @@ export default {
 
     // Sidebars
 
-    ...sidebarSlots('leftSidebar'),
-    ...sidebarSlots('rightSidebar'),
+    leftSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    rightSidebar: {
+      type: 'html',
+      mutable: true,
+    },
 
     // Banner
 
@@ -266,6 +219,14 @@ export default {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
+    // Hilariously jank. Sorry! We're going to need this content later ANYWAY,
+    // so it's "fine" to stringify it here, but this DOES mean that we're
+    // stringifying (and resolving) the content without the context that it's
+    // e.g. going to end up in a page HTML hierarchy. Might have implications
+    // later, mainly for: https://github.com/hsmusic/hsmusic-wiki/issues/434
+    const mainContentHTML = html.tags([slots.mainContent]).toString();
+    const hasID = id => mainContentHTML.includes(`id="${id}"`);
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -289,8 +250,11 @@ export default {
     let footerContent = slots.footerContent;
 
     if (html.isBlank(footerContent) && relations.defaultFooterContent) {
-      footerContent = relations.defaultFooterContent
-        .slot('mode', 'multiline');
+      footerContent =
+        relations.defaultFooterContent.slots({
+          mode: 'multiline',
+          indicateExternalLinks: false,
+        });
     }
 
     const mainHTML =
@@ -308,7 +272,7 @@ export default {
 
           html.tag('div', {class: 'main-content-container'},
             {[html.onlyIfContent]: true},
-            slots.mainContent),
+            mainContentHTML),
         ]);
 
     const footerHTML =
@@ -384,9 +348,6 @@ export default {
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
                         // Use inline-block styling on the content span,
@@ -413,64 +374,21 @@ export default {
             slots.navContent),
         ]);
 
-    const generateSidebarHTML = (side, id) => {
-      const content = slots[side + 'Content'];
-      const topClass = slots[side + 'Class'];
-      const multiple = slots[side + 'Multiple'];
-      const stickyMode = slots[side + 'StickyMode'];
-      const wide = slots[side + 'Wide'];
-      const collapse = slots[side + 'Collapse'];
-
-      let sidebarClasses = [];
-      let sidebarContent = html.blank();
-
-      if (!html.isBlank(content)) {
-        sidebarClasses = ['sidebar', topClass];
-        sidebarContent = content;
-      } else if (multiple) {
-        sidebarClasses = ['sidebar-multiple', topClass];
-        sidebarContent =
-          multiple
-            .filter(Boolean)
-            .map(box =>
-              html.tag('div', {class: 'sidebar'},
-                {[html.onlyIfContent]: true},
-                {class: box.class},
-                box.content));
-      }
-
-      if (html.isBlank(sidebarContent)) {
-        return html.blank();
-      }
-
-      return html.tag('div', {class: 'sidebar-column'},
-        {id, class: sidebarClasses},
-
-        wide &&
-          {class: 'wide'},
-
-        !collapse &&
-          {class: 'no-hide'},
-
-        stickyMode !== 'static' &&
-          {class: `sticky-${stickyMode}`},
-
-        sidebarContent);
-    }
-
-    const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
-    const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
-
-    const hasSidebarLeft = !html.isBlank(sidebarLeftHTML);
-    const hasSidebarRight = !html.isBlank(sidebarRightHTML);
+    const getSidebar = (side, id) =>
+      (html.isBlank(slots[side])
+        ? html.blank()
+        : slots[side].slots({
+            attributes:
+              slots[side]
+                .getSlotValue('attributes')
+                .with({id}),
+          }));
 
-    const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
 
-    const hasID = (() => {
-      // Hilariously jank. Sorry!
-      const mainContentHTML = slots.mainContent.toString();
-      return id => mainContentHTML.includes(`id="${id}"`);
-    })();
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
     const processSkippers = skipperList =>
       skipperList
@@ -583,15 +501,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          sidebarLeftHTML,
-          mainHTML,
-          sidebarRightHTML,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -684,7 +598,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('shared.staticFile', 'site7.css', cachebust),
             }),
 
             html.tag('style', [
@@ -724,7 +638,7 @@ export default {
 
               html.tag('script', {
                 type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
+                src: to('shared.staticFile', 'client4.js', cachebust),
               }),
             ]),
         ])
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
new file mode 100644
index 0000000..43015aa
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -0,0 +1,77 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Content boxes to line up vertically in the sidebar.
+    boxes: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Sticky mode controls which sidebar sections, if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'static' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    stickyMode: {
+      validate: v => v.is('last', 'column', 'static'),
+      default: 'static',
+    },
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    wide: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const attributes =
+      html.attributes({class: [
+        'sidebar-column',
+        'sidebar-multiple',
+      ]});
+
+    attributes.add(slots.attributes);
+
+    if (slots.wide) {
+      attributes.add('class', 'wide');
+    }
+
+    if (slots.stickyMode !== 'static') {
+      attributes.add('class', `sticky-${slots.stickyMode}`);
+    }
+
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (html.isBlank(slots.boxes)) {
+      return html.blank();
+    } else {
+      return html.tag('div', attributes, slots.boxes);
+    }
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
new file mode 100644
index 0000000..e11efc3
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'sidebar'},
+      slots.collapsible &&
+        {class: 'collapsible'},
+
+      slots.attributes,
+      slots.content),
+};
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
new file mode 100644
index 0000000..05b1d46
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -0,0 +1,42 @@
+// This component is kind of unfortunately magical. It reads the content of
+// various boxes and joins them together, discarding the boxes' attributes.
+// Since it requires access to the actual box *templates* (rather than those
+// templates' resolved content), take care when slotting into this.
+
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    box:
+      relation('generatePageSidebarBox'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    boxes: {
+      validate: v => v.looseArrayOf(v.isTemplate),
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.box.slots({
+      attributes: slots.attributes,
+      content:
+        slots.boxes.slice()
+          .map(box => box.getSlotValue('content'))
+          .map((content, index, {length}) => [
+            content,
+            index < length - 1 &&
+              html.tag('hr', {
+                style:
+                  `border-color: var(--primary-color); ` +
+                  `border-style: none none dotted none`,
+              }),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
index 6c056c9..a241eaf 100644
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -17,12 +17,18 @@ export default {
 
     color:
       track.color,
+
+    dimensions:
+      (track.hasUniqueCoverArt
+        ? track.coverArtDimensions
+        : track.album.coverArtDimensions),
   }),
 
   generate: (data, relations) =>
     relations.coverArtwork.slots({
       path: data.path,
       color: data.color,
+      dimensions: data.dimensions,
     }),
 };
 
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 7b70d4f..f532451 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -7,9 +7,9 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 export default {
   contentDependencies: [
     'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
@@ -112,6 +112,9 @@ export default {
     relations.chronologyLinks =
       relation('generateChronologyLinks');
 
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', track.album);
+
     relations.sidebar =
       relation('generateAlbumSidebar', track.album, track);
 
@@ -134,15 +137,6 @@ export default {
     relations.releaseInfo =
       relation('generateTrackReleaseInfo', track);
 
-    // Section: Extra links
-
-    const extra = sections.extra = {};
-
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
-
     // Section: Other releases
 
     if (!empty(track.otherReleases)) {
@@ -371,7 +365,11 @@ export default {
                 }),
 
               sec.additionalFiles &&
-                sec.extra.additionalFilesShortcut,
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.artistCommentary &&
                 language.$('releaseInfo.readCommentary', {
@@ -595,7 +593,11 @@ export default {
             ],
           }),
 
-        ...relations.sidebar,
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
+
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
       });
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 65f5552..3c36d24 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -44,15 +44,16 @@ export default {
                     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,
-                                }))),
-                        })),
+                        html.metatag('chunkwrap', {split: ','},
+                          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/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index f592ab9..bd0e479 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -1,48 +1,53 @@
 import {empty, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['linkNewsEntry', 'transformContent'],
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({newsData}) {
-    return {
-      entries: newsData.slice(0, 3),
-    };
-  },
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
 
-  relations(relation, sprawl) {
-    return {
-      entryContents:
-        sprawl.entries
-          .map(entry => relation('transformContent', entry.contentShort)),
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
 
-      entryMainLinks:
-        sprawl.entries
-          .map(entry => relation('linkNewsEntry', entry)),
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
 
-      entryReadMoreLinks:
-        sprawl.entries
-          .map(entry =>
-            entry.contentShort !== entry.content &&
-              relation('linkNewsEntry', entry)),
-    };
-  },
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
 
-  data(sprawl) {
-    return {
-      entryDates:
-        sprawl.entries
-          .map(entry => entry.date),
-    }
-  },
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
 
   generate(data, relations, {html, language}) {
     if (empty(relations.entryContents)) {
       return html.blank();
     }
 
-    return {
-      class: 'latest-news-sidebar-box',
+    return relations.box.slots({
+      attributes: {class: 'latest-news-sidebar-box'},
+      collapsible: false,
+
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
@@ -77,6 +82,6 @@ export default {
                     })),
               ])),
       ],
-    };
+    });
   },
 };
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 36fcc6f..ee14a58 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -1,6 +1,8 @@
 export default {
   contentDependencies: [
     'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
     'generateWikiHomeAlbumsRow',
     'generateWikiHomeNewsBox',
     'transformContent',
@@ -22,7 +24,13 @@ export default {
     relations.layout =
       relation('generatePageLayout');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
     if (homepageLayout.sidebarContent) {
+      relations.customSidebarBox =
+        relation('generatePageSidebarBox');
+
       relations.customSidebarContent =
         relation('transformContent', homepageLayout.sidebarContent);
     }
@@ -69,21 +77,24 @@ export default {
         relations.contentRows,
       ],
 
-      leftSidebarCollapse: false,
-      leftSidebarWide: true,
+      leftSidebar:
+        relations.sidebar.slots({
+          wide: true,
 
-      leftSidebarMultiple: [
-        (relations.customSidebarContent
-          ? {
-              class: 'custom-content-sidebar-box',
-              content:
-                relations.customSidebarContent
-                  .slot('mode', 'multiline'),
-            }
-          : null),
+          boxes: [
+            relations.customSidebarContent &&
+              relations.customSidebarBox.slots({
+                attributes: {class: 'custom-content-sidebar-box'},
+                collapsible: false,
 
-        relations.newsSidebarBox ?? null,
-      ],
+                content:
+                  relations.customSidebarContent
+                    .slot('mode', 'multiline'),
+              }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
 
       navLinkStyle: 'index',
       navLinks: [
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index db307a6..822efe3 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,4 +1,4 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
@@ -61,11 +61,14 @@ export default {
 
     reveal: {type: 'boolean', default: true},
     lazy: {type: 'boolean', default: false},
+
     square: {type: 'boolean', default: false},
 
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
     alt: {type: 'string'},
-    width: {type: 'number'},
-    height: {type: 'number'},
 
     attributes: {
       type: 'attributes',
@@ -116,10 +119,6 @@ export default {
     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);
@@ -134,14 +133,26 @@ export default {
       !isMissingImageFile &&
       !empty(contentWarnings);
 
-    const willSquare = slots.square;
+    const hasBothDimensions =
+      !!(slots.dimensions &&
+         slots.dimensions[0] !== null &&
+         slots.dimensions[1] !== null);
+
+    const willSquare =
+      (hasBothDimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : slots.square);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
-      slots.width && {width: slots.width},
-      slots.height && {height: slots.height},
+
+      slots.dimensions?.[0] &&
+        {width: slots.dimensions[0]},
+
+      slots.dimensions?.[1] &&
+        {width: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -220,11 +231,9 @@ export default {
           to('thumb.path', mediaSrcJpeg);
       }
 
-      const dimensions = getDimensionsOfImagePath(mediaSrc);
-      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
-
-      const [width, height] = dimensions;
-      const originalLength = Math.max(width, height)
+      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
+      const originalLength = Math.max(originalDimensions[0], originalDimensions[1]);
 
       const fileSize =
         (willLink && mediaSrc
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index cb57aa4..1a51c38 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -14,7 +14,7 @@ export default {
     const relations = {};
 
     relations.artistLink =
-      relation('linkArtist', contribution.who);
+      relation('linkArtist', contribution.artist);
 
     relations.textWithTooltip =
       relation('generateTextWithTooltip');
@@ -22,9 +22,9 @@ export default {
     relations.tooltip =
       relation('generateTooltip');
 
-    if (!empty(contribution.who.urls)) {
+    if (!empty(contribution.artist.urls)) {
       relations.artistIcons =
-        contribution.who.urls
+        contribution.artist.urls
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -33,7 +33,8 @@ export default {
 
   data(contribution) {
     return {
-      what: contribution.what,
+      contribution: contribution.annotation,
+      urls: contribution.artist.urls,
     };
   },
 
@@ -49,7 +50,7 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
+    const hasContribution = !!(slots.showContribution && data.contribution);
     const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
@@ -74,19 +75,43 @@ export default {
                   {[html.joinChildren]: ''},
 
                 content:
-                  relations.artistIcons
-                    .map(icon =>
-                      icon.slots({
+                  stitchArrays({
+                    icon: relations.artistIcons,
+                    url: data.urls,
+                  }).map(({icon, url}) => {
+                      icon.setSlots({
                         context: 'artist',
                         withText: true,
-                      })),
+                      });
+
+                      let platformText =
+                        language.formatExternalLink(url, {
+                          context: 'artist',
+                          style: 'platform',
+                        });
+
+                      // This is a pretty ridiculous hack, but we currently
+                      // don't have a way of telling formatExternalLink to *not*
+                      // use the fallback string, which just formats the URL as
+                      // its host/domain... so is technically detectable.
+                      if (platformText.toString() === (new URL(url)).host) {
+                        platformText =
+                          language.$('misc.artistLink.noExternalLinkPlatformName');
+                      }
+
+                      const platformSpan =
+                        html.tag('span', {class: 'icon-platform'},
+                          platformText);
+
+                      return [icon, platformSpan];
+                    }),
               }),
           })
         : relations.artistLink);
 
     if (hasContribution) {
       parts.push('withContribution');
-      options.contrib = data.what;
+      options.contrib = data.contribution;
     }
 
     if (hasExternalIcons && slots.iconMode === 'inline') {
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index ba2dbf2..f6b47db 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -6,12 +6,17 @@ export default {
   data: (url) => ({url}),
 
   slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
     style: {
       // This awkward syntax is because the slot descriptor validator can't
       // differentiate between a function that returns a validator (the usual
       // syntax) and a function that is itself a validator.
       validate: () => isExternalLinkStyle,
-      default: 'normal',
+      default: 'platform',
     },
 
     context: {
@@ -19,22 +24,113 @@ export default {
       default: 'generic',
     },
 
+    fromContent: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternal: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
     },
   },
 
-  generate: (data, slots, {html, language}) =>
-    html.tag('a',
-      {href: data.url},
-      {class: 'nowrap'},
+  generate(data, slots, {html, language}) {
+    let urlIsValid;
+    try {
+      new URL(data.url);
+      urlIsValid = true;
+    } catch (error) {
+      urlIsValid = false;
+    }
+
+    let formattedLink;
+    if (urlIsValid) {
+      formattedLink =
+        language.formatExternalLink(data.url, {
+          style: slots.style,
+          context: slots.context,
+        });
+
+      // Fall back to platform if nothing matched the desired style.
+      if (html.isBlank(formattedLink) && slots.style !== 'platform') {
+        formattedLink =
+          language.formatExternalLink(data.url, {
+            style: 'platform',
+            context: slots.context,
+          });
+      }
+    } else {
+      formattedLink = null;
+    }
 
-      slots.tab === 'separate' &&
-        {target: '_blank'},
+    const linkAttributes = html.attributes({
+      class: 'external-link',
+    });
 
-      language.formatExternalLink(data.url, {
-        style: slots.style,
-        context: slots.context,
-      })),
+    let linkContent;
+    if (urlIsValid) {
+      linkAttributes.set('href', data.url);
+
+      if (html.isBlank(slots.content)) {
+        linkContent = formattedLink;
+      } else {
+        linkContent = slots.content;
+      }
+    } else {
+      if (html.isBlank(slots.content)) {
+        linkContent =
+          html.tag('i',
+            language.$('misc.external.invalidURL.annotation'));
+      } else {
+        linkContent =
+          language.$('misc.external.invalidURL', {
+            link: slots.content,
+            annotation:
+              html.tag('i',
+                language.$('misc.external.invalidURL.annotation')),
+          });
+      }
+    }
+
+    if (slots.fromContent) {
+      linkAttributes.add('class', 'from-content');
+    }
+
+    if (urlIsValid && slots.indicateExternal) {
+      linkAttributes.add('class', 'indicate-external');
+
+      let titleText;
+      if (slots.tab === 'separate') {
+        if (html.isBlank(slots.content)) {
+          titleText =
+            language.$('misc.external.opensInNewTab.annotation');
+        } else {
+          titleText =
+            language.$('misc.external.opensInNewTab', {
+              link: formattedLink,
+              annotation:
+                language.$('misc.external.opensInNewTab.annotation'),
+            });
+        }
+      } else if (!html.isBlank(slots.content)) {
+        titleText = formattedLink;
+      }
+
+      if (titleText) {
+        linkAttributes.set('title', titleText.toString());
+      }
+    }
+
+    if (urlIsValid && slots.tab === 'separate') {
+      linkAttributes.set('target', '_blank');
+    }
+
+    return html.tag('a', linkAttributes, linkContent);
+  },
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index 3eb355a..6f37529 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -21,8 +21,8 @@ export default {
     const format = style =>
       language.formatExternalLink(data.url, {style, context: slots.context});
 
-    const normalText = format('normal');
-    const compactText = format('compact');
+    const platformText = format('platform');
+    const handleText = format('handle');
     const iconId = format('icon-id');
 
     return html.tag('a', {class: 'icon'},
@@ -34,7 +34,7 @@ export default {
       [
         html.tag('svg', [
           !slots.withText &&
-            html.tag('title', normalText),
+            html.tag('title', platformText),
 
           html.tag('use', {
             href: to('shared.staticIcon', iconId),
@@ -43,7 +43,9 @@ export default {
 
         slots.withText &&
           html.tag('span', {class: 'icon-text'},
-            compactText ?? normalText),
+            (html.isBlank(handleText)
+              ? platformText
+              : handleText)),
       ]);
   },
 };
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index bf48c96..e33ad7b 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -63,7 +63,7 @@ export default {
     const albumAdditionalFileFiles =
       albumAdditionalFileObjects
         .map(byAlbum => byAlbum
-          .map(({files}) => files));
+          .map(({files}) => files ?? []));
 
     const trackAdditionalFileTitles =
       trackAdditionalFileObjects
@@ -75,7 +75,7 @@ export default {
       trackAdditionalFileObjects
         .map(byAlbum => byAlbum
           .map(byTrack => byTrack
-            .map(({files}) => files)));
+            .map(({files}) => files ?? [])));
 
     return {
       spec,
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f70957..27a2faa 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index faae35a..0904cde 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -37,6 +37,7 @@ export default {
         .map(description => description.link)
         .filter(Boolean)),
     'image',
+    'linkExternal',
   ],
 
   extraDependencies: ['html', 'language', 'to', 'wikiData'],
@@ -114,7 +115,7 @@ export default {
 
             data.hash = enteredHash ?? null;
 
-            return {i: node.i, iEnd: node.iEnd, type: 'link', data};
+            return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
           }
 
           // This will be another {type: 'tag'} node which gets processed in
@@ -140,10 +141,15 @@ export default {
         sprawl.nodes
           .map(node => {
             switch (node.type) {
-              // Replace link nodes with a stub. It'll be replaced (by position)
-              // with an item from relations.
-              case 'link':
-                return {type: 'link'};
+              // Replace internal link nodes with a stub. It'll be replaced
+              // (by position) with an item from relations.
+              //
+              // TODO: This should be where label and hash get passed through,
+              // rather than in relations... (in which case there's no need to
+              // handle it specially here, and we can really just return
+              // data.nodes = sprawl.nodes)
+              case 'internal-link':
+                return {type: 'internal-link'};
 
               // Other nodes will get processed in generate.
               default:
@@ -167,9 +173,9 @@ export default {
           : getPlaceholder(node, content));
 
     return {
-      links:
+      internalLinks:
         nodes
-          .filter(({type}) => type === 'link')
+          .filter(({type}) => type === 'internal-link')
           .map(node => {
             const {link, thing, value} = node.data;
 
@@ -182,6 +188,15 @@ export default {
             }
           }),
 
+      externalLinks:
+        nodes
+          .filter(({type}) => type === 'external-link')
+          .map(node => {
+            const {href} = node.data;
+
+            return relation('linkExternal', href);
+          }),
+
       images:
         nodes
           .filter(({type}) => type === 'image')
@@ -201,6 +216,11 @@ export default {
       default: false,
     },
 
+    indicateExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -208,12 +228,10 @@ export default {
   },
 
   generate(data, relations, slots, {html, language, to}) {
-    let linkIndex = 0;
     let imageIndex = 0;
+    let internalLinkIndex = 0;
+    let externalLinkIndex = 0;
 
-    // This array contains only straight text and link nodes, which are directly
-    // representable in html (so no further processing is needed on the level of
-    // individual nodes).
     const contentFromNodes =
       data.nodes.map(node => {
         switch (node.type) {
@@ -237,57 +255,83 @@ export default {
             } = node;
 
             if (node.inline) {
+              let content =
+                html.tag('img',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+                  style && {style},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    {title:
+                      language.$('misc.external.opensInNewTab', {
+                        link:
+                          language.formatExternalLink(link, {
+                            style: 'platform',
+                          }),
+
+                        annotation:
+                          language.$('misc.external.opensInNewTab.annotation'),
+                      }).toString()},
+
+                    content);
+              }
+
               return {
-                type: 'image',
+                type: 'processed-image',
                 inline: true,
-                data:
-                  html.tag('img',
-                    src && {src},
-                    width && {width},
-                    height && {height},
-                    style && {style},
-
-                    pixelate &&
-                      {class: 'pixelate'}),
+                data: content,
               };
             }
 
             const image = relations.images[imageIndex++];
 
+            image.setSlots({
+              src,
+
+              link: link ?? true,
+              warnings: warnings ?? null,
+              thumb: slots.thumb,
+            });
+
+            if (width || height) {
+              image.setSlot('dimensions', [width ?? null, height ?? null]);
+            }
+
+            image.setSlot('attributes', [
+              {class: 'content-image'},
+
+              pixelate &&
+                {class: 'pixelate'},
+            ]);
+
             return {
-              type: 'image',
+              type: 'processed-image',
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
                   align === 'center' &&
                     {class: 'align-center'},
 
-                  image.slots({
-                    src,
-
-                    link: link ?? true,
-                    width: width ?? null,
-                    height: height ?? null,
-                    warnings: warnings ?? null,
-                    thumb: slots.thumb,
-
-                    attributes: [
-                      {class: 'content-image'},
-
-                      pixelate &&
-                        {class: 'pixelate'},
-                    ],
-                  })),
+                  image),
             };
           }
 
-          case 'link': {
-            const linkNode = relations.links[linkIndex++];
-            if (linkNode.type === 'text') {
-              return {type: 'text', data: linkNode.data};
+          case 'internal-link': {
+            const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
+            if (nodeFromRelations.type === 'text') {
+              return {type: 'text', data: nodeFromRelations.data};
             }
 
-            const {link, label, hash} = linkNode;
+            const {link, label, hash} = nodeFromRelations;
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -322,7 +366,27 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
-            return {type: 'link', data: link};
+            return {type: 'processed-internal-link', data: link};
+          }
+
+          case 'external-link': {
+            const {label} = node.data;
+            const externalLink = relations.externalLinks[externalLinkIndex++];
+
+            externalLink.setSlots({
+              content: label,
+              fromContent: true,
+            });
+
+            if (slots.indicateExternalLinks) {
+              externalLink.setSlots({
+                indicateExternal: true,
+                tab: 'separate',
+                style: 'platform',
+              });
+            }
+
+            return {type: 'processed-external-link', data: externalLink};
           }
 
           case 'tag': {
@@ -358,7 +422,10 @@ export default {
     // access to its slots.
 
     if (slots.mode === 'single-link') {
-      const link = contentFromNodes.find(node => node.type === 'link');
+      const link =
+        contentFromNodes.find(node =>
+          node.type === 'processed-internal-link' ||
+          node.type === 'processed-external-link');
 
       if (!link) {
         return html.blank();
@@ -385,13 +452,10 @@ export default {
             return getTextNodeContents(node, index);
           }
 
-          const attributes = html.attributes({
-            class: 'INSERT-NON-TEXT',
-            'data-type': node.type,
-          });
+          let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`;
 
-          if (node.type === 'image') {
-            attributes.set('data-inline', node.inline);
+          if (node.type === 'processed-image' && node.inline) {
+            attributes += ` data-inline`;
           }
 
           return `<span ${attributes}>${index}</span>`;
@@ -426,7 +490,7 @@ export default {
         // the surrounding <p> tag that marked generates. The HTML parser
         // treats a <div> that starts inside a <p> as a Crocker-class
         // misgiving, and will treat you very badly if you feed it that.
-        if (attributes.get('data-type') === 'image') {
+        if (attributes.get('data-type') === 'processed-image') {
           if (!attributes.get('data-inline')) {
             tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
             deleteParagraph = true;
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
index 67d6d5f..c4a62da 100644
--- a/src/content/util/getChronologyRelations.js
+++ b/src/content/util/getChronologyRelations.js
@@ -18,17 +18,17 @@ export default function getChronologyRelations(thing, {
 
   const artistsSoFar = new Set();
 
-  contributions = contributions.filter(({who}) => {
-    if (artistsSoFar.has(who)) {
+  contributions = contributions.filter(({artist}) => {
+    if (artistsSoFar.has(artist)) {
       return false;
     } else {
-      artistsSoFar.add(who);
+      artistsSoFar.add(artist);
       return true;
     }
   });
 
-  return contributions.map(({who}) => {
-    const things = Array.from(new Set(getThings(who)));
+  return contributions.map(({artist}) => {
+    const things = Array.from(new Set(getThings(artist)));
 
     // Don't show a line if this contribution isn't part of the artist's
     // chronology at all (usually because this thing isn't dated).
@@ -47,7 +47,7 @@ export default function getChronologyRelations(thing, {
 
     return {
       index: index + 1,
-      artistLink: linkArtist(who),
+      artistLink: linkArtist(artist),
       previousLink: previous ? linkThing(previous) : null,
       nextLink: next ? linkThing(next) : null,
     };