« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js4
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js5
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js32
-rw-r--r--src/content/dependencies/generateCoverArtwork.js11
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js59
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js139
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js3
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js2
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generatePageLayout.js38
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js20
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js17
-rw-r--r--src/content/dependencies/generateTrackListItem.js3
-rw-r--r--src/content/dependencies/linkExternal.js9
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js134
17 files changed, 478 insertions, 106 deletions
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
index 85e7576c..fb5ed7ea 100644
--- a/src/content/dependencies/generateAlbumGalleryTrackGrid.js
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -69,7 +69,7 @@ export default {
       album.tracks
         .map(track => track.name),
 
-    trackArtworkArtists:
+    artworkArtists:
       query.artworks.map(artwork =>
         (query.artistsForAllTrackArtworks
           ? null
@@ -110,7 +110,7 @@ export default {
                 })),
 
           info:
-            data.trackArtworkArtists.map(artists =>
+            data.artworkArtists.map(artists =>
               language.$('misc.coverGrid.details.coverArtists', {
                 [language.onlyIfOptions]: ['artists'],
 
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index ad02e180..e28a3fd0 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -32,8 +32,7 @@ export default {
     data.hasImage = album.hasCoverArt;
 
     if (data.hasImage) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     data.albumName = album.name;
@@ -65,7 +64,7 @@ export default {
 
         imagePath:
           (data.hasImage
-            ? ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension]
+            ? data.imagePath
             : null),
       })),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 344e7bda..cfd6d03e 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,5 +1,5 @@
 import {sortArtworksChronologically} from '#sort';
-import {empty, unique} from '#sugar';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -103,11 +103,15 @@ export default {
       query.allArtworks
         .map(artwork => artwork.thing.name);
 
-    data.coverArtists =
+    data.artworkArtists =
       query.allArtworks
         .map(artwork => artwork.artistContribs
           .map(contrib => contrib.artist.name));
 
+    data.artworkLabels =
+      query.allArtworks
+        .map(artwork => artwork.label)
+
     data.onlyFeaturedIndirectly =
       query.allArtworks.map(artwork =>
         !query.directArtworks.includes(artwork));
@@ -204,12 +208,24 @@ export default {
                   (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
 
               info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                stitchArrays({
+                  artists: data.artworkArtists,
+                  label: data.artworkLabels,
+                }).map(({artists, label}) =>
+                    language.encapsulate('misc.coverGrid.details.coverArtists', workingCapsule => {
+                      const workingOptions = {};
+
+                      workingOptions[language.onlyIfOptions] = ['artists'];
+                      workingOptions.artists =
+                        language.formatUnitList(artists);
+
+                      if (label) {
+                        workingCapsule += '.customLabel';
+                        workingOptions.label = label;
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    })),
             }),
         ],
 
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 3a10ab20..2bff4643 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -27,6 +27,9 @@ export default {
   }),
 
   data: (artwork) => ({
+    attachAbove:
+      artwork.attachAbove,
+
     color:
       artwork.thing.color ?? null,
 
@@ -76,7 +79,10 @@ export default {
       image.setSlot('dimensions', data.dimensions);
     }
 
-    return (
+    return html.tags([
+      data.attachAbove &&
+        html.tag('div', {class: 'cover-artwork-joiner'}),
+
       html.tag('div', {class: 'cover-artwork'},
         slots.mode === 'commentary' &&
           {class: 'commentary-art'},
@@ -116,6 +122,7 @@ export default {
               link: true,
               lazy: true,
             })
-          : html.blank())));
+          : html.blank())),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
index b20f599b..4d908665 100644
--- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -1,13 +1,21 @@
-import {stitchArrays} from '#sugar';
+import {compareArrays, empty, stitchArrays} from '#sugar';
+
+function linkable(tag) {
+  return !tag.isContentWarning;
+}
 
 export default {
   contentDependencies: ['linkArtTagGallery'],
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
   query: (artwork) => ({
     linkableArtTags:
-      artwork.artTags
-        .filter(tag => !tag.isContentWarning),
+      artwork.artTags.filter(linkable),
+
+    mainArtworkLinkableArtTags:
+      (artwork.mainArtwork
+        ? artwork.mainArtwork.artTags.filter(linkable)
+        : null),
   }),
 
   relations: (relation, query, _artwork) => ({
@@ -16,7 +24,19 @@ export default {
         .map(tag => relation('linkArtTagGallery', tag)),
   }),
 
-  data: (query, _artwork) => {
+  data: (query, artwork) => {
+    const data = {};
+
+    data.attachAbove = artwork.attachAbove;
+
+    data.sameAsMainArtwork =
+      !artwork.isMainArtwork &&
+      query.mainArtworkLinkableArtTags &&
+      !empty(query.mainArtworkLinkableArtTags) &&
+      compareArrays(
+        query.mainArtworkLinkableArtTags,
+        query.linkableArtTags);
+
     const seenShortNames = new Set();
     const duplicateShortNames = new Set();
 
@@ -28,23 +48,28 @@ export default {
       }
     }
 
-    const preferShortName =
+    data.preferShortName =
       query.linkableArtTags
         .map(artTag => !duplicateShortNames.has(artTag.nameShort));
 
-    return {preferShortName};
+    return data;
   },
 
-  generate: (data, relations, {html}) =>
-    html.tag('ul', {class: 'image-details'},
-      {[html.onlyIfContent]: true},
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('ul', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
 
-      {class: 'art-tag-details'},
+        {class: 'art-tag-details'},
 
-      stitchArrays({
-        artTagLink: relations.artTagLinks,
-        preferShortName: data.preferShortName,
-      }).map(({artTagLink, preferShortName}) =>
-          html.tag('li',
-            artTagLink.slot('preferShortName', preferShortName)))),
+        (data.sameAsMainArtwork && data.attachAbove
+          ? html.blank()
+       : data.sameAsMainArtwork && relations.artTagLinks.length >= 3
+          ? language.$(capsule, 'sameTagsAsMainArtwork')
+          : stitchArrays({
+              artTagLink: relations.artTagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({artTagLink, preferShortName}) =>
+                html.tag('li',
+                  artTagLink.slot('preferShortName', preferShortName)))))),
 };
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 6cb529b1..3908414f 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -13,11 +13,18 @@ export default {
   query: (artwork) => ({
     artworkThingType:
       artwork.thing.constructor[Thing.referenceType],
+
+    attachedArtistContribs:
+      (artwork.attachedArtwork
+        ? artwork.attachedArtwork.artistContribs
+        : null)
   }),
 
   relations: (relation, query, artwork) => ({
     credit:
-      relation('generateArtistCredit', artwork.artistContribs, []),
+      relation('generateArtistCredit',
+        artwork.artistContribs,
+        query.attachedArtistContribs ?? []),
 
     source:
       relation('transformContent', artwork.source),
@@ -28,7 +35,7 @@ export default {
         : null),
 
     datetimestamp:
-      (artwork.date !== artwork.thing.date
+      (artwork.date && artwork.date !== artwork.thing.date
         ? relation('generateAbsoluteDatetimestamp', artwork.date)
         : null),
   }),
@@ -50,49 +57,101 @@ export default {
 
         {class: 'origin-details'},
 
-        [
-          language.encapsulate(capsule, 'artworkBy', workingCapsule => {
-            const workingOptions = {};
+        (() => {
+          relations.datetimestamp?.setSlots({
+            style: 'year',
+            tooltip: true,
+          });
+
+          const artworkBy =
+            language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+              const workingOptions = {};
 
-            if (data.label) {
-              workingCapsule += '.customLabel';
-              workingOptions.label = data.label;
-            }
+              if (data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
 
-            if (relations.datetimestamp) {
-              workingCapsule += '.withYear';
-              workingOptions.year =
-                relations.datetimestamp.slots({
-                  style: 'year',
-                  tooltip: true,
-                });
-            }
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
 
-            return relations.credit.slots({
-              showAnnotation: true,
-              showExternalLinks: true,
-              showChronology: true,
-              showWikiEdits: true,
+              return relations.credit.slots({
+                showAnnotation: true,
+                showExternalLinks: true,
+                showChronology: true,
+                showWikiEdits: true,
 
-              trimAnnotation: false,
+                trimAnnotation: false,
 
-              chronologyKind: 'coverArt',
+                chronologyKind: 'coverArt',
 
-              normalStringKey: workingCapsule,
-              additionalStringOptions: workingOptions,
+                normalStringKey: workingCapsule,
+                additionalStringOptions: workingOptions,
+              });
             });
-          }),
-
-          pagePath[0] === 'track' &&
-          data.artworkThingType === 'album' &&
-            language.$(capsule, 'trackArtFromAlbum', {
-              album:
-                relations.albumLink.slot('color', false),
-            }),
-
-          language.$(capsule, 'source', {
-            [language.onlyIfOptions]: ['source'],
-            source: relations.source.slot('mode', 'inline'),
-          }),
-        ])),
+
+          const trackArtFromAlbum =
+            pagePath[0] === 'track' &&
+            data.artworkThingType === 'album' &&
+              language.$(capsule, 'trackArtFromAlbum', {
+                album:
+                  relations.albumLink.slot('color', false),
+              });
+
+          const source =
+            language.encapsulate(capsule, 'source', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['source'],
+                source: relations.source.slot('mode', 'inline'),
+              };
+
+              if (html.isBlank(artworkBy) && data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
+
+              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const label =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            language.encapsulate(capsule, 'customLabel', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['label'],
+                label: data.label,
+              };
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const year =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            html.isBlank(label) &&
+            language.$(capsule, 'year', {
+              [language.onlyIfOptions]: ['year'],
+              year: relations.datetimestamp,
+            });
+
+          return [
+            artworkBy,
+            trackArtFromAlbum,
+            source,
+            label,
+            year,
+          ];
+        })())),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 99e7e8ff..4680cb46 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,7 +127,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(artistCredit)));
           }
 
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
index 3f300676..1d58367d 100644
--- a/src/content/dependencies/generateIntrapageDotSwitcher.js
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -42,6 +42,8 @@ export default {
         }).map(({title, targetID}) =>
             html.tag('a', {href: '#'},
               {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
               language.sanitize(title))),
     }),
 };
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..4f9c22f1
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {class: 'lyrics-entry'},
+      slots.attributes,
+
+      relations.content.slot('mode', 'lyrics')),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 070c7c82..89fefb23 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -262,16 +262,28 @@ export default {
         ? data.canonicalBase + pagePathStringFromRoot
         : null);
 
-    const firstItemInArtworkColumn =
-      html.smooth(slots.artworkColumnContent)
-        .content[0];
-
-    const primaryCover =
-      (firstItemInArtworkColumn &&
-       html.resolve(firstItemInArtworkColumn, {normalize: 'tag'})
-         .attributes.has('class', 'cover-artwork')
-        ? firstItemInArtworkColumn
-        : null);
+    const primaryCover = (() => {
+      const apparentFirst = tag => html.smooth(tag).content[0];
+
+      const maybeTemplate =
+        apparentFirst(slots.artworkColumnContent);
+
+      if (!maybeTemplate) return null;
+
+      const maybeTemplateContent =
+        html.resolve(maybeTemplate, {normalize: 'tag'});
+
+      const maybeCoverArtwork =
+        apparentFirst(maybeTemplateContent);
+
+      if (!maybeCoverArtwork) return null;
+
+      if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) {
+        return maybeTemplate;
+      } else {
+        return null;
+      }
+    })();
 
     const titleContentsHTML =
       (html.isBlank(slots.title)
@@ -583,6 +595,11 @@ export default {
           `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
           `}`);
 
+    const goshFrigginDarnitStyleRule =
+      `.image-media-link::after {\n` +
+      `    mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` +
+      `}`;
+
     const numWallpaperParts =
       html.resolve(slots.styleRules, {normalize: 'string'})
         .match(/\.wallpaper-part:nth-child/g)
@@ -733,6 +750,7 @@ export default {
                 .slot('color', slots.color ?? data.wikiColor),
 
               fallbackBackgroundStyleRule,
+              goshFrigginDarnitStyleRule,
               slots.styleRules,
             ]),
 
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
index 188a678f..308a1105 100644
--- a/src/content/dependencies/generateSearchSidebarBox.js
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -57,6 +57,26 @@ export default {
             html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
               language.$(capsule, 'artTag')),
           ]),
+
+          language.encapsulate(capsule, 'resultFilter', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-filter-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-filter-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-flash-result-filter-string'},
+              language.$(capsule, 'flash')),
+
+            html.tag('template', {class: 'wiki-search-group-result-filter-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-track-result-filter-string'},
+              language.$(capsule, 'track')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-filter-string'},
+              language.$(capsule, 'artTag')),
+          ]),
         ],
       })),
 };
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 7d531124..11d179ad 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -9,6 +9,7 @@ export default {
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateContributionList',
+    'generateLyricsSection',
     'generatePageLayout',
     'generateTrackArtistCommentarySection',
     'generateTrackArtworkColumn',
@@ -90,8 +91,8 @@ export default {
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-    lyrics:
-      relation('transformContent', track.lyrics),
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
 
     sheetMusicFilesList:
       relation('generateAlbumAdditionalFilesList',
@@ -308,17 +309,7 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
-          html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'lyrics'},
-                title: language.$('releaseInfo.lyrics'),
-              }),
-
-            html.tag('blockquote',
-              {[html.onlyIfContent]: true},
-              relations.lyrics.slot('mode', 'lyrics')),
-          ]),
+          relations.lyricsSection,
 
           html.tags([
             relations.contentHeading.clone()
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 887b6f03..3c850a18 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,7 +97,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(relations.credit)));
           }
 
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 073c821e..c132baaf 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -39,6 +39,11 @@ export default {
       default: false,
     },
 
+    disableBrowserTooltip: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
@@ -111,7 +116,9 @@ export default {
       linkAttributes.add('class', 'indicate-external');
 
       let titleText;
-      if (slots.tab === 'separate') {
+      if (slots.disableBrowserTooltip) {
+        titleText = null;
+      } else if (slots.tab === 'separate') {
         if (html.isBlank(slots.content)) {
           titleText =
             language.$('misc.external.opensInNewTab.annotation');
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
index a13a76f0..e6ab9d7d 100644
--- a/src/content/dependencies/listTracksWithLyrics.js
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['listTracksWithExtra'],
 
   relations: (relation, spec) =>
-    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}),
 
   generate: (relations) =>
     relations.page,
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index f56a1da9..805c3625 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -2,6 +2,7 @@ import {bindFind} from '#find';
 import {replacerSpec, parseInput} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
@@ -45,6 +46,14 @@ function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
 
+function getArg(node, argKey) {
+  return (
+    node.data.args
+      ?.find(({key}) => key.data === argKey)
+      ?.value ??
+    null);
+}
+
 export default {
   contentDependencies: [
     ...(
@@ -52,6 +61,8 @@ export default {
         .map(description => description.link)
         .filter(Boolean)),
     'image',
+    'generateTextWithTooltip',
+    'generateTooltip',
     'linkExternal',
   ],
 
@@ -133,6 +144,30 @@ export default {
             return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
           }
 
+          if (replacerKey === 'tooltip') {
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+
+            return {
+              i: node.i,
+              iEnd: node.iEnd,
+              type: 'tooltip',
+              data: {
+                tooltip:
+                  replacerValue ?? '(empty tooltip...)',
+
+                label:
+                  enteredLabel ?? '(tooltip without label)',
+
+                link:
+                  (getArg(node, 'link')
+                    ? getArg(node, 'link')[0].data
+                    : null),
+              },
+            };
+          }
+
           // This will be another {type: 'tag'} node which gets processed in
           // generate. Extract replacerKey and replacerValue now, since it'd
           // be a pain to deal with later.
@@ -184,10 +219,18 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
     return {
+      textWithTooltip:
+        relation('generateTextWithTooltip'),
+
+      tooltip:
+        relation('generateTooltip'),
+
       internalLinks:
         nodes
           .filter(({type}) => type === 'internal-link')
@@ -206,11 +249,15 @@ export default {
       externalLinks:
         nodes
           .filter(({type}) => type === 'external-link')
-          .map(node => {
-            const {href} = node.data;
+          .map(({data: {href}}) =>
+            relation('linkExternal', href)),
 
-            return relation('linkExternal', href);
-          }),
+      externalLinksForTooltipNodes:
+        nodes
+          .filter(({type}) => type === 'tooltip')
+          .filter(({data}) => data.link)
+          .map(({data: {link: href}}) =>
+            relation('linkExternal', href)),
 
       images:
         nodes
@@ -241,6 +288,11 @@ export default {
       default: true,
     },
 
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -251,6 +303,7 @@ export default {
     let imageIndex = 0;
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
+    let externalLinkForTooltipNodeIndex = 0;
 
     let offsetTextNode = 0;
 
@@ -452,7 +505,17 @@ export default {
                 nodeFromRelations.link,
                 {slots: ['content', 'hash']});
 
-            const {label, hash} = nodeFromRelations;
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -506,6 +569,10 @@ export default {
             const {label} = node.data;
             const externalLink = relations.externalLinks[externalLinkIndex++];
 
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
             externalLink.setSlots({
               content: label,
               fromContent: true,
@@ -526,6 +593,52 @@ export default {
             return {type: 'processed-external-link', data: externalLink};
           }
 
+          case 'tooltip': {
+            const {label, link, tooltip: tooltipContent} = node.data;
+
+            const externalLink =
+              (link
+                ? relations.externalLinksForTooltipNodes
+                    .at(externalLinkForTooltipNodeIndex++)
+                : null);
+
+            if (externalLink) {
+              externalLink.setSlots({
+                content: label,
+                fromContent: true,
+              });
+
+              if (slots.indicateExternalLinks) {
+                externalLink.setSlots({
+                  indicateExternal: true,
+                  disableBrowserTooltip: true,
+                  tab: 'separate',
+                  style: 'platform',
+                });
+              }
+            }
+
+            const textWithTooltip = relations.textWithTooltip.clone();
+            const tooltip = relations.tooltip.clone();
+
+            tooltip.setSlots({
+              attributes: {class: 'content-tooltip'},
+              content: tooltipContent, // Not sanitized!
+            });
+
+            textWithTooltip.setSlots({
+              attributes: [
+                {class: 'content-tooltip-guy'},
+                externalLink && {class: 'has-link'},
+              ],
+
+              text: externalLink ?? label,
+              tooltip,
+            });
+
+            return {type: 'processed-tooltip', data: textWithTooltip};
+          }
+
           case 'tag': {
             const {replacerKey, replacerValue} = node.data;
 
@@ -542,12 +655,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default: