« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/page/album.js621
-rw-r--r--src/page/track.js116
-rw-r--r--src/static/site.css3
-rwxr-xr-xsrc/upd8.js80
-rw-r--r--src/util/html.js19
5 files changed, 400 insertions, 439 deletions
diff --git a/src/page/album.js b/src/page/album.js
index 12755ae9..1cadde05 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -4,8 +4,6 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
-
 import * as html from '../util/html.js';
 
 import {bindOpts, compareArrays} from '../util/sugar.js';
@@ -31,7 +29,8 @@ export function write(album, {wikiData}) {
       duration: language.formatDuration(track.duration ?? 0),
       track: link.track(track),
     };
-    return `<li style="${getLinkThemeString(track.color)}">${
+    return html.tag('li',
+      {style: getLinkThemeString(track.color)},
       compareArrays(
         track.artistContribs.map((c) => c.who),
         album.artistContribs.map((c) => c.who),
@@ -40,14 +39,12 @@ export function write(album, {wikiData}) {
         ? language.$('trackList.item.withDuration', itemOpts)
         : language.$('trackList.item.withDuration.withArtists', {
             ...itemOpts,
-            by: `<span class="by">${language.$(
-              'trackList.item.withArtists.by',
-              {
+            by: html.tag('span',
+              {class: 'by'},
+              language.$('trackList.item.withArtists.by', {
                 artists: getArtistString(track.artistContribs),
-              }
-            )}</span>`,
-          })
-    }</li>`;
+              })),
+          }));
   };
 
   const hasCommentaryEntries =
@@ -55,6 +52,11 @@ export function write(album, {wikiData}) {
   const hasAdditionalFiles = album.additionalFiles?.length > 0;
   const albumDuration = getTotalDuration(album.tracks);
 
+  const displayTrackGroups =
+    album.trackGroups &&
+      (album.trackGroups.length > 1 ||
+        !album.trackGroups[0].isDefaultTrackGroup);
+
   const listTag = getAlbumListTag(album);
 
   const data = {
@@ -142,203 +144,158 @@ export function write(album, {wikiData}) {
         },
 
         main: {
-          content: fixWS`
-                        ${
-                          cover &&
-                          generateCoverLink({
-                            src: cover,
-                            alt: language.$('misc.alt.albumCover'),
-                            tags: album.artTags,
-                          })
-                        }
-                        <h1>${language.$('albumPage.title', {
-                          album: album.name,
-                        })}</h1>
-                        <p>
-                            ${[
-                              album.artistContribs.length &&
-                                language.$('releaseInfo.by', {
-                                  artists: getArtistString(
-                                    album.artistContribs,
-                                    {
-                                      showContrib: true,
-                                      showIcons: true,
-                                    }
-                                  ),
-                                }),
-                              album.coverArtistContribs.length &&
-                                language.$('releaseInfo.coverArtBy', {
-                                  artists: getArtistString(
-                                    album.coverArtistContribs,
-                                    {
-                                      showContrib: true,
-                                      showIcons: true,
-                                    }
-                                  ),
-                                }),
-                              album.wallpaperArtistContribs.length &&
-                                language.$('releaseInfo.wallpaperArtBy', {
-                                  artists: getArtistString(
-                                    album.wallpaperArtistContribs,
-                                    {
-                                      showContrib: true,
-                                      showIcons: true,
-                                    }
-                                  ),
-                                }),
-                              album.bannerArtistContribs.length &&
-                                language.$('releaseInfo.bannerArtBy', {
-                                  artists: getArtistString(
-                                    album.bannerArtistContribs,
-                                    {
-                                      showContrib: true,
-                                      showIcons: true,
-                                    }
-                                  ),
-                                }),
-                              album.date &&
-                                language.$('releaseInfo.released', {
-                                  date: language.formatDate(album.date),
-                                }),
-                              album.coverArtDate &&
-                                +album.coverArtDate !== +album.date &&
-                                language.$('releaseInfo.artReleased', {
-                                  date: language.formatDate(album.coverArtDate),
-                                }),
-                              language.$('releaseInfo.duration', {
-                                duration: language.formatDuration(
-                                  albumDuration,
-                                  {
-                                    approximate: album.tracks.length > 1,
-                                  }
-                                ),
-                              }),
-                            ]
-                              .filter(Boolean)
-                              .join('<br>\n')}
-                        </p>
-                        ${
-                          (hasAdditionalFiles || hasCommentaryEntries) &&
-                          fixWS`<p>
-                            ${[
-                              hasAdditionalFiles &&
-                                generateAdditionalFilesShortcut(
-                                  album.additionalFiles,
-                                  {
-                                    language,
-                                  }
-                                ),
-                              hasCommentaryEntries &&
-                                language.$('releaseInfo.viewCommentary', {
-                                  link: link.albumCommentary(album, {
-                                    text: language.$(
-                                      'releaseInfo.viewCommentary.link'
-                                    ),
-                                  }),
-                                }),
-                            ]
-                              .filter(Boolean)
-                              .join('<br>\n')}</p>`
-                        }
-                        ${
-                          album.urls?.length &&
-                          `<p>${language.$('releaseInfo.listenOn', {
-                            links: language.formatDisjunctionList(
-                              album.urls.map((url) =>
-                                fancifyURL(url, {album: true})
-                              )
-                            ),
-                          })}</p>`
-                        }
-                        ${
-                          album.trackGroups &&
-                          (album.trackGroups.length > 1 ||
-                            !album.trackGroups[0].isDefaultTrackGroup)
-                            ? fixWS`
-                            <dl class="album-group-list">
-                                ${album.trackGroups
-                                  .map(
-                                    ({
-                                      name,
-                                      startIndex,
-                                      tracks,
-                                    }) => fixWS`
-                                    <dt>${language.$(
-                                      'trackList.section.withDuration',
-                                      {
-                                        duration: language.formatDuration(
-                                          getTotalDuration(tracks),
-                                          {
-                                            approximate: tracks.length > 1,
-                                          }
-                                        ),
-                                        section: name,
-                                      }
-                                    )}</dt>
-                                    <dd><${
-                                      listTag === 'ol'
-                                        ? `ol start="${startIndex + 1}"`
-                                        : listTag
-                                    }>
-                                        ${tracks
-                                          .map(trackToListItem)
-                                          .join('\n')}
-                                    </${listTag}></dd>
-                                `
-                                  )
-                                  .join('\n')}
-                            </dl>
-                        `
-                            : fixWS`
-                            <${listTag}>
-                                ${album.tracks.map(trackToListItem).join('\n')}
-                            </${listTag}>
-                        `
-                        }
-                        ${
-                          album.dateAddedToWiki &&
-                          fixWS`
-                            <p>
-                                ${[
-                                  language.$('releaseInfo.addedToWiki', {
-                                    date: language.formatDate(
-                                      album.dateAddedToWiki
-                                    ),
-                                  }),
-                                ]
-                                  .filter(Boolean)
-                                  .join('<br>\n')}
-                            </p>
-                        `
-                        }
-                        ${
-                          hasAdditionalFiles &&
-                          generateAdditionalFilesList(album.additionalFiles, {
-                            // TODO: Kinda near the metal here...
-                            getFileSize: (file) =>
-                              getSizeOfAdditionalFile(
-                                urls
-                                  .from('media.root')
-                                  .to(
-                                    'media.albumAdditionalFile',
-                                    album.directory,
-                                    file
-                                  )
-                              ),
-                            linkFile: (file) =>
-                              link.albumAdditionalFile({album, file}),
-                          })
-                        }
-                        ${
-                          album.commentary &&
-                          fixWS`
-                            <p>${language.$('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `
-                        }
-                    `,
+          content: [
+            cover && generateCoverLink({
+              src: cover,
+              alt: language.$('misc.alt.albumCover'),
+              tags: album.artTags,
+            }),
+
+            html.tag('h1', language.$('albumPage.title', {
+              album: album.name,
+            })),
+
+            html.tag('p',
+              {
+                [html.onlyIfContent]: true,
+                [html.joinChildren]: '<br>',
+              },
+              [
+                album.artistContribs.length &&
+                  language.$('releaseInfo.by', {
+                    artists: getArtistString(album.artistContribs, {
+                      showContrib: true,
+                      showIcons: true,
+                    }),
+                  }),
+
+                album.coverArtistContribs.length &&
+                  language.$('releaseInfo.coverArtBy', {
+                    artists: getArtistString(album.coverArtistContribs, {
+                      showContrib: true,
+                      showIcons: true,
+                    }),
+                  }),
+
+                album.wallpaperArtistContribs.length &&
+                  language.$('releaseInfo.wallpaperArtBy', {
+                    artists: getArtistString(album.wallpaperArtistContribs, {
+                      showContrib: true,
+                      showIcons: true,
+                    }),
+                  }),
+
+                album.bannerArtistContribs.length &&
+                  language.$('releaseInfo.bannerArtBy', {
+                    artists: getArtistString(album.bannerArtistContribs, {
+                      showContrib: true,
+                      showIcons: true,
+                    }),
+                  }),
+
+                album.date &&
+                  language.$('releaseInfo.released', {
+                    date: language.formatDate(album.date),
+                  }),
+
+                album.coverArtDate &&
+                +album.coverArtDate !== +album.date &&
+                  language.$('releaseInfo.artReleased', {
+                    date: language.formatDate(album.coverArtDate),
+                  }),
+
+                album.duration &&
+                  language.$('releaseInfo.duration', {
+                    duration: language.formatDuration(albumDuration, {
+                      approximate: album.tracks.length > 1,
+                    }),
+                  }),
+              ]),
+
+            html.tag('p',
+              {
+                [html.onlyIfContent]: true,
+                [html.joinChildren]: '<br>',
+              },
+              [
+                hasAdditionalFiles &&
+                  generateAdditionalFilesShortcut(album.additionalFiles, {
+                    language,
+                  }),
+
+                hasCommentaryEntries &&
+                  language.$('releaseInfo.viewCommentary', {
+                    link: link.albumCommentary(album, {
+                      text: language.$('releaseInfo.viewCommentary.link'),
+                    }),
+                  }),
+              ]),
+
+            album.urls?.length &&
+              html.tag('p',
+                language.$('releaseInfo.listenOn', {
+                  links: language.formatDisjunctionList(
+                    album.urls.map(url => fancifyURL(url, {album: true}))
+                  ),
+                })),
+
+            displayTrackGroups &&
+              html.tag('dl',
+                {class: 'album-group-list'},
+                album.trackGroups.flatMap(({
+                  name,
+                  startIndex,
+                  tracks,
+                }) => [
+                  html.tag('dt',
+                    language.$('trackList.section.withDuration', {
+                      duration: language.formatDuration(getTotalDuration(tracks), {
+                        approximate: tracks.length > 1,
+                      }),
+                      section: name,
+                    })),
+                  html.tag('dd',
+                    html.tag(listTag,
+                      listTag === 'ol' ? {start: startIndex + 1} : {},
+                      tracks.map(trackToListItem))),
+                ])),
+
+            !displayTrackGroups &&
+              html.tag(listTag,
+                album.tracks.map(trackToListItem)),
+
+            html.tag('p',
+              {
+                [html.onlyIfContent]: true,
+                [html.joinChildren]: '<br>',
+              },
+              [
+                album.dateAddedToWiki &&
+                  language.$('releaseInfo.addedToWiki', {
+                    date: language.formatDate(
+                      album.dateAddedToWiki
+                    ),
+                  })
+              ]),
+
+            hasAdditionalFiles &&
+              generateAdditionalFilesList(album.additionalFiles, {
+                // TODO: Kinda near the metal here...
+                getFileSize: (file) =>
+                  getSizeOfAdditionalFile(
+                    urls.from('media.root').to(
+                      'media.albumAdditionalFile',
+                      album.directory,
+                      file)),
+                linkFile: (file) =>
+                  link.albumAdditionalFile({album, file}),
+              }),
+
+            ...album.commentary ? [
+              html.tag('p', language.$('releaseInfo.artistCommentary')),
+              html.tag('blockquote', transformMultiline(album.commentary)),
+            ] : [],
+          ],
         },
 
         sidebarLeft: generateAlbumSidebar(album, null, {
@@ -387,81 +344,66 @@ export function generateAlbumSidebar(album, currentTrack, {
   language,
   transformMultiline,
 }) {
-  const listTag = getAlbumListTag(album);
+  const isAlbumPage = !currentTrack;
+  const isTrackPage = !!currentTrack;
 
-  /*
-  const trackGroups = album.trackGroups || [{
-      name: language.$('albumSidebar.trackList.fallbackGroupName'),
-      color: album.color,
-      startIndex: 0,
-      tracks: album.tracks
-  }];
-  */
+  const listTag = getAlbumListTag(album);
 
   const {trackGroups} = album;
 
   const trackToListItem = (track) =>
-    html.tag(
-      'li',
+    html.tag('li',
       {class: track === currentTrack && 'current'},
       language.$('albumSidebar.trackList.item', {
         track: link.track(track),
-      })
-    );
+      }));
 
   const nameOrDefault = (isDefaultTrackGroup, name) =>
     isDefaultTrackGroup
       ? language.$('albumSidebar.trackList.fallbackGroupName')
       : name;
 
-  const trackListPart = fixWS`
-        <h1>${link.album(album)}</h1>
-        ${trackGroups
-          .map(({name, color, startIndex, tracks, isDefaultTrackGroup}) =>
-            html.tag(
-              'details',
-              {
-                // Leave side8ar track groups collapsed on al8um homepage,
-                // since there's already a view of all the groups expanded
-                // in the main content area.
-                open: currentTrack && tracks.includes(currentTrack),
-                class: tracks.includes(currentTrack) && 'current',
-              },
-              [
-                html.tag(
-                  'summary',
-                  {style: getLinkThemeString(color)},
-                  listTag === 'ol'
-                    ? language.$('albumSidebar.trackList.group.withRange', {
-                        group: `<span class="group-name">${nameOrDefault(
-                          isDefaultTrackGroup,
-                          name
-                        )}</span>`,
-                        range: `${startIndex + 1}&ndash;${
-                          startIndex + tracks.length
-                        }`,
-                      })
-                    : language.$('albumSidebar.trackList.group', {
-                        group: `<span class="group-name">${nameOrDefault(
-                          isDefaultTrackGroup,
-                          name
-                        )}</span>`,
-                      })
-                ),
-                fixWS`
-                    <${
-                      listTag === 'ol'
-                        ? `ol start="${startIndex + 1}"`
-                        : listTag
-                    }>
-                        ${tracks.map(trackToListItem).join('\n')}
-                    </${listTag}>
-                `,
-              ]
-            )
-          )
-          .join('\n')}
-    `;
+  const trackListPart = [
+    html.tag('h1', link.album(album)),
+    ...trackGroups.map(({name, color, startIndex, tracks, isDefaultTrackGroup}) => {
+      const groupName =
+        html.tag('span',
+          {class: 'group-name'},
+          nameOrDefault(
+            isDefaultTrackGroup,
+            name
+          ));
+      return html.tag('details',
+        {
+          // Leave side8ar track groups collapsed on al8um homepage,
+          // since there's already a view of all the groups expanded
+          // in the main content area.
+          open: isTrackPage && tracks.includes(currentTrack),
+          class: tracks.includes(currentTrack) && 'current',
+        },
+        [
+          html.tag(
+            'summary',
+            {style: getLinkThemeString(color)},
+            [
+              listTag === 'ol' &&
+                language.$('albumSidebar.trackList.group.withRange', {
+                  group: groupName,
+                  range: `${startIndex + 1}&ndash;${
+                    startIndex + tracks.length
+                  }`,
+                }),
+              listTag === 'ul' &&
+                language.$('albumSidebar.trackList.group', {
+                  group: groupName,
+                }),
+            ]),
+          html.tag(listTag,
+            listTag === 'ol' ? {start: startIndex + 1} : {},
+            tracks.map(trackToListItem)),
+        ]);
+    }),
+  ];
 
   const {groups} = album;
 
@@ -473,49 +415,47 @@ export function generateAlbumSidebar(album, currentTrack, {
       const previous = index > 0 && albums[index - 1];
       return {group, next, previous};
     })
-    .map(
-      ({group, next, previous}) => fixWS`
-        <h1>${language.$('albumSidebar.groupBox.title', {
-          group: link.groupInfo(group),
-        })}</h1>
-        ${!currentTrack && transformMultiline(group.descriptionShort)}
-        ${
-          group.urls?.length &&
-          `<p>${language.$('releaseInfo.visitOn', {
-            links: language.formatDisjunctionList(
-              group.urls.map((url) => fancifyURL(url))
-            ),
-          })}</p>`
-        }
-        ${
-          !currentTrack &&
-          fixWS`
-            ${
-              next &&
-              `<p class="group-chronology-link">${language.$(
-                'albumSidebar.groupBox.next',
-                {
-                  album: link.album(next),
-                }
-              )}</p>`
-            }
-            ${
-              previous &&
-              `<p class="group-chronology-link">${language.$(
-                'albumSidebar.groupBox.previous',
-                {
-                  album: link.album(previous),
-                }
-              )}</p>`
-            }
-        `
-        }
-    `
-    );
+    // This is a map and not a flatMap because the distinction between which
+    // group sets of elements belong to matters. That means this variable is an
+    // array of arrays, and we'll need to treat it as such later!
+    .map(({group, next, previous}) => [
+      html.tag('h1', language.$('albumSidebar.groupBox.title', {
+        group: link.groupInfo(group),
+      })),
+
+      isAlbumPage &&
+        transformMultiline(group.descriptionShort),
+
+      group.urls?.length &&
+        html.tag('p', language.$('releaseInfo.visitOn', {
+          links: language.formatDisjunctionList(
+            group.urls.map((url) => fancifyURL(url))
+          ),
+        })),
+
+      ...isAlbumPage ? [
+        next &&
+          html.tag('p',
+            {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.next', {
+              album: link.album(next),
+            })),
+
+        previous &&
+          html.tag('p',
+            {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.previous', {
+              album: link.album(previous),
+            })),
+      ] : [],
+    ]);
 
   if (groupParts.length) {
-    if (currentTrack) {
-      const combinedGroupPart = groupParts.join('\n<hr>\n');
+    if (isTrackPage) {
+      const combinedGroupPart =
+        groupParts
+          .map(groupPart => groupPart.filter(Boolean).join('\n'))
+          .join('\n<hr>\n');
       return {
         multiple: [trackListPart, combinedGroupPart],
       };
@@ -536,6 +476,8 @@ export function generateAlbumSecondaryNav(
   currentTrack,
   {link, language, getLinkThemeString}
 ) {
+  const isAlbumPage = !currentTrack;
+
   const {groups} = album;
 
   if (!groups.length) {
@@ -552,20 +494,21 @@ export function generateAlbumSecondaryNav(
     })
     .map(({group, next, previous}) => {
       const previousNext =
-        !currentTrack &&
-        [
-          previous &&
-            link.album(previous, {
-              color: false,
-              text: language.$('misc.nav.previous'),
-            }),
-          next &&
-            link.album(next, {
-              color: false,
-              text: language.$('misc.nav.next'),
-            }),
-        ].filter(Boolean);
-      return html.tag('span', {style: getLinkThemeString(group.color)}, [
+        isAlbumPage &&
+          [
+            previous &&
+              link.album(previous, {
+                color: false,
+                text: language.$('misc.nav.previous'),
+              }),
+            next &&
+              link.album(next, {
+                color: false,
+                text: language.$('misc.nav.next'),
+              }),
+          ].filter(Boolean);
+      return html.tag('span',
+        {style: getLinkThemeString(group.color)}, [
         language.$('albumSidebar.groupBox.title', {
           group: link.groupInfo(group),
         }),
@@ -574,8 +517,8 @@ export function generateAlbumSecondaryNav(
     });
 
   return {
-    classes: ['dot-between-spans'],
-    content: groupParts.join('\n'),
+    classes: ['nav-links-groups'],
+    content: groupParts,
   };
 }
 
@@ -584,18 +527,21 @@ export function generateAlbumNavLinks(
   currentTrack,
   {generatePreviousNextLinks, language}
 ) {
+  const isTrackPage = !!currentTrack;
+
   if (album.tracks.length <= 1) {
     return '';
   }
 
   const previousNextLinks =
-    currentTrack &&
-    generatePreviousNextLinks(currentTrack, {
-      data: album.tracks,
-      linkKey: 'track',
-    });
+    isTrackPage &&
+      generatePreviousNextLinks(currentTrack, {
+        data: album.tracks,
+        linkKey: 'track',
+      });
+
   const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-    currentTrack
+    isTrackPage
       ? language.$('trackPage.nav.random')
       : language.$('albumPage.nav.randomTrack')
   }</a>`;
@@ -610,6 +556,8 @@ export function generateAlbumChronologyLinks(
   currentTrack,
   {generateChronologyLinks}
 ) {
+  const isTrackPage = !!currentTrack;
+
   return html.tag(
     'div',
     {
@@ -617,7 +565,7 @@ export function generateAlbumChronologyLinks(
       class: 'nav-chronology-links',
     },
     [
-      currentTrack &&
+      isTrackPage &&
         generateChronologyLinks(currentTrack, {
           contribKey: 'artistContribs',
           getThings: (artist) => [
@@ -626,7 +574,8 @@ export function generateAlbumChronologyLinks(
           ],
           headingString: 'misc.chronology.heading.track',
         }),
-      currentTrack &&
+
+      isTrackPage &&
         generateChronologyLinks(currentTrack, {
           contribKey: 'contributorContribs',
           getThings: (artist) => [
@@ -635,6 +584,7 @@ export function generateAlbumChronologyLinks(
           ],
           headingString: 'misc.chronology.heading.track',
         }),
+
       generateChronologyLinks(currentTrack || album, {
         contribKey: 'coverArtistContribs',
         dateKey: 'coverArtDate',
@@ -644,8 +594,5 @@ export function generateAlbumChronologyLinks(
         ],
         headingString: 'misc.chronology.heading.coverArt',
       }),
-    ]
-      .filter(Boolean)
-      .join('\n')
-  );
+    ]);
 }
diff --git a/src/page/track.js b/src/page/track.js
index edc8c200..436205b4 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -4,8 +4,6 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
-
 import {
   generateAlbumChronologyLinks,
   generateAlbumNavLinks,
@@ -217,44 +215,55 @@ export function write(track, {wikiData}) {
 
             html.tag('h1', language.$('trackPage.title', {track: track.name})),
 
-            html.tag('p', [
-              language.$('releaseInfo.by', {
-                artists: getArtistString(track.artistContribs, {
-                  showContrib: true,
-                  showIcons: true,
-                }),
-              }),
-              track.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', {
-                artists: getArtistString(
-                  track.coverArtistContribs,
-                  {
-                    showContrib: true,
-                    showIcons: true,
-                  }
-                ),
-              }),
-              track.date && language.$('releaseInfo.released', {
-                date: language.formatDate(track.date),
-              }),
-              track.coverArtDate && +track.coverArtDate !== +track.date &&
-                language.$('releaseInfo.artReleased', {
-                  date: language.formatDate(track.coverArtDate),
-                }),
-              track.duration && language.$('releaseInfo.duration', {
-                duration: language.formatDuration(
-                  track.duration
-                ),
-              }),
-            ].filter(Boolean).join('<br>\n')),
+            html.tag('p',
+              {
+                [html.onlyIfContent]: true,
+                [html.joinChildren]: '<br>',
+              },
+              [
+                track.artistContribs.length &&
+                  language.$('releaseInfo.by', {
+                    artists: getArtistString(track.artistContribs, {
+                      showContrib: true,
+                      showIcons: true,
+                    }),
+                  }),
+
+                track.coverArtistContribs.length &&
+                  language.$('releaseInfo.coverArtBy', {
+                    artists: getArtistString(
+                      track.coverArtistContribs,
+                      {
+                        showContrib: true,
+                        showIcons: true,
+                      }
+                    ),
+                  }),
+
+                track.date &&
+                  language.$('releaseInfo.released', {
+                    date: language.formatDate(track.date),
+                  }),
+
+                track.coverArtDate &&
+                +track.coverArtDate !== +track.date &&
+                  language.$('releaseInfo.artReleased', {
+                    date: language.formatDate(track.coverArtDate),
+                  }),
+
+                track.duration &&
+                  language.$('releaseInfo.duration', {
+                    duration: language.formatDuration(
+                      track.duration
+                    ),
+                  }),
+              ]),
 
             html.tag('p',
               (track.urls?.length
                 ? language.$('releaseInfo.listenOn', {
                     links: language.formatDisjunctionList(
-                      track.urls.map((url) =>
-                        fancifyURL(url, {language})
-                      )
-                    ),
+                      track.urls.map(url => fancifyURL(url, {language}))),
                   })
                 : language.$('releaseInfo.listenOn.noLinks'))),
 
@@ -323,7 +332,7 @@ export function write(track, {wikiData}) {
                 transformMultiline,
               })),
             ] : [],
-          ].filter(Boolean).join('\n'),
+          ],
         },
 
         sidebarLeft: generateAlbumSidebar(album, track, {
@@ -343,28 +352,31 @@ export function write(track, {wikiData}) {
               path: ['localized.album', album.directory],
               title: album.name,
             },
-            listTag === 'ol'
-              ? {
-                  html: language.$('trackPage.nav.track.withNumber', {
-                    number: album.tracks.indexOf(track) + 1,
-                    track: link.track(track, {class: 'current', to}),
-                  }),
-                }
-              : {
-                  html: language.$('trackPage.nav.track', {
-                    track: link.track(track, {class: 'current', to}),
-                  }),
-                },
+            listTag === 'ol' &&
+              {
+                html: language.$('trackPage.nav.track.withNumber', {
+                  number: album.tracks.indexOf(track) + 1,
+                  track: link.track(track, {class: 'current', to}),
+                }),
+              },
+            listTag === 'ul' &&
+              {
+                html: language.$('trackPage.nav.track', {
+                  track: link.track(track, {class: 'current', to}),
+                }),
+              },
           ].filter(Boolean),
+
           content: generateAlbumChronologyLinks(album, track, {
             generateChronologyLinks,
           }),
+
           bottomRowContent:
             album.tracks.length > 1 &&
-            generateAlbumNavLinks(album, track, {
-              generatePreviousNextLinks,
-              language,
-            }),
+              generateAlbumNavLinks(album, track, {
+                generatePreviousNextLinks,
+                language,
+              }),
         },
 
         secondaryNav: generateAlbumSecondaryNav(album, track, {
diff --git a/src/static/site.css b/src/static/site.css
index d80c57c5..328ef7d0 100644
--- a/src/static/site.css
+++ b/src/static/site.css
@@ -165,7 +165,8 @@ a:hover {
   font-weight: 800;
 }
 
-.nav-links-index > span:not(:first-child):not(.no-divider)::before {
+.nav-links-index > span:not(:first-child):not(.no-divider)::before,
+.nav-links-groups > span:not(:first-child):not(.no-divider)::before {
   content: "\0020\00b7\0020";
   font-weight: 800;
 }
diff --git a/src/upd8.js b/src/upd8.js
index 0399b290..83eecb46 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -149,7 +149,7 @@ import FileSizePreloader from './file-size-preloader.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 10;
+const CACHEBUST = 11;
 
 const DEFAULT_STRINGS_FILE = 'strings-default.json';
 
@@ -951,33 +951,28 @@ writePage.html = (
 
   const mainHTML =
     main.content &&
-    html.tag(
-      'main',
+    html.tag('main',
       {
         id: 'content',
         class: main.classes,
       },
-      main.content
-    );
+      main.content);
 
   const footerHTML =
     footer.content &&
-    html.tag(
-      'footer',
+    html.tag('footer',
       {
         id: 'footer',
         class: footer.classes,
       },
-      footer.content
-    );
+      footer.content);
 
   const generateSidebarHTML = (
     id,
     {content, multiple, classes, collapse = true, wide = false}
   ) =>
     content
-      ? html.tag(
-          'div',
+      ? html.tag('div',
           {
             id,
             class: [
@@ -988,11 +983,9 @@ writePage.html = (
               ...classes,
             ],
           },
-          content
-        )
+          content)
       : multiple
-      ? html.tag(
-          'div',
+      ? html.tag('div',
           {
             id,
             class: [
@@ -1003,9 +996,7 @@ writePage.html = (
             ],
           },
           multiple.map((content) =>
-            html.tag('div', {class: ['sidebar', ...classes]}, content)
-          )
-        )
+            html.tag('div', {class: ['sidebar', ...classes]}, content)))
       : '';
 
   const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
@@ -1064,17 +1055,14 @@ writePage.html = (
       partContent = html.tag('a', attributes, linkTitle);
     }
 
-    const part = html.tag(
-      'span',
+    const part = html.tag('span',
       {class: cur.divider === false && 'no-divider'},
-      partContent
-    );
+      partContent);
 
     navLinkParts.push(part);
   }
 
-  const navHTML = html.tag(
-    'nav',
+  const navHTML = html.tag('nav',
     {
       [html.onlyIfContent]: true,
       id: 'header',
@@ -1095,18 +1083,15 @@ writePage.html = (
       nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
       nav.bottomRowContent &&
         html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
-    ]
-  );
+    ]);
 
-  const secondaryNavHTML = html.tag(
-    'nav',
+  const secondaryNavHTML = html.tag('nav',
     {
       [html.onlyIfContent]: true,
       id: 'secondary-nav',
       class: secondaryNav.classes,
     },
-    [secondaryNav.content]
-  );
+    secondaryNav.content);
 
   const bannerSrc = banner.src
     ? banner.src
@@ -1117,8 +1102,7 @@ writePage.html = (
   const bannerHTML =
     banner.position &&
     bannerSrc &&
-    html.tag(
-      'div',
+    html.tag('div',
       {
         id: 'banner',
         class: banner.classes,
@@ -1128,23 +1112,27 @@ writePage.html = (
         alt: banner.alt,
         width: banner.dimensions[0] || 1100,
         height: banner.dimensions[1] || 200,
-      })
-    );
+      }));
 
   const layoutHTML = [
     navHTML,
     banner.position === 'top' && bannerHTML,
     secondaryNavHTML,
-    html.tag(
-      'div',
-      {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']},
-      [sidebarLeftHTML, mainHTML, sidebarRightHTML]
-    ),
+    html.tag('div',
+      {
+        class: [
+          'layout-columns',
+          !collapseSidebars && 'vertical-when-thin',
+        ],
+      },
+      [
+        sidebarLeftHTML,
+        mainHTML,
+        sidebarRightHTML,
+      ]),
     banner.position === 'bottom' && bannerHTML,
     footerHTML,
-  ]
-    .filter(Boolean)
-    .join('\n');
+  ].filter(Boolean).join('\n');
 
   const infoCardHTML = fixWS`
         <div id="info-card-container">
@@ -1197,23 +1185,25 @@ writePage.html = (
   const socialEmbedHTML = [
     socialEmbed.title &&
       html.tag('meta', {property: 'og:title', content: socialEmbed.title}),
+
     socialEmbed.description &&
       html.tag('meta', {
         property: 'og:description',
         content: socialEmbed.description,
       }),
+
     socialEmbed.image &&
       html.tag('meta', {property: 'og:image', content: socialEmbed.image}),
+
     socialEmbed.color &&
       html.tag('meta', {name: 'theme-color', content: socialEmbed.color}),
+
     oEmbedJSONHref &&
       html.tag('link', {
         type: 'application/json+oembed',
         href: oEmbedJSONHref,
       }),
-  ]
-    .filter(Boolean)
-    .join('\n');
+  ].filter(Boolean).join('\n');
 
   return filterEmptyLines(fixWS`
         <!DOCTYPE html>
diff --git a/src/util/html.js b/src/util/html.js
index 0ba923b3..338df71b 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -20,11 +20,18 @@ export const selfClosingTags = [
   'wbr',
 ];
 
-// Pass to tag() as an attri8utes key to make tag() return a 8lank string
-// if the provided content is empty. Useful for when you'll only 8e showing
-// an element according to the presence of content that would 8elong there.
+// Pass to tag() as an attributes key to make tag() return a 8lank string if the
+// provided content is empty. Useful for when you'll only 8e showing an element
+// according to the presence of content that would 8elong there.
 export const onlyIfContent = Symbol();
 
+// Pass to tag() as an attributes key to make children be joined together by the
+// provided string. This is handy, for example, for joining lines by <br> tags,
+// or putting some other divider between each child. Note this will only have an
+// effect if the tag content is passed as an array of children and not a single
+// string.
+export const joinChildren = Symbol();
+
 export function tag(tagName, ...args) {
   const selfClosing = selfClosingTags.includes(tagName);
 
@@ -59,7 +66,11 @@ export function tag(tagName, ...args) {
   }
 
   if (Array.isArray(content)) {
-    content = content.filter(Boolean).join('\n');
+    const joiner = attrs?.[joinChildren];
+    content = content.filter(Boolean).join(
+      (joiner
+        ? `\n${joiner}\n`
+        : '\n'));
   }
 
   if (content) {