« get me outta code hell

content: cut html.template boilerplate - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-06-12 16:35:38 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-06-12 16:35:38 -0300
commit535acb34613b5cf7e22654619f4337b94b70644d (patch)
tree5a713eb4f12eae7e1fe1aa60941709708585db32 /src
parent630af0a345f3be6c3e4aa3300ce138e48ed5ae91 (diff)
content: cut html.template boilerplate
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js183
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js120
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js76
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js111
-rw-r--r--src/content/dependencies/generateChronologyLinks.js140
-rw-r--r--src/content/dependencies/generateContentHeading.js34
-rw-r--r--src/content/dependencies/generateCoverArtwork.js96
-rw-r--r--src/content/dependencies/generateCoverGrid.js68
-rw-r--r--src/content/dependencies/generatePageLayout.js930
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js44
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js40
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js54
-rw-r--r--src/content/dependencies/generateTrackList.js50
-rw-r--r--src/content/dependencies/image.js346
-rw-r--r--src/content/dependencies/linkContribution.js71
-rw-r--r--src/content/dependencies/linkTemplate.js92
-rw-r--r--src/content/dependencies/transformContent.js164
17 files changed, 1250 insertions, 1369 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index eb9fc8b..56e6686 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,5 +1,29 @@
 import {empty} from '../../util/sugar.js';
 
+function validateFileMapping(v, validateValue) {
+  return value => {
+    v.isObject(value);
+
+    const valueErrors = [];
+    for (const [fileKey, fileValue] of Object.entries(value)) {
+      if (fileValue === null) {
+        continue;
+      }
+
+      try {
+        validateValue(fileValue);
+      } catch (error) {
+        error.message = `(${fileKey}) ` + error.message;
+        valueErrors.push(error);
+      }
+    }
+
+    if (!empty(valueErrors)) {
+      throw new AggregateError(valueErrors, `Errors validating values`);
+    }
+  };
+}
+
 export default {
   extraDependencies: [
     'html',
@@ -14,108 +38,67 @@ export default {
     };
   },
 
-  generate(data, {
+  slots: {
+    fileLinks: {
+      validate: v => validateFileMapping(v, v.isHTML),
+    },
+
+    fileSizes: {
+      validate: v => validateFileMapping(v, v.isWholeNumber),
+    },
+  },
+
+  generate(data, slots, {
     html,
     language,
   }) {
-    const fileKeys = data.additionalFiles.flatMap(({files}) => files);
-    const validateFileMapping = (v, validateValue) => {
-      return value => {
-        v.isObject(value);
-
-        // It's OK to skip values for files, but if keys are provided for files
-        // which don't exist, that's an error.
-
-        const unexpectedKeys =
-          Object.keys(value).filter(key => !fileKeys.includes(key))
-
-        if (!empty(unexpectedKeys)) {
-          throw new TypeError(`Unexpected file keys: ${unexpectedKeys.join(', ')}`);
-        }
-
-        const valueErrors = [];
-        for (const [fileKey, fileValue] of Object.entries(value)) {
-          if (fileValue === null) {
-            continue;
-          }
-
-          try {
-            validateValue(fileValue);
-          } catch (error) {
-            error.message = `(${fileKey}) ` + error.message;
-            valueErrors.push(error);
-          }
-        }
-
-        if (!empty(valueErrors)) {
-          throw new AggregateError(valueErrors, `Errors validating values`);
-        }
-      };
-    };
-
-    return html.template({
-      annotation: 'generateAdditionalFilesList',
-
-      slots: {
-        fileLinks: {
-          validate: v => validateFileMapping(v, v.isHTML),
-        },
-
-        fileSizes: {
-          validate: v => validateFileMapping(v, v.isWholeNumber),
-        },
-      },
-
-      content(slots) {
-        if (!slots.fileSizes) {
-          return html.blank();
-        }
-
-        const filesWithLinks = new Set(
-          Object.entries(slots.fileLinks)
-            .filter(([key, value]) => value)
-            .map(([key]) => key));
-
-        if (filesWithLinks.size === 0) {
-          return html.blank();
-        }
-
-        const filteredFileGroups = data.additionalFiles
-          .map(({title, description, files}) => ({
-            title,
-            description,
-            files: files.filter(f => filesWithLinks.has(f)),
-          }))
-          .filter(({files}) => !empty(files));
-
-        if (empty(filteredFileGroups)) {
-          return html.blank();
-        }
-
-        return html.tag('dl',
-          filteredFileGroups.flatMap(({title, description, files}) => [
-            html.tag('dt',
-              (description
-                ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                    title,
-                    description,
-                  })
-                : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-            html.tag('dd',
-              html.tag('ul',
-                files.map(file =>
-                  html.tag('li',
-                    (slots.fileSizes[file]
-                      ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                          file: slots.fileLinks[file],
-                          size: language.formatFileSize(slots.fileSizes[file]),
-                        })
-                      : language.$('releaseInfo.additionalFiles.file', {
-                          file: slots.fileLinks[file],
-                        })))))),
-          ]));
-      },
-    });
+    if (!slots.fileSizes) {
+      return html.blank();
+    }
+
+    const filesWithLinks = new Set(
+      Object.entries(slots.fileLinks)
+        .filter(([key, value]) => value)
+        .map(([key]) => key));
+
+    if (filesWithLinks.size === 0) {
+      return html.blank();
+    }
+
+    const filteredFileGroups = data.additionalFiles
+      .map(({title, description, files}) => ({
+        title,
+        description,
+        files: files.filter(f => filesWithLinks.has(f)),
+      }))
+      .filter(({files}) => !empty(files));
+
+    if (empty(filteredFileGroups)) {
+      return html.blank();
+    }
+
+    return html.tag('dl',
+      filteredFileGroups.flatMap(({title, description, files}) => [
+        html.tag('dt',
+          (description
+            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
+                title,
+                description,
+              })
+            : language.$('releaseInfo.additionalFiles.entry', {title}))),
+
+        html.tag('dd',
+          html.tag('ul',
+            files.map(file =>
+              html.tag('li',
+                (slots.fileSizes[file]
+                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
+                      file: slots.fileLinks[file],
+                      size: language.formatFileSize(slots.fileSizes[file]),
+                    })
+                  : language.$('releaseInfo.additionalFiles.file', {
+                      file: slots.fileLinks[file],
+                    })))))),
+      ]));
   },
 };
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 9d1d87c..0237fde 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -53,68 +53,62 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return html.template({
-      annotation: `generateAlbumNavAccent`,
-
-      slots: {
-        showTrackNavigation: {type: 'boolean', default: false},
-        showExtraLinks: {type: 'boolean', default: false},
-
-        currentExtra: {
-          validate: v => v.is('gallery', 'commentary'),
-        },
-      },
-
-      content(slots) {
-        const {content: extraLinks = []} =
-          slots.showExtraLinks &&
-            {content: [
-              relations.albumGalleryLink?.slots({
-                attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-                content: language.$('albumPage.nav.gallery'),
-              }),
-
-              relations.albumCommentaryLink?.slots({
-                attributes: {class: slots.currentExtra === 'commentary' && 'current'},
-                content: language.$('albumPage.nav.commentary'),
-              }),
-            ]};
-
-        const {content: previousNextLinks = []} =
-          slots.showTrackNavigation &&
-          data.isTrackPage &&
-          data.hasMultipleTracks &&
-            relations.previousNextLinks.slots({
-              previousLink: relations.previousTrackLink,
-              nextLink: relations.nextTrackLink,
-            });
-
-        const randomLink =
-          slots.showTrackNavigation &&
-          data.hasMultipleTracks &&
-            html.tag('a',
-              {
-                href: '#',
-                'data-random': 'track-in-album',
-                id: 'random-button',
-              },
-              (data.isTrackPage
-                ? language.$('trackPage.nav.random')
-                : language.$('albumPage.nav.randomTrack')));
-
-        const allLinks = [
-          ...previousNextLinks,
-          ...extraLinks,
-          randomLink,
-        ].filter(Boolean);
-
-        if (empty(allLinks)) {
-          return html.blank();
-        }
-
-        return `(${language.formatUnitList(allLinks)})`
-      },
-    });
+  slots: {
+    showTrackNavigation: {type: 'boolean', default: false},
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery', 'commentary'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          relations.albumGalleryLink?.slots({
+            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+            content: language.$('albumPage.nav.gallery'),
+          }),
+
+          relations.albumCommentaryLink?.slots({
+            attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+            content: language.$('albumPage.nav.commentary'),
+          }),
+        ]};
+
+    const {content: previousNextLinks = []} =
+      slots.showTrackNavigation &&
+      data.isTrackPage &&
+      data.hasMultipleTracks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousTrackLink,
+          nextLink: relations.nextTrackLink,
+        });
+
+    const randomLink =
+      slots.showTrackNavigation &&
+      data.hasMultipleTracks &&
+        html.tag('a',
+          {
+            href: '#',
+            'data-random': 'track-in-album',
+            id: 'random-button',
+          },
+          (data.isTrackPage
+            ? language.$('trackPage.nav.random')
+            : language.$('albumPage.nav.randomTrack')));
+
+    const allLinks = [
+      ...previousNextLinks,
+      ...extraLinks,
+      randomLink,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
   },
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 1c27af9..94536c3 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -43,46 +43,40 @@ export default {
     return relations;
   },
 
-  generate(relations, {html, language}) {
-    return html.template({
-      annotation: `generateAlbumSidebarGroupBox`,
-
-      slots: {
-        isAlbumPage: {type: 'boolean', default: false},
-      },
-
-      content(slots) {
-        return html.tags([
-          html.tag('h1',
-            language.$('albumSidebar.groupBox.title', {
-              group: relations.groupLink,
-            })),
-
-          slots.isAlbumPage &&
-            relations.description
-              ?.slot('mode', 'multiline'),
-
-          !empty(relations.externalLinks) &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(relations.externalLinks),
-              })),
-
-          slots.isAlbumPage &&
-          relations.nextAlbumLink &&
-            html.tag('p', {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.next', {
-                album: relations.nextAlbumLink,
-              })),
-
-          slots.isAlbumPage &&
-          relations.previousAlbumLink &&
-            html.tag('p', {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.previous', {
-                album: relations.previousAlbumLink,
-              })),
-        ]);
-      },
-    });
+  slots: {
+    isAlbumPage: {type: 'boolean', default: false},
+  },
+
+  generate(relations, slots, {html, language}) {
+    return html.tags([
+      html.tag('h1',
+        language.$('albumSidebar.groupBox.title', {
+          group: relations.groupLink,
+        })),
+
+      slots.isAlbumPage &&
+        relations.description
+          ?.slot('mode', 'multiline'),
+
+      !empty(relations.externalLinks) &&
+        html.tag('p',
+          language.$('releaseInfo.visitOn', {
+            links: language.formatDisjunctionList(relations.externalLinks),
+          })),
+
+      slots.isAlbumPage &&
+      relations.nextAlbumLink &&
+        html.tag('p', {class: 'group-chronology-link'},
+          language.$('albumSidebar.groupBox.next', {
+            album: relations.nextAlbumLink,
+          })),
+
+      slots.isAlbumPage &&
+      relations.previousAlbumLink &&
+        html.tag('p', {class: 'group-chronology-link'},
+          language.$('albumSidebar.groupBox.previous', {
+            album: relations.previousAlbumLink,
+          })),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
index f283b30..f78b45a 100644
--- a/src/content/dependencies/generateArtistNavLinks.js
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -40,66 +40,61 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return html.template({
-      annotation: `generateArtistNav`,
-      slots: {
-        showExtraLinks: {type: 'boolean', default: false},
-
-        currentExtra: {
-          validate: v => v.is('gallery'),
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const infoLink =
+      relations.artistInfoLink?.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          relations.artistGalleryLink?.slots({
+            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+            content: language.$('misc.nav.gallery'),
+          }),
+        ]};
+
+    const mostAccentLinks = [
+      ...extraLinks,
+    ].filter(Boolean);
+
+    // Don't show the info accent link all on its own.
+    const allAccentLinks =
+      (empty(mostAccentLinks)
+        ? []
+        : [infoLink, ...mostAccentLinks]);
+
+    const accent =
+      (empty(allAccentLinks)
+        ? html.blank()
+        : `(${language.formatUnitList(allAccentLinks)})`);
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
         },
-      },
 
-      content(slots) {
-        const infoLink =
-          relations.artistInfoLink?.slots({
-            attributes: {class: slots.currentExtra === null && 'current'},
-            content: language.$('misc.nav.info'),
-          });
-
-        const {content: extraLinks = []} =
-          slots.showExtraLinks &&
-            {content: [
-              relations.artistGalleryLink?.slots({
-                attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-                content: language.$('misc.nav.gallery'),
-              }),
-            ]};
-
-        const mostAccentLinks = [
-          ...extraLinks,
-        ].filter(Boolean);
-
-        // Don't show the info accent link all on its own.
-        const allAccentLinks =
-          (empty(mostAccentLinks)
-            ? []
-            : [infoLink, ...mostAccentLinks]);
-
-        const accent =
-          (empty(allAccentLinks)
-            ? html.blank()
-            : `(${language.formatUnitList(allAccentLinks)})`);
-
-        return [
-          {auto: 'home'},
-
-          data.enableListings &&
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-
-          {
-            accent,
-            html:
-              language.$('artistPage.nav.artist', {
-                artist: relations.artistMainLink,
-              }),
-          },
-        ];
+      {
+        accent,
+        html:
+          language.$('artistPage.nav.artist', {
+            artist: relations.artistMainLink,
+          }),
       },
-    });
+    ];
   },
 };
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
index a61b5e6..15c0898 100644
--- a/src/content/dependencies/generateChronologyLinks.js
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -3,86 +3,80 @@ import {accumulateSum, empty} from '../../util/sugar.js';
 export default {
   extraDependencies: ['html', 'language'],
 
-  generate({html, language}) {
-    return html.template({
-      annotation: `generateChronologyLinks`,
-
-      slots: {
-        chronologyInfoSets: {
-          validate: v =>
-            v.arrayOf(
-              v.validateProperties({
-                headingString: v.isString,
-                contributions: v.arrayOf(v.validateProperties({
-                  index: v.isCountingNumber,
-                  artistLink: v.isHTML,
-                  previousLink: v.isHTML,
-                  nextLink: v.isHTML,
-                })),
-              })),
-        }
-      },
+  slots: {
+    chronologyInfoSets: {
+      validate: v =>
+        v.arrayOf(
+          v.validateProperties({
+            headingString: v.isString,
+            contributions: v.arrayOf(v.validateProperties({
+              index: v.isCountingNumber,
+              artistLink: v.isHTML,
+              previousLink: v.isHTML,
+              nextLink: v.isHTML,
+            })),
+          })),
+    }
+  },
 
-      content(slots) {
-        if (empty(slots.chronologyInfoSets)) {
-          return html.blank();
-        }
+  generate(slots, {html, language}) {
+    if (empty(slots.chronologyInfoSets)) {
+      return html.blank();
+    }
 
-        const totalContributionCount =
-          accumulateSum(
-            slots.chronologyInfoSets,
-            ({contributions}) => contributions.length);
+    const totalContributionCount =
+      accumulateSum(
+        slots.chronologyInfoSets,
+        ({contributions}) => contributions.length);
 
-        if (totalContributionCount === 0) {
-          return html.blank();
-        }
+    if (totalContributionCount === 0) {
+      return html.blank();
+    }
 
-        if (totalContributionCount > 8) {
-          return html.tag('div', {class: 'chronology'},
-            language.$('misc.chronology.seeArtistPages'));
-        }
+    if (totalContributionCount > 8) {
+      return html.tag('div', {class: 'chronology'},
+        language.$('misc.chronology.seeArtistPages'));
+    }
 
-        return html.tags(
-          slots.chronologyInfoSets.map(({
-            headingString,
-            contributions,
-          }) =>
-            contributions.map(({
-              index,
-              artistLink,
-              previousLink,
-              nextLink,
-            }) => {
-              const heading =
-                html.tag('span', {class: 'heading'},
-                  language.$(headingString, {
-                    index: language.formatIndex(index),
-                    artist: artistLink,
-                  }));
+    return html.tags(
+      slots.chronologyInfoSets.map(({
+        headingString,
+        contributions,
+      }) =>
+        contributions.map(({
+          index,
+          artistLink,
+          previousLink,
+          nextLink,
+        }) => {
+          const heading =
+            html.tag('span', {class: 'heading'},
+              language.$(headingString, {
+                index: language.formatIndex(index),
+                artist: artistLink,
+              }));
 
-              const navigation =
-                (previousLink || nextLink) &&
-                  html.tag('span', {class: 'buttons'},
-                    language.formatUnitList([
-                      previousLink?.slots({
-                        tooltip: true,
-                        color: false,
-                        content: language.$('misc.nav.previous'),
-                      }),
+          const navigation =
+            (previousLink || nextLink) &&
+              html.tag('span', {class: 'buttons'},
+                language.formatUnitList([
+                  previousLink?.slots({
+                    tooltip: true,
+                    color: false,
+                    content: language.$('misc.nav.previous'),
+                  }),
 
-                      nextLink?.slots({
-                        tooltip: true,
-                        color: false,
-                        content: language.$('misc.nav.next'),
-                      }),
-                    ].filter(Boolean)));
+                  nextLink?.slots({
+                    tooltip: true,
+                    color: false,
+                    content: language.$('misc.nav.next'),
+                  }),
+                ].filter(Boolean)));
 
-              return html.tag('div', {class: 'chronology'},
-                (navigation
-                  ? language.$('misc.chronology.withNavigation', {heading, navigation})
-                  : heading));
-            })));
-      },
-    });
+          return html.tag('div', {class: 'chronology'},
+            (navigation
+              ? language.$('misc.chronology.withNavigation', {heading, navigation})
+              : heading));
+        })));
   },
 };
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 1666ef4..ccaf107 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -1,27 +1,19 @@
 export default {
-  extraDependencies: [
-    'html',
-  ],
+  extraDependencies: ['html'],
 
-  generate({html}) {
-    return html.template({
-      annotation: 'generateContentHeading',
+  slots: {
+    title: {type: 'html'},
+    id: {type: 'string'},
+    tag: {type: 'string', default: 'p'},
+  },
 
-      slots: {
-        title: {type: 'html'},
-        id: {type: 'string'},
-        tag: {type: 'string', default: 'p'},
+  generate(slots, {html}) {
+    return html.tag(slots.tag,
+      {
+        class: 'content-heading',
+        id: slots.id,
+        tabindex: '0',
       },
-
-      content(slots) {
-        return html.tag(slots.tag,
-          {
-            class: 'content-heading',
-            id: slots.id,
-            tabindex: '0',
-          },
-          slots.title);
-      },
-    });
+      slots.title);
   }
 }
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index a7a7f85..e9c91cf 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -22,62 +22,56 @@ export default {
     return relations;
   },
 
-  generate(relations, {html, language}) {
-    return html.template({
-      annotation: 'generateCoverArtwork',
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
 
-      slots: {
-        path: {
-          validate: v => v.validateArrayItems(v.isString),
-        },
+    alt: {
+      type: 'string',
+    },
 
-        alt: {
-          type: 'string',
-        },
-
-        displayMode: {
-          validate: v => v.is('primary', 'thumbnail'),
-          default: 'primary',
-        },
-      },
+    displayMode: {
+      validate: v => v.is('primary', 'thumbnail'),
+      default: 'primary',
+    },
+  },
 
-      content(slots) {
-        switch (slots.displayMode) {
-          case 'primary':
-            return html.tag('div', {id: 'cover-art-container'}, [
-              relations.image
-                .slots({
-                  path: slots.path,
-                  alt: slots.alt,
-                  thumb: 'medium',
-                  id: 'cover-art',
-                  reveal: true,
-                  link: true,
-                  square: true,
-                }),
+  generate(relations, slots, {html, language}) {
+    switch (slots.displayMode) {
+      case 'primary':
+        return html.tag('div', {id: 'cover-art-container'}, [
+          relations.image
+            .slots({
+              path: slots.path,
+              alt: slots.alt,
+              thumb: 'medium',
+              id: 'cover-art',
+              reveal: true,
+              link: true,
+              square: true,
+            }),
 
-              !empty(relations.tagLinks) &&
-                html.tag('p',
-                  language.$('releaseInfo.artTags.inline', {
-                    tags: language.formatUnitList(relations.tagLinks),
-                  })),
-              ]);
+          !empty(relations.tagLinks) &&
+            html.tag('p',
+              language.$('releaseInfo.artTags.inline', {
+                tags: language.formatUnitList(relations.tagLinks),
+              })),
+          ]);
 
-          case 'thumbnail':
-            return relations.image
-              .slots({
-                path: slots.path,
-                alt: slots.alt,
-                thumb: 'small',
-                reveal: false,
-                link: false,
-                square: true,
-              });
+      case 'thumbnail':
+        return relations.image
+          .slots({
+            path: slots.path,
+            alt: slots.alt,
+            thumb: 'small',
+            reveal: false,
+            link: false,
+            square: true,
+          });
 
-          case 'default':
-            return html.blank();
-        }
-      },
-    });
+      default:
+        return html.blank();
+    }
   },
 };
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index fdd9f8b..a024ae2 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -1,44 +1,38 @@
 export default {
   extraDependencies: ['html'],
 
-  generate({html}) {
-    return html.template({
-      annotation: `generateCoverGrid`,
+  slots: {
+    images: {validate: v => v.arrayOf(v.isHTML)},
+    links: {validate: v => v.arrayOf(v.isHTML)},
+    names: {validate: v => v.arrayOf(v.isString)},
 
-      slots: {
-        images: {validate: v => v.arrayOf(v.isHTML)},
-        links: {validate: v => v.arrayOf(v.isHTML)},
-        names: {validate: v => v.arrayOf(v.isString)},
-
-        lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)},
-      },
+    lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)},
+  },
 
-      content(slots) {
-        return (
-          html.tag('div', {class: 'grid-listing'},
-            slots.images.map((image, i) => {
-              const link = slots.links[i];
-              const name = slots.names[i];
-              return link.slots({
-                content: [
-                  image.slots({
-                    thumb: 'medium',
-                    lazy:
-                      (typeof slots.lazy === 'number'
-                        ? i >= slots.lazy
-                     : typeof slots.lazy === 'boolean'
-                        ? slots.lazy
-                        : false),
-                    square: true,
-                  }),
-                  html.tag('span', name),
-                ],
-                attributes: {
-                  class: ['grid-item', 'box', /* large && 'large-grid-item' */],
-                },
-              });
-            })));
-      },
-    });
+  generate(slots, {html}) {
+    return (
+      html.tag('div', {class: 'grid-listing'},
+        slots.images.map((image, i) => {
+          const link = slots.links[i];
+          const name = slots.names[i];
+          return link.slots({
+            content: [
+              image.slots({
+                thumb: 'medium',
+                lazy:
+                  (typeof slots.lazy === 'number'
+                    ? i >= slots.lazy
+                 : typeof slots.lazy === 'boolean'
+                    ? slots.lazy
+                    : false),
+                square: true,
+              }),
+              html.tag('span', name),
+            ],
+            attributes: {
+              class: ['grid-item', 'box', /* large && 'large-grid-item' */],
+            },
+          });
+        })));
   },
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 84acca0..610b4a1 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,5 +1,48 @@
 import {empty, openAggregate} from '../../util/sugar.js';
 
+function sidebarSlots(side) {
+  return {
+    // Content is a flat HTML array. It'll generate one sidebar section
+    // if specified.
+    [side + 'Content']: {type: 'html'},
+
+    // Multiple is an array of {content: (HTML)} objects. Each of these
+    // will generate one sidebar section.
+    [side + 'Multiple']: {
+      validate: v =>
+        v.arrayOf(
+          v.validateProperties({
+            content: v.isHTML,
+          })),
+    },
+
+    // Sticky mode controls which sidebar section(s), if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'none' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    [side + 'StickyMode']: {
+      validate: v => v.is('last', 'column', 'static'),
+    },
+
+    // Collapsing sidebars disappear when the viewport is sufficiently
+    // thin. (This is the default.) Override as false to make the sidebar
+    // stay visible in thinner viewports, where the page layout will be
+    // reflowed so the sidebar is as wide as the screen and appears below
+    // nav, above the main content.
+    [side + 'Collapse']: {type: 'boolean', default: true},
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    [side + 'Wide']: {type: 'boolean', defualt: false},
+  };
+}
+
 export default {
   contentDependencies: [
     'generateColorStyleRules',
@@ -49,495 +92,446 @@ export default {
     return relations;
   },
 
-  generate(data, relations, {
+  slots: {
+    title: {type: 'html'},
+    showWikiNameInTitle: {type: 'boolean', default: true},
+
+    cover: {type: 'html'},
+
+    socialEmbed: {type: 'html'},
+
+    colorStyleRules: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    additionalStyleRules: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    mainClasses: {
+      validate: v => v.arrayOf(v.isString),
+      default: [],
+    },
+
+    // Main
+
+    mainContent: {type: 'html'},
+
+    headingMode: {
+      validate: v => v.is('sticky', 'static'),
+      default: 'static',
+    },
+
+    // Sidebars
+
+    ...sidebarSlots('leftSidebar'),
+    ...sidebarSlots('rightSidebar'),
+
+    // Nav & Footer
+
+    navContent: {type: 'html'},
+    navBottomRowContent: {type: 'html'},
+
+    navLinkStyle: {
+      validate: v => v.is('hierarchical', 'index'),
+      default: 'index',
+    },
+
+    navLinks: {
+      validate: v =>
+        v.arrayOf(object => {
+          v.isObject(object);
+
+          const aggregate = openAggregate({message: `Errors validating navigation link`});
+
+          aggregate.call(v.validateProperties({
+            auto: () => true,
+            html: () => true,
+
+            path: () => true,
+            title: () => true,
+            accent: () => true,
+          }), object);
+
+          if (object.auto || object.html) {
+            if (object.auto && object.html) {
+              aggregate.push(new TypeError(`Don't specify both auto and html`));
+            } else if (object.auto) {
+              aggregate.call(v.is('home', 'current'), object.auto);
+            } else {
+              aggregate.call(v.isHTML, object.html);
+            }
+
+            if (object.path || object.title) {
+              aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
+            }
+          } else {
+            aggregate.call(v.validateProperties({
+              path: v.arrayOf(v.isString),
+              title: v.isString,
+            }), {
+              path: object.path,
+              title: object.title,
+            });
+          }
+
+          aggregate.close();
+
+          return true;
+        })
+    },
+
+    footerContent: {type: 'html'},
+  },
+
+  generate(data, relations, slots, {
     cachebust,
     html,
     language,
     to,
   }) {
-    const sidebarSlots = side => ({
-      // Content is a flat HTML array. It'll generate one sidebar section
-      // if specified.
-      [side + 'Content']: {type: 'html'},
-
-      // Multiple is an array of {content: (HTML)} objects. Each of these
-      // will generate one sidebar section.
-      [side + 'Multiple']: {
-        validate: v =>
-          v.arrayOf(
-            v.validateProperties({
-              content: v.isHTML,
-            })),
-      },
-
-      // Sticky mode controls which sidebar section(s), if any, follow the
-      // scroll position, "sticking" to the top of the browser viewport.
-      //
-      // 'last' - last or only sidebar box is sticky
-      // 'column' - entire column, incl. multiple boxes from top, is sticky
-      // 'none' - sidebar not sticky at all, stays at top of page
-      //
-      // Note: This doesn't affect the content of any sidebar section, only
-      // the whole section's containing box (or the sidebar column as a whole).
-      [side + 'StickyMode']: {
-        validate: v => v.is('last', 'column', 'static'),
-      },
-
-      // Collapsing sidebars disappear when the viewport is sufficiently
-      // thin. (This is the default.) Override as false to make the sidebar
-      // stay visible in thinner viewports, where the page layout will be
-      // reflowed so the sidebar is as wide as the screen and appears below
-      // nav, above the main content.
-      [side + 'Collapse']: {type: 'boolean', default: true},
-
-      // Wide sidebars generally take up more horizontal space in the normal
-      // page layout, and should be used if the content of the sidebar has
-      // a greater than typical focus compared to main content.
-      [side + 'Wide']: {type: 'boolean', defualt: false},
-    });
-
-    return html.template({
-      annotation: 'generatePageLayout',
-
-      slots: {
-        title: {type: 'html'},
-        showWikiNameInTitle: {type: 'boolean', default: true},
-
-        cover: {type: 'html'},
-
-        socialEmbed: {type: 'html'},
-
-        colorStyleRules: {
-          validate: v => v.arrayOf(v.isString),
-          default: [],
-        },
-
-        additionalStyleRules: {
-          validate: v => v.arrayOf(v.isString),
-          default: [],
-        },
-
-        mainClasses: {
-          validate: v => v.arrayOf(v.isString),
-          default: [],
-        },
-
-        // Main
-
-        mainContent: {type: 'html'},
-
-        headingMode: {
-          validate: v => v.is('sticky', 'static'),
-          default: 'static',
-        },
-
-        // Sidebars
-
-        ...sidebarSlots('leftSidebar'),
-        ...sidebarSlots('rightSidebar'),
-
-        // Nav & Footer
-
-        navContent: {type: 'html'},
-        navBottomRowContent: {type: 'html'},
-
-        navLinkStyle: {
-          validate: v => v.is('hierarchical', 'index'),
-          default: 'index',
-        },
-
-        navLinks: {
-          validate: v =>
-            v.arrayOf(object => {
-              v.isObject(object);
-
-              const aggregate = openAggregate({message: `Errors validating navigation link`});
-
-              aggregate.call(v.validateProperties({
-                auto: () => true,
-                html: () => true,
-
-                path: () => true,
-                title: () => true,
-                accent: () => true,
-              }), object);
-
-              if (object.auto || object.html) {
-                if (object.auto && object.html) {
-                  aggregate.push(new TypeError(`Don't specify both auto and html`));
-                } else if (object.auto) {
-                  aggregate.call(v.is('home', 'current'), object.auto);
-                } else {
-                  aggregate.call(v.isHTML, object.html);
-                }
-
-                if (object.path || object.title) {
-                  aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
-                }
-              } else {
-                aggregate.call(v.validateProperties({
-                  path: v.arrayOf(v.isString),
-                  title: v.isString,
-                }), {
-                  path: object.path,
-                  title: object.title,
-                });
-              }
+    let titleHTML = null;
+
+    if (!html.isBlank(slots.title)) {
+      switch (slots.headingMode) {
+        case 'sticky':
+          titleHTML =
+            relations.stickyHeadingContainer.slots({
+              title: slots.title,
+              cover: slots.cover,
+            });
+          break;
+        case 'static':
+          titleHTML = html.tag('h1', slots.title);
+          break;
+      }
+    }
+
+    let footerContent = slots.footerContent;
+
+    if (html.isBlank(footerContent)) {
+      footerContent = relations.defaultFooterContent
+        .slot('mode', 'multiline');
+    }
+
+    const mainHTML =
+      html.tag('main', {
+        id: 'content',
+        class: slots.mainClasses,
+      }, [
+        titleHTML,
+
+        slots.cover,
+
+        html.tag('div',
+          {
+            [html.onlyIfContent]: true,
+            class: 'main-content-container',
+          },
+          slots.mainContent),
+      ]);
 
-              aggregate.close();
+    const footerHTML =
+      html.tag('footer',
+        {[html.onlyIfContent]: true, id: 'footer'},
+        [
+          html.tag('div',
+            {
+              [html.onlyIfContent]: true,
+              class: 'footer-content',
+            },
+            footerContent),
 
-              return true;
-            })
-        },
+          relations.footerLocalizationLinks,
+        ]);
 
-        footerContent: {type: 'html'},
+    const navHTML = html.tag('nav',
+      {
+        [html.onlyIfContent]: true,
+        id: 'header',
+        class: [
+          !empty(slots.navLinks) && 'nav-has-main-links',
+          !html.isBlank(slots.navContent) && 'nav-has-content',
+          !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row',
+        ],
       },
-
-      content(slots) {
-        let titleHTML = null;
-
-        if (!html.isBlank(slots.title)) {
-          switch (slots.headingMode) {
-            case 'sticky':
-              titleHTML =
-                relations.stickyHeadingContainer.slots({
-                  title: slots.title,
-                  cover: slots.cover,
-                });
-              break;
-            case 'static':
-              titleHTML = html.tag('h1', slots.title);
-              break;
-          }
-        }
-
-        let footerContent = slots.footerContent;
-
-        if (html.isBlank(footerContent)) {
-          footerContent = relations.defaultFooterContent
-            .slot('mode', 'multiline');
-        }
-
-        const mainHTML =
-          html.tag('main', {
-            id: 'content',
-            class: slots.mainClasses,
-          }, [
-            titleHTML,
-
-            slots.cover,
-
-            html.tag('div',
-              {
-                [html.onlyIfContent]: true,
-                class: 'main-content-container',
-              },
-              slots.mainContent),
-          ]);
-
-        const footerHTML =
-          html.tag('footer',
-            {[html.onlyIfContent]: true, id: 'footer'},
-            [
-              html.tag('div',
-                {
-                  [html.onlyIfContent]: true,
-                  class: 'footer-content',
-                },
-                footerContent),
-
-              relations.footerLocalizationLinks,
-            ]);
-
-        const navHTML = html.tag('nav',
+      [
+        html.tag('div',
           {
             [html.onlyIfContent]: true,
-            id: 'header',
             class: [
-              !empty(slots.navLinks) && 'nav-has-main-links',
-              !html.isBlank(slots.navContent) && 'nav-has-content',
-              !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row',
+              'nav-main-links',
+              'nav-links-' + slots.navLinkStyle,
             ],
           },
-          [
-            html.tag('div',
-              {
-                [html.onlyIfContent]: true,
-                class: [
-                  'nav-main-links',
-                  'nav-links-' + slots.navLinkStyle,
-                ],
-              },
-              slots.navLinks?.map((cur, i) => {
-                let content;
-
-                if (cur.html) {
-                  content = cur.html;
-                } else {
-                  let title;
-                  let href;
-
-                  switch (cur.auto) {
-                    case 'home':
-                      title = data.wikiName;
-                      href = to('localized.home');
-                      break;
-                    case 'current':
-                      title = slots.title;
-                      href = '';
-                      break;
-                    case null:
-                    case undefined:
-                      title = cur.title;
-                      href = to(...cur.path);
-                      break;
-                  }
-
-                  content = html.tag('a',
-                    {href},
-                    title);
-                }
-
-                let className;
-
-                if (cur.auto === 'current') {
-                  className = 'current';
-                } else if (
-                  slots.navLinkStyle === 'hierarchical' &&
-                  i === slots.navLinks.length - 1
-                ) {
-                  className = 'current';
-                }
-
-                return html.tag('span',
-                  {class: className},
-                  [
-                    html.tag('span',
-                      {class: 'nav-link-content'},
-                      content),
-                    html.tag('span',
-                      {[html.onlyIfContent]: true, class: 'nav-link-accent'},
-                      cur.accent),
-                  ]);
-              })),
-
-            html.tag('div',
-              {[html.onlyIfContent]: true, class: 'nav-bottom-row'},
-              slots.navBottomRowContent),
-
-            html.tag('div',
-              {[html.onlyIfContent]: true, class: 'nav-content'},
-              slots.navContent),
-          ])
-
-        const generateSidebarHTML = (side, id) => {
-          const content = slots[side + 'Content'];
-          const multiple = slots[side + 'Multiple'];
-          const stickyMode = slots[side + 'StickyMode'];
-          const wide = slots[side + 'Wide'];
-          const collapse = slots[side + 'Collapse'];
-
-          let sidebarClasses = [];
-          let sidebarContent = html.blank();
-
-          if (!html.isBlank(content)) {
-            sidebarClasses = ['sidebar'];
-            sidebarContent = content;
-          } else if (multiple) {
-            sidebarClasses = ['sidebar-multiple'];
-            sidebarContent =
-              multiple
-                .filter(Boolean)
-                .map(({content}) =>
-                  html.tag('div',
-                    {
-                      [html.onlyIfContent]: true,
-                      class: 'sidebar',
-                    },
-                    content));
-          }
+          slots.navLinks?.map((cur, i) => {
+            let content;
+
+            if (cur.html) {
+              content = cur.html;
+            } else {
+              let title;
+              let href;
+
+              switch (cur.auto) {
+                case 'home':
+                  title = data.wikiName;
+                  href = to('localized.home');
+                  break;
+                case 'current':
+                  title = slots.title;
+                  href = '';
+                  break;
+                case null:
+                case undefined:
+                  title = cur.title;
+                  href = to(...cur.path);
+                  break;
+              }
 
-          return html.tag('div',
-            {
-              [html.onlyIfContent]: true,
-              id,
-              class: [
-                'sidebar-column',
-                wide && 'wide',
-                !collapse && 'no-hide',
-                stickyMode !== 'static' && `sticky-${stickyMode}`,
-                ...sidebarClasses,
-              ],
-            },
-            sidebarContent);
-        }
-
-        const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
-        const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
-        const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
-
-        const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
-          html.tag('div', {id: 'image-overlay-content-container'}, [
-            html.tag('a', {id: 'image-overlay-image-container'}, [
-              html.tag('img', {id: 'image-overlay-image'}),
-              html.tag('img', {id: 'image-overlay-image-thumb'}),
-            ]),
-            html.tag('div', {id: 'image-overlay-action-container'}, [
-              html.tag('div', {id: 'image-overlay-action-content-without-size'},
-                language.$('releaseInfo.viewOriginalFile', {
-                  link: html.tag('a', {class: 'image-overlay-view-original'},
-                    language.$('releaseInfo.viewOriginalFile.link')),
-                })),
-
-              html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-                language.$('releaseInfo.viewOriginalFile.withSize', {
-                  link: html.tag('a', {class: 'image-overlay-view-original'},
-                    language.$('releaseInfo.viewOriginalFile.link')),
-                  size: html.tag('span',
-                    {[html.joinChildren]: ''},
-                    [
-                      html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
-                        language.$('count.fileSize.kilobytes', {
-                          kilobytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
-                        })),
-                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                        language.$('count.fileSize.megabytes', {
-                          megabytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
-                        })),
-                    ]),
-                }),
-
-                html.tag('span', {id: 'image-overlay-file-size-warning'},
-                  language.$('releaseInfo.viewOriginalFile.sizeWarning')),
-              ]),
-            ]),
-          ]));
+              content = html.tag('a',
+                {href},
+                title);
+            }
+
+            let className;
+
+            if (cur.auto === 'current') {
+              className = 'current';
+            } else if (
+              slots.navLinkStyle === 'hierarchical' &&
+              i === slots.navLinks.length - 1
+            ) {
+              className = 'current';
+            }
+
+            return html.tag('span',
+              {class: className},
+              [
+                html.tag('span',
+                  {class: 'nav-link-content'},
+                  content),
+                html.tag('span',
+                  {[html.onlyIfContent]: true, class: 'nav-link-accent'},
+                  cur.accent),
+              ]);
+          })),
+
+        html.tag('div',
+          {[html.onlyIfContent]: true, class: 'nav-bottom-row'},
+          slots.navBottomRowContent),
+
+        html.tag('div',
+          {[html.onlyIfContent]: true, class: 'nav-content'},
+          slots.navContent),
+      ])
+
+    const generateSidebarHTML = (side, id) => {
+      const content = slots[side + 'Content'];
+      const multiple = slots[side + 'Multiple'];
+      const stickyMode = slots[side + 'StickyMode'];
+      const wide = slots[side + 'Wide'];
+      const collapse = slots[side + 'Collapse'];
+
+      let sidebarClasses = [];
+      let sidebarContent = html.blank();
+
+      if (!html.isBlank(content)) {
+        sidebarClasses = ['sidebar'];
+        sidebarContent = content;
+      } else if (multiple) {
+        sidebarClasses = ['sidebar-multiple'];
+        sidebarContent =
+          multiple
+            .filter(Boolean)
+            .map(({content}) =>
+              html.tag('div',
+                {
+                  [html.onlyIfContent]: true,
+                  class: 'sidebar',
+                },
+                content));
+      }
+
+      return html.tag('div',
+        {
+          [html.onlyIfContent]: true,
+          id,
+          class: [
+            'sidebar-column',
+            wide && 'wide',
+            !collapse && 'no-hide',
+            stickyMode !== 'static' && `sticky-${stickyMode}`,
+            ...sidebarClasses,
+          ],
+        },
+        sidebarContent);
+    }
+
+    const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
+    const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
+    const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+
+    const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('a', {id: 'image-overlay-image-container'}, [
+          html.tag('img', {id: 'image-overlay-image'}),
+          html.tag('img', {id: 'image-overlay-image-thumb'}),
+        ]),
+        html.tag('div', {id: 'image-overlay-action-container'}, [
+          html.tag('div', {id: 'image-overlay-action-content-without-size'},
+            language.$('releaseInfo.viewOriginalFile', {
+              link: html.tag('a', {class: 'image-overlay-view-original'},
+                language.$('releaseInfo.viewOriginalFile.link')),
+            })),
 
-        const layoutHTML = [
-          navHTML,
-          // banner.position === 'top' && bannerHTML,
-          // secondaryNavHTML,
-          html.tag('div',
-            {
-              class: [
-                'layout-columns',
-                !collapseSidebars && 'vertical-when-thin',
-                (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
-                (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
-                !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
-                sidebarLeftHTML && 'has-sidebar-left',
-                sidebarRightHTML && 'has-sidebar-right',
-              ],
-            },
-            [
-              sidebarLeftHTML,
-              mainHTML,
-              sidebarRightHTML,
+          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+            language.$('releaseInfo.viewOriginalFile.withSize', {
+              link: html.tag('a', {class: 'image-overlay-view-original'},
+                language.$('releaseInfo.viewOriginalFile.link')),
+              size: html.tag('span',
+                {[html.joinChildren]: ''},
+                [
+                  html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                    language.$('count.fileSize.kilobytes', {
+                      kilobytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
+                    })),
+                  html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                    language.$('count.fileSize.megabytes', {
+                      megabytes: html.tag('span', {class: 'image-overlay-file-size-count'}),
+                    })),
+                ]),
+            }),
+
+            html.tag('span', {id: 'image-overlay-file-size-warning'},
+              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
+          ]),
+        ]),
+      ]));
+
+    const layoutHTML = [
+      navHTML,
+      // banner.position === 'top' && bannerHTML,
+      // secondaryNavHTML,
+      html.tag('div',
+        {
+          class: [
+            'layout-columns',
+            !collapseSidebars && 'vertical-when-thin',
+            (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
+            (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
+            !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
+            sidebarLeftHTML && 'has-sidebar-left',
+            sidebarRightHTML && 'has-sidebar-right',
+          ],
+        },
+        [
+          sidebarLeftHTML,
+          mainHTML,
+          sidebarRightHTML,
+        ]),
+      // banner.position === 'bottom' && bannerHTML,
+      footerHTML,
+    ].filter(Boolean).join('\n');
+
+    return html.tags([
+      `<!DOCTYPE html>`,
+      html.tag('html',
+        {
+          lang: language.intlCode,
+          'data-language-code': language.code,
+
+          /*
+          'data-url-key': 'localized.' + pagePath[0],
+          ...Object.fromEntries(
+            pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
+          */
+
+          'data-rebase-localized': to('localized.root'),
+          'data-rebase-shared': to('shared.root'),
+          'data-rebase-media': to('media.root'),
+          'data-rebase-data': to('data.root'),
+        },
+        [
+          // developersComment,
+
+          html.tag('head', [
+            html.tag('title',
+              (slots.showWikiNameInTitle
+                ? language.formatString('misc.pageTitle.withWikiName', {
+                    title: slots.title,
+                    wikiName: data.wikiName,
+                  })
+                : language.formatString('misc.pageTitle', {
+                    title: slots.title,
+                  }))),
+
+            html.tag('meta', {charset: 'utf-8'}),
+            html.tag('meta', {
+              name: 'viewport',
+              content: 'width=device-width, initial-scale=1',
+            }),
+
+            /*
+            ...(
+              Object.entries(meta)
+                .filter(([key, value]) => value)
+                .map(([key, value]) => html.tag('meta', {[key]: value}))),
+
+            canonical &&
+              html.tag('link', {
+                rel: 'canonical',
+                href: canonical,
+              }),
+
+            ...(
+              localizedCanonical
+                .map(({lang, href}) => html.tag('link', {
+                  rel: 'alternate',
+                  hreflang: lang,
+                  href,
+                }))),
+
+            */
+
+            // slots.socialEmbed,
+
+            html.tag('link', {
+              rel: 'stylesheet',
+              href: to('shared.staticFile', `site4.css?${cachebust}`),
+            }),
+
+            html.tag('style', [
+              (empty(slots.colorStyleRules)
+                ? relations.defaultColorStyleRules
+                : slots.colorStyleRules),
+              slots.additionalStyleRules,
             ]),
-          // banner.position === 'bottom' && bannerHTML,
-          footerHTML,
-        ].filter(Boolean).join('\n');
 
-        const documentHTML = html.tags([
-          `<!DOCTYPE html>`,
-          html.tag('html',
-            {
-              lang: language.intlCode,
-              'data-language-code': language.code,
-
-              /*
-              'data-url-key': 'localized.' + pagePath[0],
-              ...Object.fromEntries(
-                pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
-              */
-
-              'data-rebase-localized': to('localized.root'),
-              'data-rebase-shared': to('shared.root'),
-              'data-rebase-media': to('media.root'),
-              'data-rebase-data': to('data.root'),
-            },
-            [
-              // developersComment,
-
-              html.tag('head', [
-                html.tag('title',
-                  (slots.showWikiNameInTitle
-                    ? language.formatString('misc.pageTitle.withWikiName', {
-                        title: slots.title,
-                        wikiName: data.wikiName,
-                      })
-                    : language.formatString('misc.pageTitle', {
-                        title: slots.title,
-                      }))),
-
-                html.tag('meta', {charset: 'utf-8'}),
-                html.tag('meta', {
-                  name: 'viewport',
-                  content: 'width=device-width, initial-scale=1',
-                }),
-
-                /*
-                ...(
-                  Object.entries(meta)
-                    .filter(([key, value]) => value)
-                    .map(([key, value]) => html.tag('meta', {[key]: value}))),
-
-                canonical &&
-                  html.tag('link', {
-                    rel: 'canonical',
-                    href: canonical,
-                  }),
-
-                ...(
-                  localizedCanonical
-                    .map(({lang, href}) => html.tag('link', {
-                      rel: 'alternate',
-                      hreflang: lang,
-                      href,
-                    }))),
-
-                */
-
-                // slots.socialEmbed,
-
-                html.tag('link', {
-                  rel: 'stylesheet',
-                  href: to('shared.staticFile', `site4.css?${cachebust}`),
-                }),
-
-                html.tag('style', [
-                  (empty(slots.colorStyleRules)
-                    ? relations.defaultColorStyleRules
-                    : slots.colorStyleRules),
-                  slots.additionalStyleRules,
-                ]),
+            html.tag('script', {
+              src: to('shared.staticFile', `lazy-loading.js?${cachebust}`),
+            }),
+          ]),
 
-                html.tag('script', {
-                  src: to('shared.staticFile', `lazy-loading.js?${cachebust}`),
-                }),
+          html.tag('body',
+            // {style: body.style || ''},
+            [
+              html.tag('div', {id: 'page-container'}, [
+                // mainHTML && skippersHTML,
+                layoutHTML,
               ]),
 
-              html.tag('body',
-                // {style: body.style || ''},
-                [
-                  html.tag('div', {id: 'page-container'}, [
-                    // mainHTML && skippersHTML,
-                    layoutHTML,
-                  ]),
-
-                  // infoCardHTML,
-                  imageOverlayHTML,
-
-                  html.tag('script', {
-                    type: 'module',
-                    src: to('shared.staticFile', `client.js?${cachebust}`),
-                  }),
-                ]),
-            ])
-        ]);
+              // infoCardHTML,
+              imageOverlayHTML,
 
-        return documentHTML;
-      },
-    });
+              html.tag('script', {
+                type: 'module',
+                src: to('shared.staticFile', `client.js?${cachebust}`),
+              }),
+            ]),
+        ])
+    ]);
   },
 };
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
index 42b2c42..5bdcc3a 100644
--- a/src/content/dependencies/generatePreviousNextLinks.js
+++ b/src/content/dependencies/generatePreviousNextLinks.js
@@ -5,32 +5,26 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  generate({html, language}) {
-    return html.template({
-      annotation: `generatePreviousNextLinks`,
-
-      slots: {
-        previousLink: {type: 'html'},
-        nextLink: {type: 'html'},
-      },
+  slots: {
+    previousLink: {type: 'html'},
+    nextLink: {type: 'html'},
+  },
 
-      content(slots) {
-        return [
-          !html.isBlank(slots.previousLink) &&
-            slots.previousLink.slots({
-              tooltip: true,
-              attributes: {id: 'previous-button'},
-              content: language.$('misc.nav.previous'),
-            }),
+  generate(slots, {html, language}) {
+    return html.tags([
+      !html.isBlank(slots.previousLink) &&
+        slots.previousLink.slots({
+          tooltip: true,
+          attributes: {id: 'previous-button'},
+          content: language.$('misc.nav.previous'),
+        }),
 
-          !html.isBlank(slots.nextLink) &&
-            slots.nextLink?.slots({
-              tooltip: true,
-              attributes: {id: 'next-button'},
-              content: language.$('misc.nav.next'),
-            }),
-        ].filter(Boolean);
-      },
-    });
+      !html.isBlank(slots.nextLink) &&
+        slots.nextLink?.slots({
+          tooltip: true,
+          attributes: {id: 'next-button'},
+          content: language.$('misc.nav.next'),
+        }),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 2b342d0..78d3e50 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -18,32 +18,26 @@ export default {
     };
   },
 
-  generate(relations, {html, language}) {
-    return html.template({
-      annotation: `generateReleaseInfoContributionsLine`,
+  slots: {
+    stringKey: {type: 'string'},
 
-      slots: {
-        stringKey: {type: 'string'},
-
-        showContribution: {type: 'boolean', default: true},
-        showIcons: {type: 'boolean', default: true},
-      },
+    showContribution: {type: 'boolean', default: true},
+    showIcons: {type: 'boolean', default: true},
+  },
 
-      content(slots) {
-        if (!relations.contributionLinks) {
-          return html.blank();
-        }
+  generate(relations, slots, {html, language}) {
+    if (!relations.contributionLinks) {
+      return html.blank();
+    }
 
-        return language.$(slots.stringKey, {
-          artists:
-            language.formatConjunctionList(
-              relations.contributionLinks.map(link =>
-                link.slots({
-                  showContribution: slots.showContribution,
-                  showIcons: slots.showIcons,
-                }))),
-        });
-      },
+    return language.$(slots.stringKey, {
+      artists:
+        language.formatConjunctionList(
+          relations.contributionLinks.map(link =>
+            link.slots({
+              showContribution: slots.showContribution,
+              showIcons: slots.showIcons,
+            }))),
     });
   },
 };
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 6602a2a..e5f7cc5 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -1,39 +1,33 @@
 export default {
   extraDependencies: ['html'],
 
-  generate({html}) {
-    return html.template({
-      annotation: `generateStickyHeadingContainer`,
-
-      slots: {
-        title: {type: 'html'},
-        cover: {type: 'html'},
-      },
+  slots: {
+    title: {type: 'html'},
+    cover: {type: 'html'},
+  },
 
-      content(slots) {
-        const hasCover = !html.isBlank(slots.cover);
+  generate(slots, {html}) {
+    const hasCover = !html.isBlank(slots.cover);
 
-        return html.tag('div',
-          {
-            class: [
-              'content-sticky-heading-container',
-              hasCover && 'has-cover',
-            ],
-          },
-          [
-            html.tag('div', {class: 'content-sticky-heading-row'}, [
-              html.tag('h1', slots.title),
+    return html.tag('div',
+      {
+        class: [
+          'content-sticky-heading-container',
+          hasCover && 'has-cover',
+        ],
+      },
+      [
+        html.tag('div', {class: 'content-sticky-heading-row'}, [
+          html.tag('h1', slots.title),
 
-              hasCover &&
-                html.tag('div', {class: 'content-sticky-heading-cover-container'},
-                  html.tag('div', {class: 'content-sticky-heading-cover'},
-                    slots.cover.slot('displayMode', 'thumbnail'))),
-            ]),
+          hasCover &&
+            html.tag('div', {class: 'content-sticky-heading-cover-container'},
+              html.tag('div', {class: 'content-sticky-heading-cover'},
+                slots.cover.slot('displayMode', 'thumbnail'))),
+        ]),
 
-            html.tag('div', {class: 'content-sticky-subheading-row'},
-              html.tag('h2', {class: 'content-sticky-subheading'})),
-          ]);
-      },
-    });
+        html.tag('div', {class: 'content-sticky-subheading-row'},
+          html.tag('h2', {class: 'content-sticky-subheading'})),
+      ]);
   },
 };
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index e2e9f48..6688a33 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -22,34 +22,28 @@ export default {
     };
   },
 
-  generate(relations, {html, language}) {
-    return html.template({
-      annotation: `generateTrackList`,
-
-      slots: {
-        showContribution: {type: 'boolean', default: false},
-        showIcons: {type: 'boolean', default: false},
-      },
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+  },
 
-      content(slots) {
-        return html.tag('ul',
-          relations.items.map(({trackLink, contributionLinks}) =>
-            html.tag('li',
-              language.$('trackList.item.withArtists', {
-                track: trackLink,
-                by:
-                  html.tag('span', {class: 'by'},
-                    language.$('trackList.item.withArtists.by', {
-                      artists:
-                        language.formatConjunctionList(
-                          contributionLinks.map(link =>
-                            link.slots({
-                              showContribution: slots.showContribution,
-                              showIcons: slots.showIcons,
-                            }))),
-                    })),
-              }))));
-      },
-    });
+  generate(relations, slots, {html, language}) {
+    return html.tag('ul',
+      relations.items.map(({trackLink, contributionLinks}) =>
+        html.tag('li',
+          language.$('trackList.item.withArtists', {
+            track: trackLink,
+            by:
+              html.tag('span', {class: 'by'},
+                language.$('trackList.item.withArtists.by', {
+                  artists:
+                    language.formatConjunctionList(
+                      contributionLinks.map(link =>
+                        link.slots({
+                          showContribution: slots.showContribution,
+                          showIcons: slots.showIcons,
+                        }))),
+                })),
+          }))));
   },
 };
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index bd7898b..2fbe118 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -24,189 +24,181 @@ export default {
     return data;
   },
 
-  generate(data, {
+  slots: {
+    src: {type: 'string'},
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    thumb: {type: 'string'},
+
+    reveal: {type: 'boolean', default: true},
+    link: {type: 'boolean', default: false},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
+    id: {type: 'string'},
+    class: {type: 'string'},
+    alt: {type: 'string'},
+    width: {type: 'number'},
+    height: {type: 'number'},
+
+    missingSourceContent: {type: 'html'},
+  },
+
+  generate(data, slots, {
     getSizeOfImageFile,
     html,
     language,
     thumb,
     to,
   }) {
-    return html.template({
-      annotation: 'image',
-
-      slots: {
-        src: {
-          type: 'string',
-        },
-
-        path: {
-          validate: v => v.validateArrayItems(v.isString),
-        },
-
-        thumb: {type: 'string'},
-
-        reveal: {type: 'boolean', default: true},
-        link: {type: 'boolean', default: false},
-        lazy: {type: 'boolean', default: false},
-        square: {type: 'boolean', default: false},
-
-        id: {type: 'string'},
-        class: {type: 'string'},
-        alt: {type: 'string'},
-        width: {type: 'number'},
-        height: {type: 'number'},
-
-        missingSourceContent: {type: 'html'},
-      },
-
-      content(slots) {
-        let originalSrc;
-
-        if (slots.src) {
-          originalSrc = slots.src;
-        } else if (!empty(slots.path)) {
-          originalSrc = to(...slots.path);
-        } else {
-          originalSrc = '';
-        }
-
-        const thumbSrc =
-          originalSrc &&
-            (slots.thumb
-              ? thumb[slots.thumb](originalSrc)
-              : originalSrc);
-
-        const willLink = typeof slots.link === 'string' || slots.link;
-
-        const willReveal =
-          slots.reveal &&
-          originalSrc &&
-          !empty(data.contentWarnings);
-
-        const willSquare = slots.square;
-
-        const idOnImg = willLink ? null : slots.id;
-        const idOnLink = willLink ? slots.id : null;
-        const classOnImg = willLink ? null : slots.class;
-        const classOnLink = willLink ? slots.class : null;
-
-        if (!originalSrc) {
-          return prepare(
-            html.tag('div', {class: 'image-text-area'},
-              slots.missingSourceContent));
-        }
-
-        let fileSize = null;
-        if (willLink) {
-          const mediaRoot = to('media.root');
-          if (originalSrc.startsWith(mediaRoot)) {
-            fileSize =
-              getSizeOfImageFile(
-                originalSrc
-                  .slice(mediaRoot.length)
-                  .replace(/^\//, ''));
-          }
-        }
-
-        let reveal = null;
-        if (willReveal) {
-          reveal = [
-            language.$('misc.contentWarnings', {
-              warnings: language.formatUnitList(data.contentWarnings),
+    let originalSrc;
+
+    if (slots.src) {
+      originalSrc = slots.src;
+    } else if (!empty(slots.path)) {
+      originalSrc = to(...slots.path);
+    } else {
+      originalSrc = '';
+    }
+
+    const thumbSrc =
+      originalSrc &&
+        (slots.thumb
+          ? thumb[slots.thumb](originalSrc)
+          : originalSrc);
+
+    const willLink = typeof slots.link === 'string' || slots.link;
+
+    const willReveal =
+      slots.reveal &&
+      originalSrc &&
+      !empty(data.contentWarnings);
+
+    const willSquare = slots.square;
+
+    const idOnImg = willLink ? null : slots.id;
+    const idOnLink = willLink ? slots.id : null;
+    const classOnImg = willLink ? null : slots.class;
+    const classOnLink = willLink ? slots.class : null;
+
+    if (!originalSrc) {
+      return prepare(
+        html.tag('div', {class: 'image-text-area'},
+          slots.missingSourceContent));
+    }
+
+    let fileSize = null;
+    if (willLink) {
+      const mediaRoot = to('media.root');
+      if (originalSrc.startsWith(mediaRoot)) {
+        fileSize =
+          getSizeOfImageFile(
+            originalSrc
+              .slice(mediaRoot.length)
+              .replace(/^\//, ''));
+      }
+    }
+
+    let reveal = null;
+    if (willReveal) {
+      reveal = [
+        language.$('misc.contentWarnings', {
+          warnings: language.formatUnitList(data.contentWarnings),
+        }),
+        html.tag('br'),
+        html.tag('span', {class: 'reveal-interaction'},
+          language.$('misc.contentWarnings.reveal')),
+      ];
+    }
+
+    const imgAttributes = {
+      id: idOnImg,
+      class: classOnImg,
+      alt: slots.alt,
+      width: slots.width,
+      height: slots.height,
+      'data-original-size': fileSize,
+    };
+
+    const nonlazyHTML =
+      originalSrc &&
+        prepare(
+          html.tag('img', {
+            ...imgAttributes,
+            src: thumbSrc,
+          }));
+
+    if (slots.lazy) {
+      return html.tags([
+        html.tag('noscript', nonlazyHTML),
+        prepare(
+          html.tag('img',
+            {
+              ...imgAttributes,
+              class: 'lazy',
+              'data-original': thumbSrc,
             }),
-            html.tag('br'),
-            html.tag('span', {class: 'reveal-interaction'},
-              language.$('misc.contentWarnings.reveal')),
-          ];
-        }
-
-        const imgAttributes = {
-          id: idOnImg,
-          class: classOnImg,
-          alt: slots.alt,
-          width: slots.width,
-          height: slots.height,
-          'data-original-size': fileSize,
-        };
-
-        const nonlazyHTML =
-          originalSrc &&
-            prepare(
-              html.tag('img', {
-                ...imgAttributes,
-                src: thumbSrc,
-              }));
-
-        if (slots.lazy) {
-          return html.tags([
-            html.tag('noscript', nonlazyHTML),
-            prepare(
-              html.tag('img',
-                {
-                  ...imgAttributes,
-                  class: 'lazy',
-                  'data-original': thumbSrc,
-                }),
-              true),
+          true),
+      ]);
+    }
+
+    return nonlazyHTML;
+
+    function prepare(content, hide = false) {
+      let wrapped = content;
+
+      wrapped =
+        html.tag('div', {class: 'image-container'},
+          html.tag('div', {class: 'image-inner-area'},
+            wrapped));
+
+      if (willReveal) {
+        wrapped =
+          html.tag('div', {class: 'reveal'}, [
+            wrapped,
+            html.tag('span', {class: 'reveal-text-container'},
+              html.tag('span', {class: 'reveal-text'},
+                reveal)),
           ]);
-        }
-
-        return nonlazyHTML;
-
-        function prepare(content, hide = false) {
-          let wrapped = content;
-
-          wrapped =
-            html.tag('div', {class: 'image-container'},
-              html.tag('div', {class: 'image-inner-area'},
-                wrapped));
-
-          if (willReveal) {
-            wrapped =
-              html.tag('div', {class: 'reveal'}, [
-                wrapped,
-                html.tag('span', {class: 'reveal-text-container'},
-                  html.tag('span', {class: 'reveal-text'},
-                    reveal)),
-              ]);
-          }
-
-          if (willSquare) {
-            wrapped =
-              html.tag('div',
-                {
-                  class: [
-                    'square',
-                    hide && !willLink && 'js-hide'
-                  ],
-                },
-
-                html.tag('div', {class: 'square-content'},
-                  wrapped));
-          }
-
-          if (willLink) {
-            wrapped = html.tag('a',
-              {
-                id: idOnLink,
-                class: [
-                  'box',
-                  'image-link',
-                  hide && 'js-hide',
-                  classOnLink,
-                ],
-
-                href:
-                  (typeof slots.link === 'string'
-                    ? slots.link
-                    : originalSrc),
-              },
-              wrapped);
-          }
-
-          return wrapped;
-        }
-      },
-    });
-  }
+      }
+
+      if (willSquare) {
+        wrapped =
+          html.tag('div',
+            {
+              class: [
+                'square',
+                hide && !willLink && 'js-hide'
+              ],
+            },
+
+            html.tag('div', {class: 'square-content'},
+              wrapped));
+      }
+
+      if (willLink) {
+        wrapped = html.tag('a',
+          {
+            id: idOnLink,
+            class: [
+              'box',
+              'image-link',
+              hide && 'js-hide',
+              classOnLink,
+            ],
+
+            href:
+              (typeof slots.link === 'string'
+                ? slots.link
+                : originalSrc),
+          },
+          wrapped);
+      }
+
+      return wrapped;
+    }
+  },
 };
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 1d0e2d6..cc0cb35 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -27,48 +27,39 @@ export default {
     return {contribution};
   },
 
-  generate(data, relations, {
-    html,
-    language,
-  }) {
-    return html.template({
-      annotation: 'linkContribution',
-
-      slots: {
-        showContribution: {type: 'boolean', default: false},
-        showIcons: {type: 'boolean', default: false},
-      },
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+  },
 
-      content(slots) {
-        const hasContributionPart = !!(slots.showContribution && data.contribution);
-        const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons));
+  generate(data, relations, slots, {html, language}) {
+    const hasContributionPart = !!(slots.showContribution && data.contribution);
+    const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons));
 
-        const externalLinks = hasExternalPart &&
-          html.tag('span',
-            {[html.noEdgeWhitespace]: true, class: 'icons'},
-            language.formatUnitList(relations.artistIcons));
+    const externalLinks = hasExternalPart &&
+      html.tag('span',
+        {[html.noEdgeWhitespace]: true, class: 'icons'},
+        language.formatUnitList(relations.artistIcons));
 
-        return (
-          (hasContributionPart
-            ? (hasExternalPart
-                ? language.$('misc.artistLink.withContribution.withExternalLinks', {
-                    artist: relations.artistLink,
-                    contrib: data.contribution,
-                    links: externalLinks,
-                  })
-                : language.$('misc.artistLink.withContribution', {
-                    artist: relations.artistLink,
-                    contrib: data.contribution,
-                  }))
-            : (hasExternalPart
-                ? language.$('misc.artistLink.withExternalLinks', {
-                    artist: relations.artistLink,
-                    links: externalLinks,
-                  })
-                : language.$('misc.artistLink', {
-                    artist: relations.artistLink,
-                  }))));
-      },
-    });
+    return (
+      (hasContributionPart
+        ? (hasExternalPart
+            ? language.$('misc.artistLink.withContribution.withExternalLinks', {
+                artist: relations.artistLink,
+                contrib: data.contribution,
+                links: externalLinks,
+              })
+            : language.$('misc.artistLink.withContribution', {
+                artist: relations.artistLink,
+                contrib: data.contribution,
+              }))
+        : (hasExternalPart
+            ? language.$('misc.artistLink.withExternalLinks', {
+                artist: relations.artistLink,
+                links: externalLinks,
+              })
+            : language.$('misc.artistLink', {
+                artist: relations.artistLink,
+              }))));
   },
 };
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index 9109ab5..98e2c8b 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -8,66 +8,60 @@ export default {
     'to',
   ],
 
-  generate({
+  slots: {
+    href: {type: 'string'},
+    path: {validate: v => v.validateArrayItems(v.isString)},
+    hash: {type: 'string'},
+
+    tooltip: {validate: v => v.isString},
+    attributes: {validate: v => v.isAttributes},
+    color: {validate: v => v.isColor},
+    content: {type: 'html'},
+  },
+
+  generate(slots, {
     appendIndexHTML,
     getColors,
     html,
     to,
   }) {
-    return html.template({
-      annotation: 'linkTemplate',
-
-      slots: {
-        href: {type: 'string'},
-        path: {validate: v => v.validateArrayItems(v.isString)},
-        hash: {type: 'string'},
-
-        tooltip: {validate: v => v.isString},
-        attributes: {validate: v => v.isAttributes},
-        color: {validate: v => v.isColor},
-        content: {type: 'html'},
-      },
-
-      content(slots) {
-        let href = slots.href;
-        let style;
-        let title;
+    let href = slots.href;
+    let style;
+    let title;
 
-        if (!href && !empty(slots.path)) {
-          href = to(...slots.path);
-        }
+    if (!href && !empty(slots.path)) {
+      href = to(...slots.path);
+    }
 
-        if (appendIndexHTML) {
-          if (
-            /^(?!https?:\/\/).+\/$/.test(href) &&
-            href.endsWith('/')
-          ) {
-            href += 'index.html';
-          }
-        }
+    if (appendIndexHTML) {
+      if (
+        /^(?!https?:\/\/).+\/$/.test(href) &&
+        href.endsWith('/')
+      ) {
+        href += 'index.html';
+      }
+    }
 
-        if (slots.hash) {
-          href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
-        }
+    if (slots.hash) {
+      href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+    }
 
-        if (slots.color) {
-          const {primary, dim} = getColors(slots.color);
-          style = `--primary-color: ${primary}; --dim-color: ${dim}`;
-        }
+    if (slots.color) {
+      const {primary, dim} = getColors(slots.color);
+      style = `--primary-color: ${primary}; --dim-color: ${dim}`;
+    }
 
-        if (slots.tooltip) {
-          title = slots.tooltip;
-        }
+    if (slots.tooltip) {
+      title = slots.tooltip;
+    }
 
-        return html.tag('a',
-          {
-            ...slots.attributes ?? {},
-            href,
-            style,
-            title,
-          },
-          slots.content);
+    return html.tag('a',
+      {
+        ...slots.attributes ?? {},
+        href,
+        style,
+        title,
       },
-    });
+      slots.content);
   },
 }
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index bf4233f..57404ba 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -180,7 +180,14 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
+  slots: {
+    mode: {
+      validate: v => v.is('inline', 'multiline', 'lyrics'),
+      default: 'multiline',
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
     let linkIndex = 0;
 
     // This array contains only straight text and link nodes, which are directly
@@ -233,93 +240,80 @@ export default {
         return getPlaceholder(node, data.content);
       });
 
-    return html.template({
-      annotation: `transformContent`,
+    // In inline mode, no further processing is needed!
 
-      slots: {
-        mode: {
-          validate: v => v.is('inline', 'multiline', 'lyrics'),
-          default: 'multiline',
-        },
-      },
+    if (slots.mode === 'inline') {
+      return html.tags(contentFromNodes.map(node => node.data));
+    }
 
-      content(slots) {
-        // In inline mode, no further processing is needed!
+    // Multiline mode has a secondary processing stage where it's passed...
+    // through marked! Rolling your own Markdown only gets you so far :D
 
-        if (slots.mode === 'inline') {
-          return html.tags(contentFromNodes.map(node => node.data));
-        }
-
-        // Multiline mode has a secondary processing stage where it's passed...
-        // through marked! Rolling your own Markdown only gets you so far :D
-
-        const markedOptions = {
-          headerIds: false,
-          mangle: false,
-        };
-
-        // This is separated into its own function just since we're gonna reuse
-        // it in a minute if everything goes to heck in lyrics mode.
-        const transformMultiline = () => {
-          const markedInput =
-            contentFromNodes
-              .map(node => {
-                if (node.type === 'text') {
-                  return node.data;
-                } else {
-                  return node.data.toString();
-                }
-              })
-              .join('')
-
-              // Compress multiple line breaks into single line breaks.
-              .replace(/\n{2,}/g, '\n')
-              // Expand line breaks which don't follow a list.
-              .replace(/(?<!^ *-.*)\n+/gm, '\n\n')
-              // Expand line breaks which are at the end of a list.
-              .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n');
-
-          return marked.parse(markedInput, markedOptions);
-        }
-
-        if (slots.mode === 'multiline') {
-          // Unfortunately, we kind of have to be super evil here and stringify
-          // the links, or else parse marked's output into html tags, which is
-          // very out of scope at the moment.
-          return transformMultiline();
-        }
-
-        // Lyrics mode goes through marked too, but line breaks are processed
-        // differently. Instead of having each line get its own paragraph,
-        // "adjacent" lines are joined together (with blank lines separating
-        // each verse/paragraph).
-
-        if (slots.mode === 'lyrics') {
-          // If it looks like old data, using <br> instead of bunched together
-          // lines... then oh god... just use transformMultiline. Perishes.
-          if (
-            contentFromNodes.some(node =>
-              node.type === 'text' &&
-              node.data.includes('<br'))
-          ) {
-            return transformMultiline();
-          }
+    const markedOptions = {
+      headerIds: false,
+      mangle: false,
+    };
 
-          // Lyrics mode is also evil for the same stringifying reasons as
-          // multiline.
-          return marked.parse(
-            contentFromNodes
-              .map(node => {
-                if (node.type === 'text') {
-                  return node.data.replace(/\b\n\b/g, '<br>\n');
-                } else {
-                  return node.data.toString();
-                }
-              })
-              .join(''),
-            markedOptions);
-        }
-      },
-    });
+    // This is separated into its own function just since we're gonna reuse
+    // it in a minute if everything goes to heck in lyrics mode.
+    const transformMultiline = () => {
+      const markedInput =
+        contentFromNodes
+          .map(node => {
+            if (node.type === 'text') {
+              return node.data;
+            } else {
+              return node.data.toString();
+            }
+          })
+          .join('')
+
+          // Compress multiple line breaks into single line breaks.
+          .replace(/\n{2,}/g, '\n')
+          // Expand line breaks which don't follow a list.
+          .replace(/(?<!^ *-.*)\n+/gm, '\n\n')
+          // Expand line breaks which are at the end of a list.
+          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n');
+
+      return marked.parse(markedInput, markedOptions);
+    }
+
+    if (slots.mode === 'multiline') {
+      // Unfortunately, we kind of have to be super evil here and stringify
+      // the links, or else parse marked's output into html tags, which is
+      // very out of scope at the moment.
+      return transformMultiline();
+    }
+
+    // Lyrics mode goes through marked too, but line breaks are processed
+    // differently. Instead of having each line get its own paragraph,
+    // "adjacent" lines are joined together (with blank lines separating
+    // each verse/paragraph).
+
+    if (slots.mode === 'lyrics') {
+      // If it looks like old data, using <br> instead of bunched together
+      // lines... then oh god... just use transformMultiline. Perishes.
+      if (
+        contentFromNodes.some(node =>
+          node.type === 'text' &&
+          node.data.includes('<br'))
+      ) {
+        return transformMultiline();
+      }
+
+      // Lyrics mode is also evil for the same stringifying reasons as
+      // multiline.
+      return marked.parse(
+        contentFromNodes
+          .map(node => {
+            if (node.type === 'text') {
+              return node.data.replace(/\b\n\b/g, '<br>\n');
+            } else {
+              return node.data.toString();
+            }
+          })
+          .join(''),
+        markedOptions);
+    }
   },
 }