« 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.js28
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js77
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js13
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js8
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js50
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js8
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js8
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js38
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js15
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js4
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js23
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js107
-rw-r--r--src/content/dependencies/generateAlbumStyleTags.js65
-rw-r--r--src/content/dependencies/generateAlbumWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/generateArtistCredit.js47
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js1
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js137
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js44
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js45
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js108
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleTag.js51
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js18
-rw-r--r--src/content/dependencies/generateContentContentHeading.js39
-rw-r--r--src/content/dependencies/generateCoverArtwork.js39
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js18
-rw-r--r--src/content/dependencies/generateCoverGrid.js24
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js20
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js18
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGrid.js8
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js6
-rw-r--r--src/content/dependencies/generateGroupGalleryPageSeriesSection.js105
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js4
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js22
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js51
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js151
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js23
-rw-r--r--src/content/dependencies/generateLyricsEntry.js11
-rw-r--r--src/content/dependencies/generatePageLayout.js70
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js4
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js4
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js159
-rw-r--r--src/content/dependencies/generateStaticPage.js14
-rw-r--r--src/content/dependencies/generateStaticURLStyleTag.js23
-rw-r--r--src/content/dependencies/generateStyleTag.js48
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js88
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js143
-rw-r--r--src/content/dependencies/generateTrackList.js11
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js10
-rw-r--r--src/content/dependencies/generateTrackListItem.js4
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js8
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js8
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js31
-rw-r--r--src/content/dependencies/generateWallpaperStyleTag.js80
-rw-r--r--src/content/dependencies/generateWikiWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/index.js9
-rw-r--r--src/content/dependencies/linkAdditionalFile.js29
-rw-r--r--src/content/dependencies/linkAlbum.js10
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkContribution.js2
-rw-r--r--src/content/dependencies/linkExternal.js2
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js197
-rw-r--r--src/content/dependencies/listArtistsByContributions.js52
-rw-r--r--src/content/dependencies/transformContent.js99
66 files changed, 1681 insertions, 1126 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 68120b23..7e05b5b5 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,26 +1,22 @@
-import {stitchArrays} from '#sugar';
-
 export default {
+  contentDependencies: ['generateAdditionalFilesListChunk'],
   extraDependencies: ['html'],
 
-  slots: {
-    chunks: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  relations: (relation, additionalFiles) => ({
+    chunks:
+      additionalFiles
+        .map(file => relation('generateAdditionalFilesListChunk', file)),
+  }),
 
-    chunkItems: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate: (slots, {html}) =>
+  generate: (relations, slots, {html}) =>
     html.tag('ul', {class: 'additional-files-list'},
       {[html.onlyIfContent]: true},
 
-      stitchArrays({
-        chunk: slots.chunks,
-        items: slots.chunkItems,
-      }).map(({chunk, items}) =>
-          chunk.clone()
-            .slot('items', items))),
+      relations.chunks.map(chunk => chunk.slots({
+        showFileSizes: slots.showFileSizes,
+      }))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 507b2329..3cac851b 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -1,46 +1,81 @@
+import {stitchArrays} from '#sugar';
+
 export default {
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['linkAdditionalFile', 'transformContent'],
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'],
 
-  slots: {
-    title: {
-      type: 'html',
-      mutable: false,
-    },
+  relations: (relation, file) => ({
+    description:
+      relation('transformContent', file.description),
 
-    description: {
-      type: 'html',
-      mutable: false,
-    },
+    links:
+      file.filenames
+        .map(filename => relation('linkAdditionalFile', file, filename)),
+  }),
+
+  data: (file) => ({
+    title:
+      file.title,
+
+    paths:
+      file.paths,
+  }),
 
-    items: {
-      validate: v => v.looseArrayOf(v.isHTML),
+  slots: {
+    showFileSizes: {
+      type: 'boolean',
     },
   },
 
-  generate: (slots, {html, language}) =>
-    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+  generate: (data, relations, slots, {getSizeOfMediaFile, html, language, urls}) =>
+    language.encapsulate('releaseInfo.additionalFiles', capsule =>
       html.tag('li',
         html.tag('details',
-          html.isBlank(slots.items) &&
+          html.isBlank(relations.links) &&
             {open: true},
 
           [
             html.tag('summary',
               html.tag('span',
-                language.$(capsule, {
+                language.$(capsule, 'entry', {
                   title:
-                    html.tag('b', slots.title),
+                    html.tag('b', data.title),
                 }))),
 
             html.tag('ul', [
               html.tag('li', {class: 'entry-description'},
                 {[html.onlyIfContent]: true},
-                slots.description),
 
-              (html.isBlank(slots.items)
+                relations.description.slot('mode', 'inline')),
+
+              (html.isBlank(relations.links)
                 ? html.tag('li',
-                    language.$(capsule, 'noFilesAvailable'))
-                : slots.items),
+                    language.$(capsule, 'entry.noFilesAvailable'))
+
+                : stitchArrays({
+                    link: relations.links,
+                    path: data.paths,
+                  }).map(({link, path}) =>
+                      html.tag('li',
+                        language.encapsulate(capsule, 'file', workingCapsule => {
+                          const workingOptions = {file: link};
+
+                          if (slots.showFileSizes) {
+                            const fileSize =
+                              getSizeOfMediaFile(
+                                urls
+                                  .from('media.root')
+                                  .to(...path));
+
+                            if (fileSize) {
+                              workingCapsule += '.withSize';
+                              workingOptions.size =
+                                language.formatFileSize(fileSize);
+                            }
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        })))),
             ]),
           ]))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
deleted file mode 100644
index c37d6bb2..00000000
--- a/src/content/dependencies/generateAdditionalFilesListChunkItem.js
+++ /dev/null
@@ -1,30 +0,0 @@
-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/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
deleted file mode 100644
index ad17206f..00000000
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import {stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesList',
-    'generateAdditionalFilesListChunk',
-    'generateAdditionalFilesListChunkItem',
-    'linkAlbumAdditionalFile',
-    'transformContent',
-  ],
-
-  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
-
-  relations: (relation, album, additionalFiles) => ({
-    list:
-      relation('generateAdditionalFilesList', additionalFiles),
-
-    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, {getSizeOfMediaFile, 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
-                      ? getSizeOfMediaFile(
-                          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 1e39b47d..3529c4dc 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -5,7 +5,7 @@ export default {
     'generateAlbumCommentarySidebar',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateCoverArtwork',
@@ -44,8 +44,8 @@ export default {
     relations.sidebar =
       relation('generateAlbumCommentarySidebar', album);
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
+    relations.albumStyleTags =
+      relation('generateAlbumStyleTags', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -151,7 +151,7 @@ export default {
         headingMode: 'sticky',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['long-content'],
         mainContent: [
@@ -266,7 +266,10 @@ export default {
                       }),
                   })),
 
-              cover?.slots({mode: 'commentary'}),
+              cover?.slots({
+                mode: 'commentary',
+                color: true,
+              }),
 
               trackDate &&
               trackDate !== data.date &&
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index 2ba3b272..516a7ca8 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -9,7 +9,7 @@ export default {
     'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateIntrapageDotSwitcher',
     'generatePageLayout',
     'linkAlbum',
@@ -46,8 +46,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -106,7 +106,7 @@ export default {
         headingMode: 'static',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['top-index'],
         mainContent: [
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index d0788523..b9cf20dc 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -2,8 +2,8 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
     'generateAlbumArtworkColumn',
     'generateAlbumBanner',
     'generateAlbumNavAccent',
@@ -11,9 +11,10 @@ export default {
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumSocialEmbed',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateAlbumTrackList',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generatePageLayout',
     'linkAlbumCommentary',
@@ -26,8 +27,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     socialEmbed:
       relation('generateAlbumSocialEmbed', album),
@@ -55,6 +56,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', album),
+
     releaseInfo:
       relation('generateAlbumReleaseInfo', album),
 
@@ -72,16 +76,14 @@ export default {
       relation('generateAlbumTrackList', album),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        album,
-        album.additionalFiles),
+      relation('generateAdditionalFilesList', album.additionalFiles),
 
     artistCommentaryEntries:
       album.commentary
         .map(entry => relation('generateCommentaryEntry', entry)),
 
     creditSourceEntries:
-      album.creditSources
+      album.creditingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
@@ -106,7 +108,7 @@ export default {
 
         color: data.color,
         headingMode: 'sticky',
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         additionalNames: relations.additionalNamesBox,
 
@@ -159,11 +161,11 @@ export default {
                 : html.blank()),
 
               !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -172,14 +174,16 @@ export default {
 
           html.tag('p',
             {[html.onlyIfContent]: true},
-            {[html.joinChildren]: html.tag('br')},
 
-            language.encapsulate('releaseInfo', capsule => [
-              language.$(capsule, 'addedToWiki', {
-                [language.onlyIfOptions]: ['date'],
-                date: language.formatDate(data.dateAddedToWiki),
-              }),
-            ])),
+            language.$('releaseInfo.addedToWiki', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.dateAddedToWiki),
+            })),
+
+          (!html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditSourceEntries))
+          &&
+            html.tag('hr', {class: 'main-separator'}),
 
           language.encapsulate('releaseInfo.additionalFiles', capsule =>
             html.tags([
@@ -193,20 +197,20 @@ export default {
             ])),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
+                string: 'misc.artistCommentary',
               }),
 
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
index 7586393c..52c78dc2 100644
--- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencedArtworksPage',
     'linkAlbum',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencedArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
index d072d2f6..bc36ae06 100644
--- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencingArtworksPage',
     'linkAlbum',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencingArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 0abb412c..2a958244 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -14,15 +14,8 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.wallpaperArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
-
-    relations.bannerArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
-
-    relations.externalLinks =
-      album.urls.map(url =>
-        relation('linkExternal', url));
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', album);
 
     return relations;
   },
@@ -87,21 +80,16 @@ export default {
         html.tag('p',
           {[html.onlyIfContent]: true},
 
-          language.$(capsule, 'listenOn', {
-            [language.onlyIfOptions]: ['links'],
-
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
+          relations.listenLine.slots({
+            context: [
+              'album',
+
+              (data.numTracks === 0
+                ? 'albumNoTracks'
+             : data.numTracks === 1
+                ? 'albumOneTrack'
+                : 'albumMultipleTracks'),
+            ],
           })),
       ])),
 };
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 7cf689cc..464274e3 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -108,24 +108,29 @@ export default {
         : null),
   }),
 
-  data: (_query, _sprawl, _album, track) => ({
+  data: (_query, _sprawl, album, track) => ({
     isAlbumPage: !track,
     isTrackPage: !!track,
+
+    albumStyle: album.style,
   }),
 
   generate(data, relations, {html}) {
+    const presentGroupsLikeAlbum =
+      data.isAlbumPage ||
+      data.albumStyle === 'single';
+
     for (const box of [
       ...relations.groupBoxes,
       ...relations.seriesBoxes.flat(),
       ...relations.disconnectedSeriesBoxes,
     ]) {
-      box.setSlot('mode',
-        data.isAlbumPage ? 'album' : 'track');
+      box.setSlot('mode', presentGroupsLikeAlbum ? 'album' : 'track');
     }
 
     return relations.sidebar.slots({
       boxes: [
-        data.isAlbumPage && [
+        presentGroupsLikeAlbum && [
           relations.disconnectedSeriesBoxes,
 
           stitchArrays({
@@ -150,7 +155,7 @@ export default {
         data.isTrackPage &&
           relations.laterTrackReleaseBoxes,
 
-        data.isTrackPage &&
+        !presentGroupsLikeAlbum &&
           relations.conjoinedBox.slots({
             attributes: {class: 'conjoined-group-sidebar-box'},
             boxes:
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
index 3a244e3a..218e07ab 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackListBox.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -24,7 +24,9 @@ export default {
       attributes: {class: 'track-list-sidebar-box'},
 
       content: [
-        html.tag('h1', relations.albumLink),
+        html.tag('h1', {[html.onlyIfSiblings]: true},
+          relations.albumLink),
+
         relations.trackSections,
       ],
     })
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index dae5fa03..a158d2d4 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -22,10 +22,12 @@ export default {
       !empty(trackSection.tracks);
 
     data.isTrackPage = !!track;
+    data.albumStyle = album.style;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+    data.hasSiblingSections = album.trackSections.length > 1;
 
     data.firstTrackNumber =
       (data.hasTrackNumbers
@@ -115,6 +117,21 @@ export default {
                   : trackLink),
             })));
 
+    const list =
+      (data.hasTrackNumbers
+        ? html.tag('ol',
+            {start: data.firstTrackNumber},
+            trackListItems)
+        : html.tag('ul', trackListItems));
+
+    if (data.albumStyle === 'single' && !data.hasSiblingSections) {
+      if (trackListItems.length <= 1) {
+        return html.blank();
+      } else {
+        return list;
+      }
+    }
+
     return html.tag('details',
       data.includesCurrentTrack &&
         {class: 'current'},
@@ -157,11 +174,7 @@ export default {
                 return language.$(workingCapsule, workingOptions);
               })))),
 
-        (data.hasTrackNumbers
-          ? html.tag('ol',
-              {start: data.firstTrackNumber},
-              trackListItems)
-          : html.tag('ul', trackListItems)),
+        list,
       ]);
   },
 };
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
deleted file mode 100644
index 6bfcc62e..00000000
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-
-export default {
-  extraDependencies: ['to'],
-
-  data(album, track) {
-    const data = {};
-
-    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
-    data.hasBanner = !empty(album.bannerArtistContribs);
-
-    if (data.hasWallpaper) {
-      if (!empty(album.wallpaperParts)) {
-        data.wallpaperMode = 'parts';
-
-        data.wallpaperPaths =
-          album.wallpaperParts.map(part =>
-            (part.asset
-              ? ['media.albumWallpaperPart', album.directory, part.asset]
-              : null));
-
-        data.wallpaperStyles =
-          album.wallpaperParts.map(part => part.style);
-      } else {
-        data.wallpaperMode = 'one';
-        data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
-        data.wallpaperStyle = album.wallpaperStyle;
-      }
-    }
-
-    if (data.hasBanner) {
-      data.hasBannerStyle = !!album.bannerStyle;
-      data.bannerStyle = album.bannerStyle;
-    }
-
-    data.albumDirectory = album.directory;
-
-    if (track) {
-      data.trackDirectory = track.directory;
-    }
-
-    return data;
-  },
-
-  generate(data, {to}) {
-    const indent = parts =>
-      (parts ?? [])
-        .filter(Boolean)
-        .join('\n')
-        .split('\n')
-        .map(line => ' '.repeat(4) + line)
-        .join('\n');
-
-    const rule = (selector, parts) =>
-      (!empty(parts.filter(Boolean))
-        ? [`${selector} {`, indent(parts), `}`]
-        : []);
-
-    const oneWallpaperRule =
-      data.wallpaperMode === 'one' &&
-        rule(`body::before`, [
-          `background-image: url("${to(...data.wallpaperPath)}");`,
-          data.wallpaperStyle,
-        ]);
-
-    const wallpaperPartRules =
-      data.wallpaperMode === 'parts' &&
-        stitchArrays({
-          path: data.wallpaperPaths,
-          style: data.wallpaperStyles,
-        }).map(({path, style}, index) =>
-            rule(`.wallpaper-part:nth-child(${index + 1})`, [
-              path && `background-image: url("${to(...path)}");`,
-              style,
-            ]));
-
-    const nukeBasicWallpaperRule =
-      data.wallpaperMode === 'parts' &&
-        rule(`body::before`, ['display: none']);
-
-    const wallpaperRules = [
-      oneWallpaperRule,
-      ...wallpaperPartRules || [],
-      nukeBasicWallpaperRule,
-    ];
-
-    const bannerRule =
-      data.hasBanner &&
-        rule(`#banner img`, [
-          data.bannerStyle,
-        ]);
-
-    const dataRule =
-      rule(`:root`, [
-        data.albumDirectory &&
-          `--album-directory: ${data.albumDirectory};`,
-        data.trackDirectory &&
-          `--track-directory: ${data.trackDirectory};`,
-      ]);
-
-    return (
-      [...wallpaperRules, bannerRule, dataRule]
-        .filter(Boolean)
-        .flat()
-        .join('\n'));
-  },
-};
diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js
new file mode 100644
index 00000000..4cdc6581
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleTags.js
@@ -0,0 +1,65 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAlbumWallpaperStyleTag', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album, _track) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    wallpaperStyleTag:
+      relation('generateAlbumWallpaperStyleTag', album),
+  }),
+
+  data(album, track) {
+    const data = {};
+
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tags([
+      relations.wallpaperStyleTag,
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-banner-style'},
+
+        rules: [
+          data.hasBanner && {
+            select: '#banner img',
+            declare: [data.bannerStyle],
+          },
+        ],
+      }),
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-directory-style'},
+
+        rules: [
+          {
+            select: ':root',
+            declare: [
+              data.albumDirectory &&
+                `--album-directory: ${data.albumDirectory};`,
+              data.trackDirectory &&
+                `--track-directory: ${data.trackDirectory};`,
+            ],
+          },
+        ]
+      }),
+    ], {[html.joinChildren]: ''}),
+};
diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
new file mode 100644
index 00000000..47864a1d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    wallpaperStyleTag:
+      (album.hasWallpaperArt
+        ? relation('generateWallpaperStyleTag')
+        : null),
+  }),
+
+  data: (album) => ({
+    singleWallpaperPath:
+      ['media.albumWallpaper', album.directory, album.wallpaperFileExtension],
+
+    singleWallpaperStyle:
+      album.wallpaperStyle,
+
+    wallpaperPartPaths:
+      album.wallpaperParts.map(part =>
+        (part.asset
+          ? ['media.albumWallpaperPart', album.directory, part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      album.wallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations, {html}) =>
+    (relations.wallpaperStyleTag
+      ? relations.wallpaperStyleTag.slots({
+          singleWallpaperPath: data.singleWallpaperPath,
+          singleWallpaperStyle: data.singleWallpaperStyle,
+          wallpaperPartPaths: data.wallpaperPartPaths,
+          wallpaperPartStyles: data.wallpaperPartStyles,
+        })
+      : html.blank()),
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index bab32f7d..2d611ca6 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -162,33 +162,42 @@ export default {
       (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
       (data.normalContributionArtistsDifferFromContext);
 
+    let content;
+
     if (empty(relations.featuringContributionLinks)) {
       if (effectivelyDiffers) {
-        return language.$(slots.normalStringKey, {
-          ...slots.additionalStringOptions,
-          artists: artistsList,
-        });
+        content =
+          language.$(slots.normalStringKey, {
+            ...slots.additionalStringOptions,
+            artists: artistsList,
+          });
       } else {
         return html.blank();
       }
-    }
-
-    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
-      return language.$(slots.normalFeaturingStringKey, {
-        ...slots.additionalStringOptions,
-        artists: artistsList,
-        featuring: featuringList,
+    } else if (effectivelyDiffers && slots.normalFeaturingStringKey) {
+      content =
+        language.$(slots.normalFeaturingStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+          featuring: featuringList,
       });
     } else if (slots.featuringStringKey) {
-      return language.$(slots.featuringStringKey, {
-        ...slots.additionalStringOptions,
-        artists: featuringList,
-      });
+      content =
+        language.$(slots.featuringStringKey, {
+          ...slots.additionalStringOptions,
+          artists: featuringList,
+        });
     } else {
-      return language.$(slots.normalStringKey, {
-        ...slots.additionalStringOptions,
-        artists: everyoneList,
-      });
+      content =
+        language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: everyoneList,
+        });
     }
+
+    // TODO: This is obviously evil.
+    return (
+      html.metatag('chunkwrap', {split: /,| (?=and)/},
+        html.resolve(content)));
   },
 };
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
index 70296e39..1b9930ee 100644
--- a/src/content/dependencies/generateArtistCreditWikiEditsPart.js
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -48,6 +48,7 @@ export default {
                         showAnnotation: slots.showAnnotation,
                         trimAnnotation: true,
                         preventTooltip: true,
+                        preventWrapping: true,
                       }))),
                 }),
           }),
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 3e0cd1d2..e1fa7a0b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,83 +1,90 @@
-import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar';
 
 export default {
   contentDependencies: ['linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {
-      groupOrder: groupCategoryData.flatMap(category => category.groups),
-    }
-  },
+  sprawl: ({groupCategoryData}) => ({
+    groupOrder:
+      groupCategoryData.flatMap(category => category.groups),
+  }),
 
-  query(sprawl, tracksAndAlbums) {
-    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
-    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+  query(sprawl, contributions) {
+    const allGroupsUnordered =
+      new Set(contributions.flatMap(contrib => contrib.groups));
 
-    const allAlbums = unique([
-      ...filteredAlbums,
-      ...filteredTracks.map(track => track.album),
-    ]);
+    const allGroupsOrdered =
+      sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
 
-    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
-    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+    const groupToThingsCountedForContributions =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
-    const groupToCountMap = new Map(mapTemplate);
-    const groupToDurationMap = new Map(mapTemplate);
-    const groupToDurationCountMap = new Map(mapTemplate);
+    const groupToThingsCountedForDuration =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    for (const album of filteredAlbums) {
-      for (const group of album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-      }
-    }
+    for (const contrib of contributions) {
+      for (const group of contrib.groups) {
+        if (contrib.countInContributionTotals) {
+          groupToThingsCountedForContributions.get(group).add(contrib.thing);
+        }
 
-    for (const track of filteredTracks) {
-      for (const group of track.album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-        if (track.duration && track.mainReleaseTrack === null) {
-          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
-          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        if (contrib.countInDurationTotals) {
+          groupToThingsCountedForDuration.get(group).add(contrib.thing);
         }
       }
     }
 
+    const groupToTotalContributions =
+      withEntries(
+        groupToThingsCountedForContributions,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, things.size])));
+
+    const groupToTotalDuration =
+      withEntries(
+        groupToThingsCountedForDuration,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, accumulateSum(things, thing => thing.duration)])))
+
     const groupsSortedByCount =
       allGroupsOrdered
-        .slice()
-        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+        .filter(group => groupToTotalContributions.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalContributions.get(b)
+         - groupToTotalContributions.get(a)));
 
-    // The filter here ensures all displayed groups have at least some duration
-    // when sorting by duration.
     const groupsSortedByDuration =
       allGroupsOrdered
-        .filter(group => groupToDurationMap.get(group) > 0)
-        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+        .filter(group => groupToTotalDuration.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalDuration.get(b)
+         - groupToTotalDuration.get(a)));
 
     const groupCountsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     const groupCountsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     return {
       groupsSortedByCount,
@@ -93,29 +100,35 @@ export default {
     };
   },
 
-  relations(relation, query) {
-    return {
-      groupLinksSortedByCount:
-        query.groupsSortedByCount
-          .map(group => relation('linkGroup', group)),
+  relations: (relation, query) => ({
+    groupLinksSortedByCount:
+      query.groupsSortedByCount
+        .map(group => relation('linkGroup', group)),
 
-      groupLinksSortedByDuration:
-        query.groupsSortedByDuration
-          .map(group => relation('linkGroup', group)),
-    };
-  },
+    groupLinksSortedByDuration:
+      query.groupsSortedByDuration
+        .map(group => relation('linkGroup', group)),
+  }),
 
-  data(query) {
-    return filterProperties(query, [
-      'groupCountsSortedByCount',
-      'groupDurationsSortedByCount',
-      'groupDurationsApproximateSortedByCount',
+  data: (query) => ({
+    groupCountsSortedByCount:
+      query.groupCountsSortedByCount,
 
-      'groupCountsSortedByDuration',
-      'groupDurationsSortedByDuration',
-      'groupDurationsApproximateSortedByDuration',
-    ]);
-  },
+    groupDurationsSortedByCount:
+      query.groupDurationsSortedByCount,
+
+    groupDurationsApproximateSortedByCount:
+      query.groupDurationsApproximateSortedByCount,
+
+    groupCountsSortedByDuration:
+      query.groupCountsSortedByDuration,
+
+    groupDurationsSortedByDuration:
+      query.groupDurationsSortedByDuration,
+
+    groupDurationsApproximateSortedByDuration:
+      query.groupDurationsApproximateSortedByDuration,
+  }),
 
   slots: {
     title: {
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 3a3cf8b7..1f738de4 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -20,29 +20,17 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query: (artist) => ({
-    // Even if an artist has served as both "artist" (compositional) and
-    // "contributor" (instruments, production, etc) on the same track, that
-    // track only counts as one unique contribution in the list.
-    allTracks:
-      unique(
-        ([
-          artist.trackArtistContributions,
-          artist.trackContributorContributions,
-        ]).flat()
-          .map(({thing}) => thing)),
-
-    // Artworks are different, though. We intentionally duplicate album data
-    // objects when the artist has contributed some combination of cover art,
-    // wallpaper, and banner - these each count as a unique contribution.
-    allArtworkThings:
-      ([
-        artist.albumCoverArtistContributions,
-        artist.albumWallpaperArtistContributions,
-        artist.albumBannerArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
-        .map(({thing}) => thing.thing),
+    trackContributions: [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ],
+
+    artworkContributions: [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ],
 
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
@@ -93,7 +81,7 @@ export default {
       relation('generateArtistInfoPageTracksChunkedList', artist),
 
     tracksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allTracks),
+      relation('generateArtistGroupContributionsInfo', query.trackContributions),
 
     artworksChunkedList:
       relation('generateArtistInfoPageArtworksChunkedList', artist, false),
@@ -102,7 +90,7 @@ export default {
       relation('generateArtistInfoPageArtworksChunkedList', artist, true),
 
     artworksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+      relation('generateArtistGroupContributionsInfo', query.artworkContributions),
 
     artistGalleryLink:
       (query.hasGallery
@@ -128,7 +116,11 @@ export default {
         .map(({annotation}) => annotation),
 
     totalTrackCount:
-      query.allTracks.length,
+      unique(
+        query.trackContributions
+          .filter(contrib => contrib.countInContributionTotals)
+          .map(contrib => contrib.thing))
+        .length,
 
     totalDuration:
       artist.totalDuration,
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index 2f2fe0c5..cb436b0f 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -1,8 +1,11 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkTrack',
+    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -29,6 +32,9 @@ export default {
 
     otherArtistLinks:
       relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+
+    originDetails:
+      relation('transformContent', contrib.thing.originDetails),
   }),
 
   data: (query, contrib) => ({
@@ -37,6 +43,9 @@ export default {
 
     annotation:
       contrib.annotation,
+
+    label:
+      contrib.thing.label,
   }),
 
   slots: {
@@ -51,9 +60,33 @@ export default {
       otherArtistLinks: relations.otherArtistLinks,
 
       annotation:
-        (slots.filterEditsForWiki
-          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
-          : data.annotation),
+        language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => {
+          const workingOptions = {};
+
+          const artworkLabel = data.label;
+
+          if (artworkLabel) {
+            workingCapsule += '.withLabel';
+            workingOptions.label =
+              language.typicallyLowerCase(artworkLabel);
+          }
+
+          const contribAnnotation =
+            (slots.filterEditsForWiki
+              ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+              : data.annotation);
+
+          if (contribAnnotation) {
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = contribAnnotation;
+          }
+
+          if (empty(Object.keys(workingOptions))) {
+            return html.blank();
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
 
       content:
         language.encapsulate('artistPage.creditList.entry', capsule =>
@@ -68,5 +101,11 @@ export default {
                  : data.kind === 'banner'
                     ? language.$(capsule, 'bannerArt')
                     : language.$(capsule, 'coverArt')))))),
+
+      originDetails:
+        relations.originDetails.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        }),
     }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index 7987b642..c80aeab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -33,6 +33,11 @@ export default {
       type: 'html',
       mutable: false,
     },
+
+    originDetails: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate: (relations, slots, {html, language}) =>
@@ -40,52 +45,59 @@ export default {
       html.tag('li',
         slots.rerelease && {class: 'rerelease'},
 
-        language.encapsulate(entryCapsule, workingCapsule => {
-          const workingOptions = {entry: slots.content};
-
-          if (!html.isBlank(slots.rereleaseTooltip)) {
-            workingCapsule += '.rerelease';
-            workingOptions.rerelease =
-              relations.textWithTooltip.slots({
-                attributes: {class: 'rerelease'},
-                text: language.$(entryCapsule, 'rerelease.term'),
-                tooltip: slots.rereleaseTooltip,
-              });
-
-            return language.$(workingCapsule, workingOptions);
-          }
-
-          if (!html.isBlank(slots.firstReleaseTooltip)) {
-            workingCapsule += '.firstRelease';
-            workingOptions.firstRelease =
-              relations.textWithTooltip.slots({
-                attributes: {class: 'first-release'},
-                text: language.$(entryCapsule, 'firstRelease.term'),
-                tooltip: slots.firstReleaseTooltip,
-              });
-
-            return language.$(workingCapsule, workingOptions);
-          }
-
-          let anyAccent = false;
-
-          if (!empty(slots.otherArtistLinks)) {
-            anyAccent = true;
-            workingCapsule += '.withArtists';
-            workingOptions.artists =
-              language.formatConjunctionList(slots.otherArtistLinks);
-          }
-
-          if (!html.isBlank(slots.annotation)) {
-            anyAccent = true;
-            workingCapsule += '.withAnnotation';
-            workingOptions.annotation = slots.annotation;
-          }
-
-          if (anyAccent) {
-            return language.$(workingCapsule, workingOptions);
-          } else {
-            return slots.content;
-          }
-        }))),
+        html.tags([
+          language.encapsulate(entryCapsule, workingCapsule => {
+            const workingOptions = {entry: slots.content};
+
+            if (!html.isBlank(slots.rereleaseTooltip)) {
+              workingCapsule += '.rerelease';
+              workingOptions.rerelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'rerelease'},
+                  text: language.$(entryCapsule, 'rerelease.term'),
+                  tooltip: slots.rereleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            if (!html.isBlank(slots.firstReleaseTooltip)) {
+              workingCapsule += '.firstRelease';
+              workingOptions.firstRelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'first-release'},
+                  text: language.$(entryCapsule, 'firstRelease.term'),
+                  tooltip: slots.firstReleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            let anyAccent = false;
+
+            if (!empty(slots.otherArtistLinks)) {
+              anyAccent = true;
+              workingCapsule += '.withArtists';
+              workingOptions.artists =
+                language.formatConjunctionList(slots.otherArtistLinks);
+            }
+
+            if (!html.isBlank(slots.annotation)) {
+              anyAccent = true;
+              workingCapsule += '.withAnnotation';
+              workingOptions.annotation = slots.annotation;
+            }
+
+            if (anyAccent) {
+              return language.$(workingCapsule, workingOptions);
+            } else {
+              return slots.content;
+            }
+          }),
+
+          html.tag('span', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            slots.originDetails),
+        ]))),
 };
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
deleted file mode 100644
index c412b8f2..00000000
--- a/src/content/dependencies/generateColorStyleRules.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default {
-  contentDependencies: ['generateColorStyleVariables'],
-  extraDependencies: ['html'],
-
-  relations: (relation) => ({
-    variables:
-      relation('generateColorStyleVariables'),
-  }),
-
-  data: (color) => ({
-    color:
-      color ?? null,
-  }),
-
-  slots: {
-    color: {
-      validate: v => v.isColor,
-    },
-  },
-
-  generate(data, relations, slots) {
-    const color = data.color ?? slots.color;
-
-    if (!color) {
-      return '';
-    }
-
-    return [
-      `:root {`,
-      ...(
-        relations.variables
-          .slots({
-            color,
-            context: 'page-root',
-            mode: 'property-list',
-          })
-          .content
-          .map(line => line + ';')),
-      `}`,
-    ].join('\n');
-  },
-};
diff --git a/src/content/dependencies/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js
new file mode 100644
index 00000000..2b1a21dd
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleTag.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    variables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const color =
+      data.color ?? slots.color;
+
+    if (!color) {
+      return html.blank();
+    }
+
+    return relations.styleTag.slots({
+      attributes: [
+        {class: 'color-style'},
+        {'data-color': color},
+      ],
+
+      rules: [
+        {
+          select: ':root',
+          declare:
+            relations.variables.slots({
+              color,
+              context: 'page-root',
+              mode: 'declarations',
+            }).content,
+        },
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 5270dbe4..c872d0b6 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -18,7 +18,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('style', 'property-list'),
+      validate: v => v.is('style', 'declarations'),
       default: 'style',
     },
   },
@@ -50,15 +50,15 @@ export default {
       `--shadow-color: ${shadow}`,
     ];
 
-    let selectedProperties;
+    let selectedDeclarations;
 
     switch (slots.context) {
       case 'any-content':
-        selectedProperties = anyContent;
+        selectedDeclarations = anyContent;
         break;
 
       case 'image-box':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
           `--dim-color: ${dim}`,
           `--deep-color: ${deep}`,
@@ -67,14 +67,14 @@ export default {
         break;
 
       case 'page-root':
-        selectedProperties = [
+        selectedDeclarations = [
           ...anyContent,
           `--page-primary-color: ${primary}`,
         ];
         break;
 
       case 'primary-only':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
         ];
         break;
@@ -82,10 +82,10 @@ export default {
 
     switch (slots.mode) {
       case 'style':
-        return selectedProperties.join('; ');
+        return selectedDeclarations.join('; ');
 
-      case 'property-list':
-        return selectedProperties;
+      case 'declarations':
+        return selectedDeclarations.map(declaration => declaration + ';');
     }
   },
 };
diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js
new file mode 100644
index 00000000..314ef197
--- /dev/null
+++ b/src/content/dependencies/generateContentContentHeading.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _thing) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  data: (thing) => ({
+    name:
+      thing.name,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    string: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.contentHeading.slots({
+      attributes: slots.attributes,
+
+      title:
+        language.$(slots.string, {
+          thing:
+            html.tag('i', data.name),
+        }),
+
+      stickyTitle:
+        language.$(slots.string, 'sticky'),
+    }),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 2bff4643..78a6103b 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,5 +1,6 @@
 export default {
   contentDependencies: [
+    'generateColorStyleAttribute',
     'generateCoverArtworkArtTagDetails',
     'generateCoverArtworkArtistDetails',
     'generateCoverArtworkOriginDetails',
@@ -10,6 +11,9 @@ export default {
   extraDependencies: ['html'],
 
   relations: (relation, artwork) => ({
+    colorStyleAttribute:
+      relation('generateColorStyleAttribute'),
+
     image:
       relation('image', artwork),
 
@@ -30,6 +34,11 @@ export default {
     attachAbove:
       artwork.attachAbove,
 
+    attachedArtworkIsMainArtwork:
+      (artwork.attachAbove
+        ? artwork.attachedArtwork.isMainArtwork
+        : null),
+
     color:
       artwork.thing.color ?? null,
 
@@ -41,7 +50,8 @@ export default {
     alt: {type: 'string'},
 
     color: {
-      validate: v => v.isColor,
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: false,
     },
 
     mode: {
@@ -63,10 +73,7 @@ export default {
   generate(data, relations, slots, {html}) {
     const {image} = relations;
 
-    image.setSlots({
-      color: slots.color ?? data.color,
-      alt: slots.alt,
-    });
+    image.setSlot('alt', slots.alt);
 
     const square =
       (data.dimensions
@@ -79,6 +86,22 @@ export default {
       image.setSlot('dimensions', data.dimensions);
     }
 
+    const attributes = html.attributes();
+
+    let color = null;
+    if (typeof slots.color === 'boolean') {
+      if (slots.color) {
+        color = data.color;
+      }
+    } else if (slots.color) {
+      color = slots.color;
+    }
+
+    if (color) {
+      relations.colorStyleAttribute.setSlot('color', color);
+      attributes.add(relations.colorStyleAttribute);
+    }
+
     return html.tags([
       data.attachAbove &&
         html.tag('div', {class: 'cover-artwork-joiner'}),
@@ -87,6 +110,12 @@ export default {
         slots.mode === 'commentary' &&
           {class: 'commentary-art'},
 
+        data.attachAbove &&
+        data.attachedArtworkIsMainArtwork &&
+          {class: 'attached-artwork-is-main-artwork'},
+
+        attributes,
+
         (slots.mode === 'primary'
           ? [
               relations.image.slots({
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 3908414f..5a7768fc 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -29,6 +29,9 @@ export default {
     source:
       relation('transformContent', artwork.source),
 
+    originDetails:
+      relation('transformContent', artwork.originDetails),
+
     albumLink:
       (query.artworkThingType === 'album'
         ? relation('linkAlbum', artwork.thing)
@@ -47,6 +50,10 @@ export default {
 
     artworkThingType:
       query.artworkThingType,
+
+    forSingleStyleAlbum:
+      query.artworkThingType === 'album' &&
+      artwork.thing.style === 'single',
   }),
 
   generate: (data, relations, {html, language, pagePath}) =>
@@ -95,6 +102,7 @@ export default {
           const trackArtFromAlbum =
             pagePath[0] === 'track' &&
             data.artworkThingType === 'album' &&
+            !data.forSingleStyleAlbum &&
               language.$(capsule, 'trackArtFromAlbum', {
                 album:
                   relations.albumLink.slot('color', false),
@@ -146,12 +154,22 @@ export default {
               year: relations.datetimestamp,
             });
 
+          const originDetails =
+            html.tag('span', {class: 'origin-details'},
+              {[html.onlyIfContent]: true},
+
+              relations.originDetails.slots({
+                mode: 'inline',
+                absorbPunctuationFollowingExternalLinks: false,
+              }));
+
           return [
             artworkBy,
             trackArtFromAlbum,
             source,
             label,
             year,
+            originDetails,
           ];
         })())),
 };
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 29ac08b7..e4dfd905 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -15,6 +15,7 @@ export default {
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
+    notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)},
 
     // Differentiating from sparseArrayOf here - this list of classes should
     // have the same length as the items above, i.e. nulls aren't going to be
@@ -44,7 +45,18 @@ export default {
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({classes, image, link, name, info}, index) =>
+
+          notFromThisGroup:
+            slots.notFromThisGroup ??
+            Array.from(slots.links).fill(null)
+        }).map(({
+            classes,
+            image,
+            link,
+            name,
+            info,
+            notFromThisGroup,
+          }, index) =>
             link.slots({
               attributes: [
                 {class: ['grid-item', 'box']},
@@ -71,7 +83,15 @@ export default {
                 html.tag('span',
                   {[html.onlyIfContent]: true},
 
-                  language.sanitize(name)),
+                  (notFromThisGroup
+                    ? language.encapsulate('misc.coverGrid.details.notFromThisGroup', capsule =>
+                        language.$(capsule, {
+                          name,
+                          marker:
+                            html.tag('span', {class: 'grid-name-marker'},
+                              language.$(capsule, 'marker')),
+                        }))
+                    : language.sanitize(name))),
 
                 html.tag('span',
                   {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 095e43c4..ee043bfa 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -4,6 +4,7 @@ export default {
   contentDependencies: [
     'generateAdditionalNamesBox',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
@@ -53,6 +54,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', flash),
+
     flashActLink:
       relation('linkFlashAct', flash.act),
 
@@ -70,7 +74,7 @@ export default {
         .map(entry => relation('generateCommentaryEntry', entry)),
 
     creditSourceEntries:
-      flash.commentary
+      flash.creditingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
@@ -133,11 +137,11 @@ export default {
                   })),
 
               !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -168,20 +172,20 @@ export default {
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
+                string: 'misc.artistCommentary',
               }),
 
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 535a2bc9..dfdad0e8 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -134,6 +134,8 @@ export default {
 
             language.encapsulate(pageCapsule, 'albumViewSwitcher', capsule =>
               html.tag('p', {class: 'gallery-view-switcher'},
+                {class: ['drop', 'shiny']},
+
                 {[html.onlyIfContent]: true},
                 {[html.joinChildren]: html.tag('br')},
 
@@ -144,19 +146,19 @@ export default {
                     initialOptionIndex: 0,
 
                     titles: [
-                      !html.isBlank(relations.albumsBySeriesView) &&
-                        language.$(capsule, 'bySeries'),
-
                       !html.isBlank(relations.albumsByDateView) &&
                         language.$(capsule, 'byDate'),
-                    ].filter(Boolean),
 
-                    targetIDs: [
                       !html.isBlank(relations.albumsBySeriesView) &&
-                        'group-album-gallery-by-series',
+                        language.$(capsule, 'bySeries'),
+                    ].filter(Boolean),
 
+                    targetIDs: [
                       !html.isBlank(relations.albumsByDateView) &&
                         'group-album-gallery-by-date',
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        'group-album-gallery-by-series',
                     ].filter(Boolean),
                   }),
                 ])),
@@ -181,9 +183,9 @@ export default {
                 }))),
           */
 
-          relations.albumsBySeriesView,
+          relations.albumsByDateView,
 
-          relations.albumsByDateView.slots({
+          relations.albumsBySeriesView.slots({
             attributes: [
               !html.isBlank(relations.albumsBySeriesView) &&
                 {style: 'display: none'},
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
index 7015679b..7d9aa2d2 100644
--- a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
@@ -5,7 +5,7 @@ export default {
   contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
   extraDependencies: ['language'],
 
-  relations: (relation, albums) => ({
+  relations: (relation, albums, _group) => ({
     coverGrid:
       relation('generateCoverGrid'),
 
@@ -20,7 +20,7 @@ export default {
           : relation('image')))
   }),
 
-  data: (albums) => ({
+  data: (albums, group) => ({
     names:
       albums.map(album => album.name),
 
@@ -29,6 +29,9 @@ export default {
 
     tracks:
       albums.map(album => album.tracks.length),
+
+    notFromThisGroup:
+      albums.map(album => !album.groups.includes(group)),
   }),
 
   generate: (data, relations, {language}) =>
@@ -36,6 +39,7 @@ export default {
       relations.coverGrid.slots({
         links: relations.links,
         names: data.names,
+        notFromThisGroup: data.notFromThisGroup,
 
         images:
           stitchArrays({
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
index 54f4b8cb..b7d01eb5 100644
--- a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
@@ -9,9 +9,11 @@ export default {
       sortChronologically(group.albums, {latestFirst: true}),
   }),
 
-  relations: (relation, query, _group) => ({
+  relations: (relation, query, group) => ({
     albumGrid:
-      relation('generateGroupGalleryPageAlbumGrid', query.albums),
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albums,
+        group),
   }),
 
   slots: {
diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
index 3fe3fb45..2ccead5d 100644
--- a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
+++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
@@ -21,6 +21,9 @@ export default {
     query.allAlbumsDated =
       series.albums.every(album => album.date);
 
+    query.anyAlbumNotFromThisGroup =
+      series.albums.some(album => !album.groups.includes(series.group));
+
     query.latestAlbum =
       albumsLatestFirst
         .filter(album => album.date)
@@ -36,21 +39,28 @@ export default {
     return query;
   },
 
-  relations: (relation, query, _series) => ({
+  relations: (relation, query, series) => ({
     gallerySection:
       relation('generateExpandableGallerySection'),
 
     gridAboveCut:
-      relation('generateGroupGalleryPageAlbumGrid', query.albumsAboveCut),
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsAboveCut,
+        series.group),
 
     gridBelowCut:
-      relation('generateGroupGalleryPageAlbumGrid', query.albumsBelowCut),
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsBelowCut,
+        series.group),
   }),
 
   data: (query, series) => ({
     name:
       series.name,
 
+    groupName:
+      series.group.name,
+
     albums:
       series.albums.length,
 
@@ -62,6 +72,9 @@ export default {
     allAlbumsDated:
       query.allAlbumsDated,
 
+    anyAlbumNotFromThisGroup:
+      query.anyAlbumNotFromThisGroup,
+
     earliestAlbumDate:
       (query.earliestAlbum
         ? query.earliestAlbum.date
@@ -82,43 +95,57 @@ export default {
         contentBelowCut: relations.gridBelowCut,
 
         caption:
-          language.encapsulate(capsule, 'caption', workingCapsule => {
-            const workingOptions = {};
-
-            workingOptions.tracks =
-              html.tag('b',
-                language.countTracks(data.tracks, {unit: true}));
-
-            workingOptions.albums =
-              html.tag('b',
-                language.countAlbums(data.albums, {unit: true}));
-
-            if (data.allAlbumsDated) {
-              const earliestDate = data.earliestAlbumDate;
-              const latestDate = data.latestAlbumDate;
-
-              const earliestYear = earliestDate.getFullYear();
-              const latestYear = latestDate.getFullYear();
-
-              if (earliestYear === latestYear) {
-                if (data.albums === 1) {
-                  workingCapsule += '.withDate';
-                  workingOptions.date =
-                    language.formatDate(earliestDate);
-                } else {
-                  workingCapsule += '.withYear';
-                  workingOptions.year =
-                    language.formatYear(earliestDate);
+          language.encapsulate(capsule, 'caption', captionCapsule =>
+            html.tags([
+              data.anyAlbumNotFromThisGroup &&
+                language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
+                  marker:
+                    language.$('misc.coverGrid.details.notFromThisGroup.marker'),
+
+                  series:
+                    html.tag('i', data.name),
+
+                  group: data.groupName,
+                }),
+
+              language.encapsulate(captionCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.tracks =
+                  html.tag('b',
+                    language.countTracks(data.tracks, {unit: true}));
+
+                workingOptions.albums =
+                  html.tag('b',
+                    language.countAlbums(data.albums, {unit: true}));
+
+                if (data.allAlbumsDated) {
+                  const earliestDate = data.earliestAlbumDate;
+                  const latestDate = data.latestAlbumDate;
+
+                  const earliestYear = earliestDate.getFullYear();
+                  const latestYear = latestDate.getFullYear();
+
+                  if (earliestYear === latestYear) {
+                    if (data.albums === 1) {
+                      workingCapsule += '.withDate';
+                      workingOptions.date =
+                        language.formatDate(earliestDate);
+                    } else {
+                      workingCapsule += '.withYear';
+                      workingOptions.year =
+                        language.formatYear(earliestDate);
+                    }
+                  } else {
+                    workingCapsule += '.withYearRange';
+                    workingOptions.yearRange =
+                      language.formatYearRange(earliestDate, latestDate);
+                  }
                 }
-              } else {
-                workingCapsule += '.withYearRange';
-                workingOptions.yearRange =
-                  language.formatYearRange(earliestDate, latestDate);
-              }
-            }
-
-            return language.$(workingCapsule, workingOptions);
-          }),
+
+                return language.$(workingCapsule, workingOptions);
+              }),
+            ], {[html.joinChildren]: html.tag('br')})),
 
         expandCue:
           language.$(capsule, 'expand'),
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 4680cb46..cec18240 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,9 +127,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(artistCredit)));
+                artistCredit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
new file mode 100644
index 00000000..0a929429
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _album, additionalFiles) => ({
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      relations.chunk.slots({
+        title:
+          language.$(pageCapsule, 'albumFiles'),
+
+        stringsKey: slots.stringsKey,
+      })),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
new file mode 100644
index 00000000..a0af1375
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListAllAdditionalFilesAlbumChunk',
+    'generateListAllAdditionalFilesTrackChunk',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, property) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumChunk:
+      relation('generateListAllAdditionalFilesAlbumChunk',
+        album,
+        album[property] ?? []),
+
+    trackChunks:
+      album.tracks.map(track =>
+        relation('generateListAllAdditionalFilesTrackChunk',
+          track,
+          track[property] ?? [])),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tags([
+      relations.heading.slots({
+        tag: 'h3',
+        title: relations.albumLink,
+      }),
+
+      html.tag('dl',
+        {[html.onlyIfContent]: true},
+
+        [
+          relations.albumChunk.slot('stringsKey', slots.stringsKey),
+
+          relations.trackChunks.map(trackChunk =>
+            trackChunk.slot('stringsKey', slots.stringsKey)),
+        ]),
+    ]),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index deb8c4ea..df652efd 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -1,90 +1,99 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
+  contentDependencies: ['linkAdditionalFile'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation, additionalFiles) => ({
+    links:
+      additionalFiles
+        .map(file => file.filenames
+          .map(filename => relation('linkAdditionalFile', file, filename))),
+  }),
+
+  data: (additionalFiles) => ({
+    titles:
+      additionalFiles
+        .map(file => file.title),
+
+    filenames:
+      additionalFiles
+        .map(file => file.filenames),
+  }),
+
   slots: {
     title: {
       type: 'html',
       mutable: false,
     },
 
-    additionalFileTitles: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
-
-    additionalFileLinks: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)),
-    },
-
-    additionalFileFiles: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)),
-    },
-
     stringsKey: {type: 'string'},
   },
 
-  generate(slots, {html, language}) {
-    if (empty(slots.additionalFileLinks)) {
-      return html.blank();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      html.tags([
+        html.tag('dt',
+          {[html.onlyIfSiblings]: true},
+          slots.title),
 
-    return html.tags([
-      html.tag('dt', slots.title),
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            additionalFileTitle: slots.additionalFileTitles,
-            additionalFileLinks: slots.additionalFileLinks,
-            additionalFileFiles: slots.additionalFileFiles,
-          }).map(({
-              additionalFileTitle,
-              additionalFileLinks,
-              additionalFileFiles,
-            }) =>
-              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
-                (additionalFileLinks.length === 1
-                  ? html.tag('li',
-                      additionalFileLinks[0].slots({
-                        content:
-                          language.$(capsule, {
-                            title: additionalFileTitle,
-                          }),
-                      }))
+        html.tag('dd',
+          {[html.onlyIfContent]: true},
 
-               : additionalFileLinks.length === 0
-                  ? html.tag('li',
-                      language.$(capsule, 'withNoFiles', {
-                        title: additionalFileTitle,
-                      }))
+          html.tag('ul',
+          {[html.onlyIfContent]: true},
 
-                  : html.tag('li', {class: 'has-details'},
-                      html.tag('details', [
-                        html.tag('summary',
-                          html.tag('span',
-                            language.$(capsule, 'withMultipleFiles', {
-                              title:
-                                html.tag('b', additionalFileTitle),
+            stitchArrays({
+              title: data.titles,
+              links: relations.links,
+              filenames: data.filenames,
+            }).map(({
+                title,
+                links,
+                filenames,
+              }) =>
+                language.encapsulate(pageCapsule, 'file', capsule =>
+                  (links.length === 1
+                    ? html.tag('li',
+                        links[0].slots({
+                          content:
+                            language.$(capsule, {
+                              title: title,
+                            }),
+                        }))
 
-                              files:
-                                language.countAdditionalFiles(
-                                  additionalFileLinks.length,
-                                  {unit: true}),
-                            }))),
+                 : links.length === 0
+                    ? html.tag('li',
+                        language.$(capsule, 'withNoFiles', {
+                          title: title,
+                        }))
 
-                        html.tag('ul',
-                          stitchArrays({
-                            additionalFileLink: additionalFileLinks,
-                            additionalFileFile: additionalFileFiles,
-                          }).map(({additionalFileLink, additionalFileFile}) =>
-                              html.tag('li',
-                                additionalFileLink.slots({
-                                  content:
-                                    language.$(capsule, {
-                                      title: additionalFileFile,
-                                    }),
-                                })))),
-                      ]))))))),
-    ]);
-  },
+                    : html.tag('li', {class: 'has-details'},
+                        html.tag('details', [
+                          html.tag('summary',
+                            html.tag('span',
+                              language.$(capsule, 'withMultipleFiles', {
+                                title:
+                                  html.tag('b', title),
+
+                                files:
+                                  language.countAdditionalFiles(
+                                    links.length,
+                                    {unit: true}),
+                              }))),
+
+                          html.tag('ul',
+                            stitchArrays({
+                              link: links,
+                              filename: filenames,
+                            }).map(({link, filename}) =>
+                                html.tag('li',
+                                  link.slots({
+                                    content:
+                                      language.$(capsule, {
+                                        title: filename,
+                                      }),
+                                  })))),
+                        ]))))))),
+      ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
new file mode 100644
index 00000000..b2e5addf
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track, additionalFiles) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.chunk.slots({
+      title: relations.trackLink,
+      stringsKey: slots.stringsKey,
+    }),
+};
+
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
index 02fd3634..0c91ce0c 100644
--- a/src/content/dependencies/generateLyricsEntry.js
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -17,6 +17,9 @@ export default {
     sourceLinks:
       entry.sourceURLs
         .map(url => relation('linkExternal', url)),
+
+    originDetails:
+      relation('transformContent', entry.originDetails),
   }),
 
   data: (entry) => ({
@@ -75,6 +78,14 @@ export default {
                 language.$(capsule, 'squareBracketAnnotations'),
             ]),
 
+          html.tag('p', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            relations.originDetails.slots({
+              mode: 'inline',
+              absorbPunctuationFollowingExternalLinks: false,
+            })),
+
           relations.content.slot('mode', 'lyrics'),
         ])),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 89fefb23..0326f415 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -3,12 +3,14 @@ import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleRules',
+    'generateColorStyleTag',
     'generateFooterLocalizationLinks',
     'generateImageOverlay',
     'generatePageSidebar',
     'generateSearchSidebarBox',
+    'generateStaticURLStyleTag',
     'generateStickyHeadingContainer',
+    'generateWikiWallpaperStyleTag',
     'transformContent',
   ],
 
@@ -58,8 +60,14 @@ export default {
         relation('transformContent', sprawl.footerContent);
     }
 
-    relations.colorStyleRules =
-      relation('generateColorStyleRules');
+    relations.colorStyleTag =
+      relation('generateColorStyleTag');
+
+    relations.staticURLStyleTag =
+      relation('generateStaticURLStyleTag');
+
+    relations.wikiWallpaperStyleTag =
+      relation('generateWikiWallpaperStyleTag');
 
     relations.imageOverlay =
       relation('generateImageOverlay');
@@ -107,9 +115,9 @@ export default {
 
     color: {validate: v => v.isColor},
 
-    styleRules: {
-      validate: v => v.sparseArrayOf(v.isHTML),
-      default: [],
+    styleTags: {
+      type: 'html',
+      mutable: false,
     },
 
     mainClasses: {
@@ -581,29 +589,33 @@ export default {
               {id: 'additional-files', string: 'additionalFiles'},
               {id: 'commentary', string: 'commentary'},
               {id: 'artist-commentary', string: 'artistCommentary'},
-              {id: 'credit-sources', string: 'creditSources'},
+              {id: 'crediting-sources', string: 'creditingSources'},
+              {id: 'referencing-sources', string: 'referencingSources'},
             ])),
         ]);
 
-    const styleRulesCSS =
-      html.resolve(slots.styleRules, {normalize: 'string'});
+    const slottedStyleTags =
+      html.smush(slots.styleTags);
 
-    const fallbackBackgroundStyleRule =
-      (styleRulesCSS.match(/body::before[^}]*background-image:/)
-        ? ''
-        : `body::before {\n` +
-          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
-          `}`);
+    const slottedWallpaperStyleTag =
+      slottedStyleTags.content
+        .find(tag => tag.attributes.has('class', 'wallpaper-style'));
 
-    const goshFrigginDarnitStyleRule =
-      `.image-media-link::after {\n` +
-      `    mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` +
-      `}`;
+    const fallbackWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? html.blank()
+        : relations.wikiWallpaperStyleTag);
+
+    const usingWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? slottedWallpaperStyleTag
+        : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'}));
 
     const numWallpaperParts =
-      html.resolve(slots.styleRules, {normalize: 'string'})
-        .match(/\.wallpaper-part:nth-child/g)
-        ?.length ?? 0;
+      (usingWallpaperStyleTag &&
+       usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts')
+        ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts'))
+        : 0);
 
     const wallpaperPartsHTML =
       html.tag('div', {class: 'wallpaper-parts'},
@@ -745,14 +757,14 @@ export default {
               href: to('staticCSS.path', 'site.css'),
             }),
 
-            html.tag('style', [
-              relations.colorStyleRules
-                .slot('color', slots.color ?? data.wikiColor),
+            relations.colorStyleTag
+              .slot('color', slots.color ?? data.wikiColor),
 
-              fallbackBackgroundStyleRule,
-              goshFrigginDarnitStyleRule,
-              slots.styleRules,
-            ]),
+            relations.staticURLStyleTag,
+
+            fallbackWallpaperStyleTag,
+
+            slottedStyleTags,
 
             html.tag('script', {
               src: to('staticLib.path', 'chroma-js/chroma.min.js'),
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
index 154b4762..83451eca 100644
--- a/src/content/dependencies/generateReferencedArtworksPage.js
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -47,7 +47,7 @@ export default {
   }),
 
   slots: {
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
 
@@ -62,7 +62,7 @@ export default {
         subtitle: language.$(pageCapsule, 'subtitle'),
 
         color: data.color,
-        styleRules: slots.styleRules,
+        styleTags: slots.styleTags,
 
         artworkColumnContent:
           relations.cover.slots({
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
index 55977b37..e97b01f8 100644
--- a/src/content/dependencies/generateReferencingArtworksPage.js
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -47,7 +47,7 @@ export default {
   }),
 
   slots: {
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
 
@@ -62,7 +62,7 @@ export default {
         subtitle: language.$(pageCapsule, 'subtitle'),
 
         color: data.color,
-        styleRules: slots.styleRules,
+        styleTags: slots.styleTags,
 
         artworkColumnContent:
           relations.cover.slots({
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..b02ff6f9
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,159 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.urls =
+      (!empty(thing.urls)
+        ? thing.urls
+     : thing.album &&
+       thing.album.style === 'single' &&
+       thing.album.tracks[0] === thing
+        ? thing.album.urls
+        : []);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, query, _thing) => ({
+    links:
+      query.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      query.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        query.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 226152c7..931352b4 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -23,17 +23,19 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        styleRules:
-          (data.stylesheet
-            ? [data.stylesheet]
-            : []),
+        styleTags: [
+          html.tag('style', {class: 'static-page-style'},
+            {[html.onlyIfContent]: true},
+            data.stylesheet),
+        ],
 
         mainClasses: ['long-content'],
         mainContent: [
           relations.content,
 
-          data.script &&
-            html.tag('script', data.script),
+          html.tag('script',
+            {[html.onlyIfContent]: true},
+            data.script),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js
new file mode 100644
index 00000000..b927e5d6
--- /dev/null
+++ b/src/content/dependencies/generateStaticURLStyleTag.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  generate: (relations, {to}) =>
+    relations.styleTag.slots({
+      attributes: {class: 'static-url-style'},
+
+      rules: [
+        {
+          select: '.image-media-link::after',
+          declare: [
+            `mask-image: url("${to('staticMisc.path', 'image.svg')}");`
+          ],
+        },
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js
new file mode 100644
index 00000000..5ed09ae5
--- /dev/null
+++ b/src/content/dependencies/generateStyleTag.js
@@ -0,0 +1,48 @@
+import {empty} from '#sugar';
+
+const indent = text =>
+  text
+    .split('\n')
+    .map(line => ' '.repeat(4) + line)
+    .join('\n');
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    rules: {
+      validate: v =>
+        v.looseArrayOf(
+          v.validateProperties({
+            select: v.isString,
+            declare: v.looseArrayOf(v.isString),
+          })),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('style', slots.attributes,
+      {[html.onlyIfContent]: true},
+
+      slots.rules
+        .filter(Boolean)
+
+        .map(rule => ({
+          select: rule.select,
+          declare: rule.declare.filter(Boolean),
+        }))
+
+        .filter(rule => !empty(rule.declare))
+
+        .map(rule =>
+          `${rule.select} {\n` +
+          indent(rule.declare.join('\n')) + '\n' +
+          `}`)
+
+        .join('\n\n')),
+};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
index e3041d3a..c7e7f0f8 100644
--- a/src/content/dependencies/generateTrackArtistCommentarySection.js
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -2,8 +2,8 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateContentContentHeading',
     'generateCommentaryEntry',
-    'generateContentHeading',
     'linkAlbum',
     'linkTrack',
   ],
@@ -18,8 +18,8 @@ export default {
   }),
 
   relations: (relation, query, track) => ({
-    contentHeading:
-      relation('generateContentHeading'),
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
 
     mainReleaseTrackLink:
       (track.isSecondaryRelease
@@ -78,54 +78,44 @@ export default {
   generate: (data, relations, {html, language}) =>
     language.encapsulate('misc.artistCommentary', capsule =>
       html.tags([
-        relations.contentHeading.clone()
-          .slots({
-            attributes: {id: 'artist-commentary'},
-            title: language.$('misc.artistCommentary'),
-          }),
+        relations.contentContentHeading.slots({
+          attributes: {id: 'artist-commentary'},
+          string: 'misc.artistCommentary',
+        }),
+
+        relations.artistCommentaryEntries,
 
         data.isSecondaryRelease &&
-          html.tags([
-            html.tag('p', {class: ['drop', 'commentary-drop']},
-              {[html.onlyIfSiblings]: true},
-
-              language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
-                const workingOptions = {};
-
-                workingOptions.album =
-                  relations.mainReleaseTrackLink.slots({
-                    content:
-                      data.mainReleaseAlbumName,
-
-                    color:
-                      data.mainReleaseAlbumColor,
-                  });
-
-                if (data.name !== data.mainReleaseName) {
-                  workingCapsule += '.namedDifferently';
-                  workingOptions.name =
-                    html.tag('i', data.mainReleaseName);
-                }
-
-                return language.$(workingCapsule, workingOptions);
-              })),
-
-            relations.mainReleaseArtistCommentaryEntries,
-          ]),
-
-        html.tags([
-          data.isSecondaryRelease &&
-          !html.isBlank(relations.mainReleaseArtistCommentaryEntries) &&
-            html.tag('p', {class: ['drop', 'commentary-drop']},
-              {[html.onlyIfSiblings]: true},
-
-              language.$(capsule, 'info.releaseSpecific', {
-                album:
-                  relations.thisReleaseAlbumLink,
-              })),
-
-          relations.artistCommentaryEntries,
-        ]),
+          html.tag('div', {class: 'inherited-commentary-section'},
+            {[html.onlyIfContent]: true},
+
+            [
+              html.tag('p', {class: ['drop', 'commentary-drop']},
+                {[html.onlyIfSiblings]: true},
+
+                language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                  const workingOptions = {};
+
+                  workingOptions.album =
+                    relations.mainReleaseTrackLink.slots({
+                      content:
+                        data.mainReleaseAlbumName,
+
+                      color:
+                        data.mainReleaseAlbumColor,
+                    });
+
+                  if (data.name !== data.mainReleaseName) {
+                    workingCapsule += '.namedDifferently';
+                    workingOptions.name =
+                      html.tag('i', data.mainReleaseName);
+                  }
+
+                  return language.$(workingCapsule, workingOptions);
+                })),
+
+              relations.mainReleaseArtistCommentaryEntries,
+            ]),
 
         html.tag('p', {class: ['drop', 'commentary-drop']},
           {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 11d179ad..8556f6cf 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,12 +1,14 @@
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateLyricsSection',
@@ -32,14 +34,22 @@ export default {
       (track.isMainRelease
         ? track
         : track.mainReleaseTrack),
+
+    singleTrackSingle:
+      track.album.style === 'single' &&
+      track.album.tracks.length === 1,
+
+    firstTrackInSingle:
+      track.album.style === 'single' &&
+      track === track.album.tracks[0],
   }),
 
   relations: (relation, query, track) => ({
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     socialEmbed:
       relation('generateTrackSocialEmbed', track),
@@ -47,6 +57,9 @@ export default {
     navLinks:
       relation('generateTrackNavLinks', track),
 
+    albumNavLink:
+      relation('linkAlbum', track.album),
+
     albumNavAccent:
       relation('generateAlbumNavAccent', track.album, track),
 
@@ -60,11 +73,16 @@ export default {
       relation('generateAdditionalNamesBox', track.additionalNames),
 
     artworkColumn:
-      relation('generateTrackArtworkColumn', track),
+      (query.firstTrackInSingle
+        ? relation('generateAlbumArtworkColumn', track.album)
+        : relation('generateTrackArtworkColumn', track)),
 
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
     releaseInfo:
       relation('generateTrackReleaseInfo', track),
 
@@ -75,18 +93,20 @@ export default {
       relation('generateContributionList', track.contributorContribs),
 
     referencedTracksList:
-      relation('generateTrackList', track.referencedTracks),
+      relation('generateTrackList', track.referencedTracks, track),
 
     sampledTracksList:
-      relation('generateTrackList', track.sampledTracks),
+      relation('generateTrackList', track.sampledTracks, track),
 
     referencedByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.referencedByTracks),
+        query.mainReleaseTrack.referencedByTracks,
+        track),
 
     sampledByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.sampledByTracks),
+        query.mainReleaseTrack.sampledByTracks,
+        track),
 
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
@@ -95,34 +115,41 @@ export default {
       relation('generateLyricsSection', track.lyrics),
 
     sheetMusicFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.sheetMusicFiles),
+      relation('generateAdditionalFilesList', track.sheetMusicFiles),
 
     midiProjectFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.midiProjectFiles),
+      relation('generateAdditionalFilesList', track.midiProjectFiles),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.additionalFiles),
+      relation('generateAdditionalFilesList', track.additionalFiles),
 
     artistCommentarySection:
       relation('generateTrackArtistCommentarySection', track),
 
-    creditSourceEntries:
-      track.creditSources
+    creditingSourceEntries:
+      track.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    referencingSourceEntries:
+      track.referencingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
-  data: (_query, track) => ({
+  data: (query, track) => ({
     name:
       track.name,
 
     color:
       track.color,
+
+    dateAlbumAddedToWiki:
+      track.album.dateAddedToWiki,
+
+    singleTrackSingle:
+      query.singleTrackSingle,
+
+    firstTrackInSingle:
+      query.firstTrackInSingle,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -138,7 +165,7 @@ export default {
         additionalNames: relations.additionalNamesBox,
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         artworkColumnContent:
           relations.artworkColumn,
@@ -187,12 +214,21 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+              !html.isBlank(relations.creditingSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#crediting-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.referencingSourceEntries) &&
+                language.encapsulate(capsule, 'readReferencingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#referencing-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -309,6 +345,22 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
+          data.firstTrackInSingle &&
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.$('releaseInfo.addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAlbumAddedToWiki),
+              })),
+
+          data.firstTrackInSingle &&
+          (!html.isBlank(relations.lyricsSection) ||
+           !html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditingSourceEntries) ||
+           !html.isBlank(relations.referencingSourceEntries)) &&
+            html.tag('hr', {class: 'main-separator'}),
+
           relations.lyricsSection,
 
           html.tags([
@@ -344,28 +396,49 @@ export default {
           relations.artistCommentarySection,
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
-            relations.creditSourceEntries,
+            relations.creditingSourceEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'referencing-sources'},
+                string: 'misc.referencingSources',
+              }),
+
+            relations.referencingSourceEntries,
           ]),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: html.resolve(relations.navLinks),
+        navLinks:
+          (data.singleTrackSingle
+            ? [
+                {auto: 'home'},
+                {
+                  html: relations.albumNavLink,
+                  accent: language.$(pageCapsule, 'nav.singleAccent'),
+                },
+              ]
+            : html.resolve(relations.navLinks)),
 
         navBottomRowContent:
-          relations.albumNavAccent.slots({
-            showTrackNavigation: true,
-            showExtraLinks: false,
-          }),
+          (data.singleTrackSingle
+            ? null
+            : relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: false,
+              })),
 
         secondaryNav:
           relations.secondaryNav
-            .slot('mode', 'track'),
+            .slot('mode', data.singleTrackSingle ? 'album' : 'track'),
 
         leftSidebar: relations.sidebar,
 
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 53a32536..f3ada092 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -2,9 +2,16 @@ export default {
   contentDependencies: ['generateTrackListItem'],
   extraDependencies: ['html'],
 
-  relations: (relation, tracks) => ({
+  query: (tracks, contextTrack) => ({
+    presentedTracks:
+      tracks.map(track =>
+        track.otherReleases.find(({album}) => album === contextTrack.album) ??
+        track),
+  }),
+
+  relations: (relation, query, _tracks, _contextTrack) => ({
     items:
-      tracks
+      query.presentedTracks
         .map(track => relation('generateTrackListItem', track, [])),
   }),
 
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index 230868d6..9deccc0c 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -14,7 +14,7 @@ export default {
       wikiInfo.divideTrackListsByGroups,
   }),
 
-  query(sprawl, tracks) {
+  query(sprawl, tracks, _contextTrack) {
     const dividingGroups = sprawl.divideTrackListsByGroups;
 
     const groupings = new Map();
@@ -50,10 +50,10 @@ export default {
     return {groups, groupedTracks, ungroupedTracks};
   },
 
-  relations: (relation, query, sprawl, tracks) => ({
+  relations: (relation, query, sprawl, tracks, contextTrack) => ({
     flatList:
       (empty(sprawl.divideTrackListsByGroups)
-        ? relation('generateTrackList', tracks)
+        ? relation('generateTrackList', tracks, contextTrack)
         : null),
 
     contentHeading:
@@ -65,12 +65,12 @@ export default {
 
     groupedTrackLists:
       query.groupedTracks
-        .map(tracks => relation('generateTrackList', tracks)),
+        .map(tracks => relation('generateTrackList', tracks, contextTrack)),
 
     ungroupedTrackList:
       (empty(query.ungroupedTracks)
         ? null
-        : relation('generateTrackList', query.ungroupedTracks)),
+        : relation('generateTrackList', query.ungroupedTracks, contextTrack)),
   }),
 
   data: (query, _sprawl, _tracks) => ({
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 3c850a18..5678e240 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,9 +97,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(relations.credit)));
+                relations.credit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
index 93438c5b..7073409e 100644
--- a/src/content/dependencies/generateTrackReferencedArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencedArtworksPage',
     'generateTrackNavLinks',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencedArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
index e9818bad..a45144c8 100644
--- a/src/content/dependencies/generateTrackReferencingArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencingArtworksPage',
     'generateTrackNavLinks',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencingArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 54e462c7..3298dcc4 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -1,9 +1,7 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -11,14 +9,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.artistContributionLinks =
+    relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (!empty(track.urls)) {
-      relations.externalLinks =
-        track.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', track);
 
     return relations;
   },
@@ -48,7 +43,7 @@ export default {
           {[html.joinChildren]: html.tag('br')},
 
           [
-            relations.artistContributionLinks.slots({
+            relations.artistContributionsLine.slots({
               stringKey: capsule + '.by',
               featuringStringKey: capsule + '.by.featuring',
               chronologyKind: 'track',
@@ -66,17 +61,9 @@ export default {
           ]),
 
         html.tag('p',
-          language.encapsulate(capsule, 'listenOn', capsule =>
-            (relations.externalLinks
-              ? language.$(capsule, {
-                  links:
-                    language.formatDisjunctionList(
-                      relations.externalLinks
-                        .map(link => link.slot('context', 'track'))),
-                })
-              : language.$(capsule, 'noLinks', {
-                  name:
-                    html.tag('i', data.name),
-                })))),
+          relations.listenLine.slots({
+            visibleWithoutLinks: true,
+            context: ['track'],
+          })),
       ])),
 };
diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js
new file mode 100644
index 00000000..bf094300
--- /dev/null
+++ b/src/content/dependencies/generateWallpaperStyleTag.js
@@ -0,0 +1,80 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['html', 'to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  slots: {
+    singleWallpaperPath: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+
+    singleWallpaperStyle: {
+      validate: v => v.isString,
+    },
+
+    wallpaperPartPaths: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))),
+    },
+
+    wallpaperPartStyles: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.isString)),
+    },
+  },
+
+  generate(relations, slots, {html, to}) {
+    const attributes = html.attributes();
+    const rules = [];
+
+    attributes.add('class', 'wallpaper-style');
+
+    if (empty(slots.wallpaperPartPaths)) {
+      attributes.set('data-wallpaper-mode', 'one');
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          `background-image: url("${to(...slots.singleWallpaperPath)}");`,
+          slots.singleWallpaperStyle,
+        ],
+      });
+    } else {
+      attributes.set('data-wallpaper-mode', 'parts');
+      attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length);
+
+      stitchArrays({
+        path: slots.wallpaperPartPaths,
+        style: slots.wallpaperPartStyles,
+      }).forEach(({path, style}, index) => {
+          rules.push({
+            select: `.wallpaper-part:nth-child(${index + 1})`,
+            declare: [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ],
+          });
+        });
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          'display: none;',
+        ],
+      });
+    }
+
+    relations.styleTag.setSlots({
+      attributes,
+      rules,
+    });
+
+    return relations.styleTag;
+  },
+};
diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js
new file mode 100644
index 00000000..12d27304
--- /dev/null
+++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  relations: (relation) => ({
+    wallpaperStyleTag:
+      relation('generateWallpaperStyleTag'),
+  }),
+
+  data: ({wikiInfo}) => ({
+    singleWallpaperPath: [
+      'media.path',
+      'bg.' + wikiInfo.wikiWallpaperFileExtension,
+    ],
+
+    singleWallpaperStyle:
+      wikiInfo.wikiWallpaperStyle,
+
+    wallpaperPartPaths:
+      wikiInfo.wikiWallpaperParts.map(part =>
+        (part.asset
+          ? ['media.path', part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      wikiInfo.wikiWallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations) =>
+    relations.wallpaperStyleTag.slots({
+      singleWallpaperPath: data.singleWallpaperPath,
+      singleWallpaperStyle: data.singleWallpaperStyle,
+      wallpaperPartPaths: data.wallpaperPartPaths,
+      wallpaperPartStyles: data.wallpaperPartStyles,
+    }),
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index a5009804..25d7324f 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli';
 import contentFunction, {ContentFunctionSpecError} from '#content-function';
 import {annotateFunction} from '#sugar';
 
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = path.resolve(__dirname, '..');
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
 function cachebust(filePath) {
   if (filePath in cachebust.cache) {
     cachebust.cache[filePath] += 1;
@@ -42,7 +47,9 @@ export function watchContentDependencies({
     close,
   });
 
-  const eslint = new ESLint();
+  const eslint = new ESLint({
+    cwd: codeRootPath,
+  });
 
   const metaPath = fileURLToPath(import.meta.url);
   const metaDirname = path.dirname(metaPath);
diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js
new file mode 100644
index 00000000..a8a940b1
--- /dev/null
+++ b/src/content/dependencies/linkAdditionalFile.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  query: (file, filename) => ({
+    index:
+      file.filenames.indexOf(filename),
+  }),
+
+  relations: (relation, _query, _file, _filename) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+  }),
+
+  data: (query, file, filename) => ({
+    filename,
+
+    // Kinda jank, but eh.
+    path:
+      (query.index >= 0
+        ? file.paths.at(query.index)
+        : null),
+  }),
+
+  generate: (data, relations) =>
+    relations.linkTemplate.slots({
+      path: data.path,
+      content: data.filename,
+    }),
+};
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
index 36b0d13a..cdfe65d3 100644
--- a/src/content/dependencies/linkAlbum.js
+++ b/src/content/dependencies/linkAlbum.js
@@ -1,8 +1,12 @@
 export default {
-  contentDependencies: ['linkThing'],
+  contentDependencies: ['linkThing', 'linkTrack'],
 
-  relations: (relation, album) =>
-    ({link: relation('linkThing', 'localized.album', album)}),
+  relations: (relation, album) => ({
+    link:
+      (album.style === 'single'
+        ? relation('linkTrack', album.tracks[0])
+        : relation('linkThing', 'localized.album', album)),
+  }),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
deleted file mode 100644
index 39e7111e..00000000
--- a/src/content/dependencies/linkAlbumAdditionalFile.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default {
-  contentDependencies: ['linkTemplate'],
-
-  relations(relation) {
-    return {
-      linkTemplate: relation('linkTemplate'),
-    };
-  },
-
-  data(album, file) {
-    return {
-      albumDirectory: album.directory,
-      file,
-    };
-  },
-
-  generate(data, relations) {
-    return relations.linkTemplate
-      .slots({
-        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
-        content: data.file,
-      });
-  },
-};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index c658d461..1db0373b 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -30,7 +30,7 @@ export default {
 
     trimAnnotation: {type: 'boolean', default: false},
 
-    preventWrapping: {type: 'boolean', default: true},
+    preventWrapping: {type: 'boolean', default: false},
     preventTooltip: {type: 'boolean', default: false},
 
     chronologyKind: {type: 'string'},
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index c132baaf..45c08a08 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -55,7 +55,7 @@ export default {
     try {
       new URL(data.url);
       urlIsValid = true;
-    } catch (error) {
+    } catch {
       urlIsValid = false;
     }
 
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index e33ad7b5..8ec69f1d 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -1,209 +1,44 @@
 import {sortChronologically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListAllAdditionalFilesChunk',
-    'linkAlbum',
-    'linkTrack',
-    'linkAlbumAdditionalFile',
+    'generateListAllAdditionalFilesAlbumSection',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'wikiData'],
 
   sprawl: ({albumData}) => ({albumData}),
 
-  query(sprawl, spec, property) {
-    const albums =
-      sortChronologically(sprawl.albumData.slice());
+  query: (sprawl, spec, property) => ({
+    spec,
+    property,
 
-    const tracks =
-      albums
-        .map(album => album.tracks.slice());
-
-    // Get additional file objects from albums and their tracks.
-    // There's a possibility that albums and tracks don't both implement
-    // the same additional file fields - in this case, just treat them
-    // as though they do implement those fields, but don't have any
-    // additional files of that type.
-
-    const albumAdditionalFileObjects =
-      albums
-        .map(album => album[property] ?? []);
-
-    const trackAdditionalFileObjects =
-      tracks
-        .map(byAlbum => byAlbum
-          .map(track => track[property] ?? []));
-
-    // Filter out tracks that don't have any additional files.
-
-    stitchArrays({tracks, trackAdditionalFileObjects})
-      .forEach(({tracks, trackAdditionalFileObjects}) => {
-        filterMultipleArrays(tracks, trackAdditionalFileObjects,
-          (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects));
-      });
-
-    // Filter out albums that don't have any tracks,
-    // nor any additional files of their own.
-
-    filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects,
-      (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) =>
-        !empty(albumAdditionalFileObjects) ||
-        !empty(trackAdditionalFileObjects));
-
-    // Map additional file objects into titles and lists of file names.
-
-    const albumAdditionalFileTitles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({title}) => title));
-
-    const albumAdditionalFileFiles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({files}) => files ?? []));
-
-    const trackAdditionalFileTitles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({title}) => title)));
-
-    const trackAdditionalFileFiles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({files}) => files ?? [])));
-
-    return {
-      spec,
-      albums,
-      tracks,
-      albumAdditionalFileTitles,
-      albumAdditionalFileFiles,
-      trackAdditionalFileTitles,
-      trackAdditionalFileFiles,
-    };
-  },
+    albums:
+      sortChronologically(sprawl.albumData.slice()),
+  }),
 
   relations: (relation, query) => ({
     page:
       relation('generateListingPage', query.spec),
 
-    albumLinks:
-      query.albums
-        .map(album => relation('linkAlbum', album)),
-
-    trackLinks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(track => relation('linkTrack', track))),
-
-    albumChunks:
-      query.albums
-        .map(() => relation('generateListAllAdditionalFilesChunk')),
-
-    trackChunks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(() => relation('generateListAllAdditionalFilesChunk'))),
-
-    albumAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.albumAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum.map(files => files
-            .map(file =>
-              relation('linkAlbumAdditionalFile', album, file)))),
-
-    trackAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.trackAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum
-            .map(byTrack => byTrack
-              .map(files => files
-                .map(file => relation('linkAlbumAdditionalFile', album, file))))),
-  }),
-
-  data: (query) => ({
-    albumAdditionalFileTitles: query.albumAdditionalFileTitles,
-    trackAdditionalFileTitles: query.trackAdditionalFileTitles,
-    albumAdditionalFileFiles: query.albumAdditionalFileFiles,
-    trackAdditionalFileFiles: query.trackAdditionalFileFiles,
+    albumSections:
+      query.albums.map(album =>
+        relation('generateListAllAdditionalFilesAlbumSection',
+          album,
+          query.property)),
   }),
 
   slots: {
     stringsKey: {type: 'string'},
   },
 
-  generate: (data, relations, slots, {html, language}) =>
+  generate: (relations, slots) =>
     relations.page.slots({
       type: 'custom',
 
       content:
-        stitchArrays({
-          albumLink: relations.albumLinks,
-          trackLinks: relations.trackLinks,
-          albumChunk: relations.albumChunks,
-          trackChunks: relations.trackChunks,
-          albumAdditionalFileTitles: data.albumAdditionalFileTitles,
-          trackAdditionalFileTitles: data.trackAdditionalFileTitles,
-          albumAdditionalFileLinks: relations.albumAdditionalFileLinks,
-          trackAdditionalFileLinks: relations.trackAdditionalFileLinks,
-          albumAdditionalFileFiles: data.albumAdditionalFileFiles,
-          trackAdditionalFileFiles: data.trackAdditionalFileFiles,
-        }).map(({
-            albumLink,
-            trackLinks,
-            albumChunk,
-            trackChunks,
-            albumAdditionalFileTitles,
-            trackAdditionalFileTitles,
-            albumAdditionalFileLinks,
-            trackAdditionalFileLinks,
-            albumAdditionalFileFiles,
-            trackAdditionalFileFiles,
-          }) => [
-            html.tag('h3', {class: 'content-heading'}, albumLink),
-
-            html.tag('dl', [
-              albumChunk.slots({
-                title:
-                  language.$('listingPage', slots.stringsKey, 'albumFiles'),
-
-                additionalFileTitles: albumAdditionalFileTitles,
-                additionalFileLinks: albumAdditionalFileLinks,
-                additionalFileFiles: albumAdditionalFileFiles,
-
-                stringsKey: slots.stringsKey,
-              }),
-
-              stitchArrays({
-                trackLink: trackLinks,
-                trackChunk: trackChunks,
-                trackAdditionalFileTitles,
-                trackAdditionalFileLinks,
-                trackAdditionalFileFiles,
-              }).map(({
-                  trackLink,
-                  trackChunk,
-                  trackAdditionalFileTitles,
-                  trackAdditionalFileLinks,
-                  trackAdditionalFileFiles,
-                }) =>
-                  trackChunk.slots({
-                    title: trackLink,
-                    additionalFileTitles: trackAdditionalFileTitles,
-                    additionalFileLinks: trackAdditionalFileLinks,
-                    additionalFileFiles: trackAdditionalFileFiles,
-                    stringsKey: slots.stringsKey,
-                  })),
-            ]),
-          ]),
+        relations.albumSections.map(section =>
+          section.slot('stringsKey', slots.stringsKey)),
     }),
 };
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 41944959..99f19764 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,13 +1,6 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-
-import {
-  accumulateSum,
-  empty,
-  filterByCount,
-  filterMultipleArrays,
-  stitchArrays,
-  unique,
-} from '#sugar';
+import {empty, filterByCount, filterMultipleArrays, stitchArrays}
+  from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -41,37 +34,46 @@ export default {
       query[countsKey] = counts;
     };
 
+    const countContributions = (artist, keys) => {
+      const contribs =
+        keys
+          .flatMap(key => artist[key])
+          .filter(contrib => contrib.countInContributionTotals);
+
+      const things =
+        new Set(contribs.map(contrib => contrib.thing));
+
+      return things.size;
+    };
+
     queryContributionInfo(
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        (unique(
-          ([
-            artist.trackArtistContributions,
-            artist.trackContributorContributions,
-          ]).flat()
-            .map(({thing}) => thing)
-        )).length);
+        countContributions(artist, [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ]));
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        accumulateSum(
-          [
-            artist.albumCoverArtistContributions,
-            artist.albumWallpaperArtistContributions,
-            artist.albumBannerArtistContributions,
-            artist.trackCoverArtistContributions,
-          ],
-          contribs => contribs.length));
+        countContributions(artist, [
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+          'trackCoverArtistContributions',
+        ]));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashContributorContributions.length);
+          countContributions(artist, [
+            'flashContributorContributions',
+          ]));
     }
 
     return query;
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index c1415474..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,3 +1,6 @@
+import {basename} from 'node:path';
+
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
 import {replacerSpec, parseContentNodes} from '#replacer';
 
@@ -60,20 +63,30 @@ export default {
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
     'generateTextWithTooltip',
     'generateTooltip',
     'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseContentNodes(content ?? '');
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -187,6 +200,9 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
@@ -299,7 +315,12 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language, to}) {
+  generate(data, relations, slots, {html, language, niceShowAggregate, to}) {
+    if (data.error) {
+      logWarn`Error in content text.`;
+      niceShowAggregate(data.error);
+    }
+
     let imageIndex = 0;
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
@@ -358,9 +379,8 @@ export default {
                   height && {height},
                   style && {style},
 
-                  align === 'center' &&
-                  !link &&
-                    {class: 'align-center'},
+                  align && !link &&
+                    {class: 'align-' + align},
 
                   pixelate &&
                     {class: 'pixelate'});
@@ -371,8 +391,8 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     {title:
                       language.encapsulate('misc.external.opensInNewTab', capsule =>
@@ -422,8 +442,8 @@ export default {
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
-                  align === 'center' &&
-                    {class: 'align-center'},
+                  align &&
+                    {class: 'align-' + align},
 
                   image),
             };
@@ -435,22 +455,31 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {width, height, align, pixelate} = node;
+            const {width, height, align, inline, pixelate} = node;
 
-            const content =
-              html.tag('div', {class: 'content-video-container'},
-                align === 'center' &&
-                  {class: 'align-center'},
+            const video =
+              html.tag('video',
+                src && {src},
+                width && {width},
+                height && {height},
 
-                html.tag('video',
-                  src && {src},
-                  width && {width},
-                  height && {height},
+                {controls: true},
 
-                  {controls: true},
+                align && inline &&
+                  {class: 'align-' + align},
+
+                pixelate &&
+                  {class: 'pixelate'});
+
+            const content =
+              (inline
+                ? video
+                : html.tag('div', {class: 'content-video-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    video));
 
-                  pixelate &&
-                    {class: 'pixelate'}));
 
             return {
               type: 'processed-video',
@@ -464,15 +493,14 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {align, inline} = node;
+            const {align, inline, nameless} = node;
 
             const audio =
               html.tag('audio',
                 src && {src},
 
-                align === 'center' &&
-                inline &&
-                  {class: 'align-center'},
+                align && inline &&
+                  {class: 'align-' + align},
 
                 {controls: true});
 
@@ -480,10 +508,17 @@ export default {
               (inline
                 ? audio
                 : html.tag('div', {class: 'content-audio-container'},
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    [
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
 
-                    audio));
+                      audio,
+                    ]));
 
             return {
               type: 'processed-audio',
@@ -529,7 +564,7 @@ export default {
             try {
               link.getSlotDescription('preferShortName');
               hasPreferShortNameSlot = true;
-            } catch (error) {
+            } catch {
               hasPreferShortNameSlot = false;
             }
 
@@ -542,7 +577,7 @@ export default {
             try {
               link.getSlotDescription('tooltipStyle');
               hasTooltipStyleSlot = true;
-            } catch (error) {
+            } catch {
               hasTooltipStyleSlot = false;
             }