« 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--package-lock.json17
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js10
-rw-r--r--src/content/dependencies/generateAlbumInfoPageContent.js186
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js2
-rw-r--r--src/content/dependencies/generateTrackInfoPageContent.js190
-rw-r--r--src/content/dependencies/transformContent.js67
-rw-r--r--src/data/things/album.js17
-rw-r--r--src/data/things/thing.js17
-rw-r--r--src/data/yaml.js1
-rw-r--r--src/strings-default.json11
11 files changed, 382 insertions, 137 deletions
diff --git a/package-lock.json b/package-lock.json
index c1e9441e..49852903 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
                 "eslint": "^8.37.0",
                 "he": "^1.2.0",
                 "js-yaml": "^4.1.0",
+                "marked": "^5.0.2",
                 "word-wrap": "^1.2.3"
             },
             "bin": {
@@ -2035,6 +2036,17 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
+        "node_modules/marked": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
+            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==",
+            "bin": {
+                "marked": "bin/marked.js"
+            },
+            "engines": {
+                "node": ">= 18"
+            }
+        },
         "node_modules/minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6424,6 +6436,11 @@
                 "semver": "^6.0.0"
             }
         },
+        "marked": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
+            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ=="
+        },
         "minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
diff --git a/package.json b/package.json
index 6c5adb87..67e00fdf 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
         "eslint": "^8.37.0",
         "he": "^1.2.0",
         "js-yaml": "^4.1.0",
+        "marked": "^5.0.2",
         "word-wrap": "^1.2.3"
     },
     "license": "GPL-3.0",
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
index f8fd5499..5fd4e05b 100644
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -9,24 +9,24 @@ export default {
     'urls',
   ],
 
-  data(album, {fileSize = true} = {}) {
+  data(album, additionalFiles, {fileSize = true} = {}) {
     return {
       albumDirectory: album.directory,
-      fileLocations: album.additionalFiles.flatMap(({files}) => files),
+      fileLocations: additionalFiles.flatMap(({files}) => files),
       showFileSizes: fileSize,
     };
   },
 
-  relations(relation, album, {fileSize = true} = {}) {
+  relations(relation, album, additionalFiles, {fileSize = true} = {}) {
     return {
       additionalFilesList:
-        relation('generateAdditionalFilesList', album.additionalFiles, {
+        relation('generateAdditionalFilesList', additionalFiles, {
           fileSize,
         }),
 
       additionalFileLinks:
         Object.fromEntries(
-          album.additionalFiles
+          additionalFiles
             .flatMap(({files}) => files)
             .map(file => [
               file,
diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js
index 76862f9c..7b8522b7 100644
--- a/src/content/dependencies/generateAlbumInfoPageContent.js
+++ b/src/content/dependencies/generateAlbumInfoPageContent.js
@@ -13,65 +13,104 @@ export default {
     'linkExternal',
   ],
 
-  extraDependencies: [
-    'html',
-    'language',
-    'transformMultiline',
-  ],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, album) {
     const relations = {};
+    const sections = relations.sections = {};
 
     const contributionLinksRelation = contribs =>
       contribs.map(contrib =>
         relation('linkContribution', contrib.who, contrib.what));
 
+    // Section: Release info
+
+    const releaseInfo = sections.releaseInfo = {};
+
+    if (!empty(album.artistContribs)) {
+      releaseInfo.artistContributionLinks =
+        contributionLinksRelation(album.artistContribs);
+    }
+
     if (album.hasCoverArt) {
       relations.cover =
         relation('generateCoverArtwork', album.artTags);
+      releaseInfo.coverArtistContributionLinks =
+        contributionLinksRelation(album.coverArtistContribs);
+    } else {
+      relations.cover = null;
     }
 
-    relations.artistLinks =
-      contributionLinksRelation(album.artistContribs);
+    if (album.hasWallpaperArt) {
+      releaseInfo.wallpaperArtistContributionLinks =
+        contributionLinksRelation(album.wallpaperArtistContribs);
+    }
+
+    if (album.hasBannerArt) {
+      releaseInfo.bannerArtistContributionLinks =
+        contributionLinksRelation(album.bannerArtistContribs);
+    }
+
+    // Section: Listen on
+
+    if (!empty(album.urls)) {
+      const listen = sections.listen = {};
 
-    relations.coverArtistLinks =
-      contributionLinksRelation(album.coverArtistContribs);
+      listen.heading =
+        relation('generateContentHeading');
+
+      listen.externalLinks =
+        album.urls.map(url =>
+          relation('linkExternal', url, {type: 'album'}));
+    }
 
-    relations.wallpaperArtistLinks =
-      contributionLinksRelation(album.wallpaperArtistContribs);
+    // Section: Extra links
 
-    relations.bannerArtistLinks =
-      contributionLinksRelation(album.bannerArtistContribs);
+    const extra = sections.extra = {};
 
     if (album.tracks.some(t => t.hasUniqueCoverArt)) {
-      relations.galleryLink =
+      extra.galleryLink =
         relation('linkAlbumGallery', album);
     }
 
     if (album.commentary || album.tracks.some(t => t.commentary)) {
-      relations.commentaryLink =
+      extra.commentaryLink =
         relation('linkAlbumCommentary', album);
     }
 
-    relations.externalLinks =
-      album.urls.map(url =>
-        relation('linkExternal', url, {type: 'album'}));
+    if (!empty(album.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', album.additionalFiles);
+    }
+
+    // Section: Track list
+
+    relations.trackList =
+      relation('generateAlbumTrackList', album);
 
-    relations.trackList = relation('generateAlbumTrackList', album);
+    // Section: Additional files
 
     if (!empty(album.additionalFiles)) {
-      relations.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
+      const additionalFiles = sections.additionalFiles = {};
 
-      relations.additionalFilesHeading =
+      additionalFiles.heading =
         relation('generateContentHeading');
 
-      relations.additionalFilesList =
-        relation('generateAlbumAdditionalFilesList', album);
+      additionalFiles.additionalFilesList =
+        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
     }
 
-    relations.artistCommentaryHeading =
-      relation('generateContentHeading');
+    // Section: Artist commentary
+
+    if (album.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', album.commentary);
+    }
 
     return relations;
   },
@@ -99,7 +138,6 @@ export default {
     }
 
     data.dateAddedToWiki = album.dateAddedToWiki;
-    data.artistCommentary = album.commentary;
 
     return data;
   },
@@ -107,18 +145,20 @@ export default {
   generate(data, relations, {
     html,
     language,
-    transformMultiline,
   }) {
     const content = {};
 
-    const formatContributions = contributionLinks =>
-      language.formatConjunctionList(
-        contributionLinks.map(link =>
-          link
-            .slots({
-              showContribution: true,
-              showIcons: true,
-            })));
+    const {sections: sec} = relations;
+
+    const formatContributions =
+      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
+        contributionLinks &&
+          language.$(stringKey, {
+            artists:
+              language.formatConjunctionList(
+                contributionLinks.map(link =>
+                  link.slots({showContribution, showIcons}))),
+          });
 
     if (data.hasCoverArt) {
       content.cover = relations.cover
@@ -126,6 +166,8 @@ export default {
           path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension],
           alt: language.$('misc.alt.trackCover')
         });
+    } else {
+      content.cover = null;
     }
 
     content.main = {
@@ -137,25 +179,10 @@ export default {
             [html.joinChildren]: html.tag('br'),
           },
           [
-            !empty(relations.artistLinks) &&
-              language.$('releaseInfo.by', {
-                artists: formatContributions(relations.artistLinks),
-              }),
-
-            !empty(relations.coverArtistLinks) &&
-              language.$('releaseInfo.coverArtBy', {
-                artists: formatContributions(relations.coverArtistLinks),
-              }),
-
-            !empty(relations.wallpaperArtistLinks) &&
-              language.$('releaseInfo.wallpaperArtBy', {
-                artists: formatContributions(relations.wallpaperArtistLinks),
-              }),
-
-            !empty(relations.bannerArtistLinks) &&
-              language.$('releaseInfo.bannerArtBy', {
-                artists: formatContributions(relations.bannerArtistLinks),
-              }),
+            formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
+            formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
+            formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks),
+            formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks),
 
             data.date &&
               language.$('releaseInfo.released', {
@@ -176,35 +203,48 @@ export default {
               }),
           ]),
 
+        sec.listen &&
+          sec.listen.heading.slots({
+            id: 'listen-on',
+            title:
+              language.$('releaseInfo.listenOn', {
+                links: language.formatDisjunctionList(sec.listen.externalLinks),
+              }),
+          }),
+
         html.tag('p',
           {
             [html.onlyIfContent]: true,
             [html.joinChildren]: html.tag('br'),
           },
           [
-            relations.additionalFilesShortcut,
+            sec.extra.additionalFilesShortcut,
+
+            sec.extra.galleryLink && sec.extra.commentaryLink &&
+              language.$('releaseInfo.viewGalleryOrCommentary', {
+                gallery:
+                  sec.extra.galleryLink
+                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
+                commentary:
+                  sec.extra.commentaryLink
+                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
+              }),
 
-            relations.galleryLink &&
+            sec.extra.galleryLink && !sec.extra.commentaryLink &&
               language.$('releaseInfo.viewGallery', {
                 link:
-                  relations.galleryLink
+                  sec.extra.galleryLink
                     .slot('content', language.$('releaseInfo.viewGallery.link')),
               }),
 
-            relations.commentaryLink &&
+            !sec.extra.galleryLink && sec.extra.commentaryLink &&
               language.$('releaseInfo.viewCommentary', {
                 link:
-                  relations.commentaryLink
+                  sec.extra.commentaryLink
                     .slot('content', language.$('releaseInfo.viewCommentary.link')),
               }),
           ]),
 
-        !empty(relations.externalLinks) &&
-          html.tag('p',
-            language.$('releaseInfo.listenOn', {
-              links: language.formatDisjunctionList(relations.externalLinks),
-            })),
-
         relations.trackList,
 
         html.tag('p',
@@ -219,11 +259,10 @@ export default {
               }),
           ]),
 
-        relations.additionalFilesList && [
-          relations.additionalFilesHeading
+        sec.additionalFiles && [
+          sec.additionalFiles.heading
             .slots({
               id: 'additional-files',
-
               title:
                 language.$('releaseInfo.additionalFiles.heading', {
                   additionalFiles:
@@ -231,18 +270,19 @@ export default {
                 }),
             }),
 
-          relations.additionalFilesList,
+          sec.additionalFiles.additionalFilesList,
         ],
 
-        data.artistCommentary && [
-          relations.artistCommentaryHeading
+        sec.artistCommentary && [
+          sec.artistCommentary.heading
             .slots({
               id: 'artist-commentary',
               title: language.$('releaseInfo.artistCommentary')
             }),
 
           html.tag('blockquote',
-            transformMultiline(data.artistCommentary)),
+            sec.artistCommentary.content
+              .slot('mode', 'multiline')),
         ],
       ]),
     };
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index f2f2279d..ce174953 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -101,7 +101,7 @@ export default {
         {class: 'album-group-list'},
         data.trackSectionInfo.map((info, index) => [
           html.tag('dt',
-            {class: 'content-heading'},
+            {class: 'content-heading', tabindex: '0'},
             language.$('trackList.section.withDuration', {
               section: info.name,
               duration:
diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js
index 57bdc0c2..47a9130d 100644
--- a/src/content/dependencies/generateTrackInfoPageContent.js
+++ b/src/content/dependencies/generateTrackInfoPageContent.js
@@ -2,6 +2,8 @@ import {empty} from '../../util/sugar.js';
 
 export default {
   contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
     'generateContentHeading',
     'generateCoverArtwork',
     'generateTrackList',
@@ -12,11 +14,7 @@ export default {
     'linkTrack',
   ],
 
-  extraDependencies: [
-    'html',
-    'language',
-    'transformMultiline',
-  ],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, track, {topLevelGroups}) {
     const {album} = track;
@@ -28,6 +26,11 @@ export default {
       contribs.map(contrib =>
         relation('linkContribution', contrib.who, contrib.what));
 
+    const additionalFilesSection = additionalFiles => ({
+      heading: relation('generateContentHeading'),
+      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
+    });
+
     // Section: Release info
 
     const releaseInfo = sections.releaseInfo = {};
@@ -60,6 +63,15 @@ export default {
           relation('linkExternal', url));
     }
 
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (!empty(track.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', track.additionalFiles);
+    }
+
     // Section: Other releases
 
     if (!empty(track.otherReleases)) {
@@ -113,6 +125,44 @@ export default {
           topLevelGroups);
     }
 
+    // Section: Lyrics
+
+    if (track.lyrics) {
+      const lyrics = sections.lyrics = {};
+
+      lyrics.heading =
+        relation('generateContentHeading');
+
+      lyrics.content =
+        relation('transformContent', track.lyrics);
+    }
+
+    // Sections: Sheet music files, MIDI/proejct files, additional files
+
+    if (!empty(track.sheetMusicFiles)) {
+      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
+    }
+
+    if (!empty(track.midiProjectFiles)) {
+      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
+    }
+
+    if (!empty(track.additionalFiles)) {
+      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (track.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', track.commentary);
+    }
+
     return relations;
   },
 
@@ -141,23 +191,27 @@ export default {
       data.coverArtFileExtension = album.coverArtFileExtension;
     }
 
+    if (!empty(track.additionalFiles)) {
+      data.numAdditionalFiles = track.additionalFiles.length;
+    }
+
     return data;
   },
 
-  generate(data, relations, {
-    html,
-    language,
-    // transformMultiline,
-  }) {
+  generate(data, relations, {html, language}) {
     const content = {};
 
     const {sections: sec} = relations;
 
     const formatContributions =
-      (contributionLinks, {showContribution = true, showIcons = true} = {}) =>
-        language.formatConjunctionList(
-          contributionLinks.map(link =>
-            link.slots({showContribution, showIcons})));
+      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
+        contributionLinks &&
+          language.$(stringKey, {
+            artists:
+              language.formatConjunctionList(
+                contributionLinks.map(link =>
+                  link.slots({showContribution, showIcons}))),
+          });
 
     if (data.hasUniqueCoverArt) {
       content.cover = relations.cover
@@ -190,15 +244,8 @@ export default {
           [html.onlyIfContent]: true,
           [html.joinChildren]: html.tag('br'),
         }, [
-          sec.releaseInfo.artistContributionLinks &&
-            language.$('releaseInfo.by', {
-              artists: formatContributions(sec.releaseInfo.artistContributionLinks),
-            }),
-
-          sec.releaseInfo.coverArtistContributionLinks &&
-            language.$('releaseInfo.coverArtBy', {
-              artists: formatContributions(sec.releaseInfo.coverArtistContributionLinks),
-            }),
+          formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
+          formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
 
           data.date &&
             language.$('releaseInfo.released', {
@@ -216,41 +263,48 @@ export default {
             }),
         ]),
 
-        /*
+        sec.listen.heading.slots({
+          id: 'listen-on',
+          title:
+            (sec.listen.externalLinks
+              ? language.$('releaseInfo.listenOn', {
+                  links: language.formatDisjunctionList(sec.listen.externalLinks),
+                })
+              : language.$('releaseInfo.listenOn.noLinks', {
+                  name: html.tag('i', data.name),
+                })),
+        }),
+
         html.tag('p',
           {
             [html.onlyIfContent]: true,
             [html.joinChildren]: '<br>',
           },
           [
-            hasSheetMusicFiles &&
+            sec.sheetMusicFiles &&
               language.$('releaseInfo.sheetMusicFiles.shortcut', {
                 link: html.tag('a',
                   {href: '#sheet-music-files'},
                   language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
               }),
 
-            hasMidiProjectFiles &&
+            sec.midiProjectFiles &&
               language.$('releaseInfo.midiProjectFiles.shortcut', {
                 link: html.tag('a',
                   {href: '#midi-project-files'},
                   language.$('releaseInfo.midiProjectFiles.shortcut.link')),
               }),
 
-            hasAdditionalFiles &&
-              generateAdditionalFilesShortcut(track.additionalFiles),
-          ]),
-        */
+            sec.additionalFiles &&
+              sec.extra.additionalFilesShortcut,
 
-        sec.listen.heading.slots({
-          id: 'listen-on',
-          title:
-            (sec.listen.externalLinks
-              ? language.$('releaseInfo.listenOn', {
-                  links: language.formatDisjunctionList(sec.listen.externalLinks),
-                })
-              : language.$('releaseInfo.listenOn.noLinks')),
-        }),
+            sec.artistCommentary &&
+              language.$('releaseInfo.readCommentary', {
+                link: html.tag('a',
+                  {href: '#artist-commentary'},
+                  language.$('releaseInfo.readCommentary.link')),
+              }),
+          ]),
 
         sec.otherReleases && [
           sec.otherReleases.heading
@@ -309,6 +363,64 @@ export default {
 
           sec.referencedBy.list,
         ],
+
+        sec.lyrics && [
+          sec.lyrics.heading
+            .slots({
+              id: 'lyrics',
+              title: language.$('releaseInfo.lyrics'),
+            }),
+
+          html.tag('blockquote',
+            sec.lyrics.content
+              .slot('mode', 'lyrics')),
+        ],
+
+        sec.sheetMusicFiles && [
+          sec.sheetMusicFiles.heading
+            .slots({
+              id: 'sheet-music-files',
+              title: language.$('releaseInfo.sheetMusicFiles.heading'),
+            }),
+
+          sec.sheetMusicFiles.list,
+        ],
+
+        sec.midiProjectFiles && [
+          sec.midiProjectFiles.heading
+            .slots({
+              id: 'midi-project-files',
+              title: language.$('releaseInfo.midiProjectFiles.heading'),
+            }),
+
+          sec.midiProjectFiles.list,
+        ],
+
+        sec.additionalFiles && [
+          sec.additionalFiles.heading
+            .slots({
+              id: 'additional-files',
+              title:
+                language.$('releaseInfo.additionalFiles.heading', {
+                  additionalFiles:
+                    language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                }),
+            }),
+
+          sec.additionalFiles.list,
+        ],
+
+        sec.artistCommentary && [
+          sec.artistCommentary.heading
+            .slots({
+              id: 'artist-commentary',
+              title: language.$('releaseInfo.artistCommentary')
+            }),
+
+          html.tag('blockquote',
+            sec.artistCommentary.content
+              .slot('mode', 'multiline')),
+        ],
       ]),
     };
 
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 262c2982..c2ca548a 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,3 +1,5 @@
+import {marked} from 'marked';
+
 import {bindFind} from '../../util/find.js';
 import {parseInput} from '../../util/replacer.js';
 import {replacerSpec} from '../../util/transform-content.js';
@@ -191,7 +193,13 @@ export default {
         }
 
         if (node.type === 'link') {
-          const {link, label, hash} = relations.links[linkIndex++];
+          const linkNode = relations.links[linkIndex++];
+          if (linkNode.type === 'text') {
+            return {type: 'text', data: linkNode.data};
+          }
+
+          const {link, label, hash} = linkNode;
+
           return {
             type: 'link',
             data: link.slots({content: label, hash}),
@@ -242,16 +250,65 @@ export default {
           return html.tags(contentFromNodes.map(node => node.data));
         }
 
-        // In multiline mode...
+        // 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 = () =>
+          marked.parse(
+            contentFromNodes
+              .map(node => {
+                if (node.type === 'text') {
+                  return node.data.replace(/\n+/g, '\n\n');
+                } else {
+                  return node.data.toString();
+                }
+              })
+              .join(''),
+            markedOptions);
 
         if (slots.mode === 'multiline') {
-          return html.tags(contentFromNodes.map(node => node.data));
+          // 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();
         }
 
-        // In lyrics mode...
+        // 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') {
-          return html.tags(contentFromNodes.map(node => node.data));
+          // 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);
         }
       },
     });
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 2a188f2d..47416521 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -103,7 +103,6 @@ export class Album extends Thing {
       update: {validate: isDimensions},
     },
 
-    hasCoverArt: Thing.common.flag(true),
     hasTrackArt: Thing.common.flag(true),
     hasTrackNumbers: Thing.common.flag(true),
     isListedOnHomepage: Thing.common.flag(true),
@@ -123,18 +122,16 @@ export class Album extends Thing {
 
     artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
     coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs(
-      'trackCoverArtistContribsByRef'
-    ),
-    wallpaperArtistContribs: Thing.common.dynamicContribs(
-      'wallpaperArtistContribsByRef'
-    ),
-    bannerArtistContribs: Thing.common.dynamicContribs(
-      'bannerArtistContribsByRef'
-    ),
+    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
+    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
+    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
 
     commentatorArtists: Thing.common.commentatorArtists(),
 
+    hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'),
+    hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'),
+    hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'),
+
     tracks: {
       flags: {expose: true},
 
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index f0065b55..cefcd012 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -23,6 +23,7 @@ import {
 
 import {inspect} from 'util';
 import {color} from '../../util/cli.js';
+import {empty} from '../../util/sugar.js';
 import {getKebabCase} from '../../util/wiki-data.js';
 
 import find from '../../util/find.js';
@@ -297,6 +298,22 @@ export default class Thing extends CacheableObject {
       },
     }),
 
+    // Nice 'n simple shorthand for an exposed-only flag which is true when any
+    // contributions are present in the specified property.
+    contribsPresent: (
+      contribsByRefProperty
+    ) => ({
+      flags: {expose: true},
+      expose: {
+        dependencies: [contribsByRefProperty],
+        compute({
+          [contribsByRefProperty]: contribsByRef,
+        }) {
+          return !empty(contribsByRef);
+        },
+      }
+    }),
+
     // Neat little shortcut for "reversing" the reference lists stored on other
     // things - for example, tracks specify a "referenced tracks" property, and
     // you would use this to compute a corresponding "referenced *by* tracks"
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 1b1195ea..5a6f2031 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -191,7 +191,6 @@ export const processAlbumDocument = makeProcessDocument(T.Album, {
     color: 'Color',
     urls: 'URLs',
 
-    hasCoverArt: 'Has Cover Art',
     hasTrackArt: 'Has Track Art',
     hasTrackNumbers: 'Has Track Numbers',
     isListedOnHomepage: 'Listed on Homepage',
diff --git a/src/strings-default.json b/src/strings-default.json
index b7151f16..7d487e5a 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -96,14 +96,19 @@
   "releaseInfo.viewCommentary.link": "commentary page",
   "releaseInfo.viewGallery": "View {LINK}!",
   "releaseInfo.viewGallery.link": "gallery page",
+  "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!",
+  "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page",
+  "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page",
   "releaseInfo.viewOriginalFile": "View {LINK}.",
   "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).",
   "releaseInfo.viewOriginalFile.link": "original file",
   "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)",
   "releaseInfo.listenOn": "Listen on {LINKS}.",
-  "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
+  "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.",
   "releaseInfo.visitOn": "Visit on {LINKS}.",
   "releaseInfo.playOn": "Play on {LINKS}.",
+  "releaseInfo.readCommentary": "Read {LINK}.",
+  "releaseInfo.readCommentary.link": "artist commentary",
   "releaseInfo.alsoReleasedAs": "Also released as:",
   "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
   "releaseInfo.contributors": "Contributors:",
@@ -120,8 +125,8 @@
   "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
   "releaseInfo.artTags": "Tags:",
   "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
+  "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}",
+  "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files",
   "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:",
   "releaseInfo.additionalFiles.entry": "{TITLE}",
   "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",