« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/common-util/wiki-data.js30
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js5
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js8
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js2
-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.js6
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js17
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js35
-rw-r--r--src/data/checks.js35
-rw-r--r--src/data/composite/things/album/index.js1
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/commentary-entry/index.js1
-rw-r--r--src/data/composite/things/commentary-entry/withWebArchiveDate.js41
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js23
-rw-r--r--src/data/composite/wiki-data/index.js1
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js260
-rw-r--r--src/data/composite/wiki-properties/commentary.js30
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js11
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/things/album.js71
-rw-r--r--src/data/things/content.js122
-rw-r--r--src/data/things/flash.js40
-rw-r--r--src/data/things/index.js2
-rw-r--r--src/data/things/track.js42
-rw-r--r--src/data/yaml.js85
-rw-r--r--src/static/css/site.css78
-rw-r--r--src/static/js/client/sticky-heading.js2
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/strings-default.yaml7
-rw-r--r--src/urls-default.yaml2
-rw-r--r--src/validators.js139
34 files changed, 853 insertions, 460 deletions
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index 4bbef8ab..a4c6b3bd 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -102,6 +102,36 @@ export const commentaryRegexCaseSensitive =
 export const commentaryRegexCaseSensitiveOneShot =
   new RegExp(commentaryRegexRaw);
 
+// The #validators function isOldStyleLyrics() describes
+// what this regular expression detects against.
+export const multipleLyricsDetectionRegex =
+  /^<i>.*:<\/i>/m;
+
+export function matchContentEntries(sourceText) {
+  const matchEntries = [];
+
+  let previousMatchEntry = null;
+  let previousEndIndex = null;
+
+  for (const {0: matchText, index: startIndex, groups: matchEntry}
+          of sourceText.matchAll(commentaryRegexCaseSensitive)) {
+    if (previousMatchEntry) {
+      previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex);
+    }
+
+    matchEntries.push(matchEntry);
+
+    previousMatchEntry = matchEntry;
+    previousEndIndex = startIndex + matchText.length;
+  }
+
+  if (previousMatchEntry) {
+    previousMatchEntry.body = sourceText.slice(previousEndIndex);
+  }
+
+  return matchEntries;
+}
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
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/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index c93020f3..4cb618e3 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -98,8 +98,6 @@ export default {
 
                 return language.$(workingCapsule, workingOptions);
               })),
-
-            relations.date,
           ])),
 
         html.tag('blockquote', {class: 'commentary-entry-body'},
@@ -107,6 +105,10 @@ export default {
             relations.colorStyle.clone()
               .slot('color', slots.color),
 
-          relations.bodyContent.slot('mode', 'multiline')),
+          [
+            relations.date,
+
+            relations.bodyContent.slot('mode', 'multiline'),
+          ]),
       ])),
 };
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 6cb529b1..08a01cfe 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -28,7 +28,7 @@ export default {
         : null),
 
     datetimestamp:
-      (artwork.date !== artwork.thing.date
+      (artwork.date && artwork.date !== artwork.thing.date
         ? relation('generateAbsoluteDatetimestamp', artwork.date)
         : null),
   }),
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..0acf401c 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -583,6 +583,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 +738,7 @@ export default {
                 .slot('color', slots.color ?? data.wikiColor),
 
               fallbackBackgroundStyleRule,
+              goshFrigginDarnitStyleRule,
               slots.styleRules,
             ]),
 
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/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..1bbd45e2 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,
@@ -184,6 +185,8 @@ 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));
 
@@ -241,6 +244,11 @@ export default {
       default: true,
     },
 
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -452,7 +460,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 +524,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,
@@ -542,12 +564,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:
diff --git a/src/data/checks.js b/src/data/checks.js
index b11b5d55..52024144 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -9,7 +9,6 @@ import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
   from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
-import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data';
 
 import {
   annotateErrorWithIndex,
@@ -185,7 +184,8 @@ export function filterReferenceErrors(wikiData, {
       groups: 'group',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['artTagData', {
@@ -193,7 +193,8 @@ export function filterReferenceErrors(wikiData, {
     }],
 
     ['flashData', {
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -233,7 +234,9 @@ export function filterReferenceErrors(wikiData, {
       artTags: '_artTag',
       referencedArtworks: '_artwork',
       mainReleaseTrack: '_trackMainReleasesOnly',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
+      lyrics: '_content',
     }],
 
     ['wikiInfo', {
@@ -268,12 +271,12 @@ export function filterReferenceErrors(wikiData, {
             let writeProperty = true;
 
             switch (findFnKey) {
-              case '_commentary':
+              case '_content':
                 if (value) {
                   value =
-                    Array.from(value.matchAll(commentaryRegexCaseSensitive))
-                      .map(({groups}) => groups.artistReferences)
-                      .map(text => text.split(',').map(text => text.trim()));
+                    value.map(entry =>
+                      CacheableObject.getUpdateValue(entry, 'artists') ??
+                      []);
                 }
 
                 writeProperty = false;
@@ -329,7 +332,7 @@ export function filterReferenceErrors(wikiData, {
                 findFn = boundFind.artTag;
                 break;
 
-              case '_commentary':
+              case '_content':
                 findFn = findArtistOrAlias;
                 break;
 
@@ -461,7 +464,7 @@ export function filterReferenceErrors(wikiData, {
                 }
               }
 
-              if (findFnKey === '_commentary') {
+              if (findFnKey === '_content') {
                 filter(
                   value, {message: errorMessage},
                   decorateErrorWithIndex(refs =>
@@ -568,6 +571,12 @@ export function reportContentTextErrors(wikiData, {
     annotation: 'commentary annotation',
   };
 
+  const lyricsShape = {
+    body: 'lyrics body',
+    artistDisplayText: 'lyrics artist display text',
+    annotation: 'lyrics annotation',
+  };
+
   const contentTextSpec = [
     ['albumData', {
       additionalFiles: additionalFileShape,
@@ -614,7 +623,7 @@ export function reportContentTextErrors(wikiData, {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
       creditSources: commentaryShape,
-      lyrics: '_content',
+      lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
     }],
@@ -737,8 +746,8 @@ export function reportContentTextErrors(wikiData, {
         for (const thing of things) {
           nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
 
-            for (const [property, shape] of Object.entries(propSpec)) {
-              const value = thing[property];
+            for (let [property, shape] of Object.entries(propSpec)) {
+              let value = thing[property];
 
               if (value === undefined) {
                 push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8b5098f0..dfc6864f 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
new file mode 100644
index 00000000..fd3f2894
--- /dev/null
+++ b/src/data/composite/things/album/withHasCoverArt.js
@@ -0,0 +1,64 @@
+// TODO: This shouldn't be coded as an Album-specific thing,
+// or even really to do with cover artworks in particular, either.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: 'withHasCoverArt',
+
+  outputs: ['#hasCoverArt'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'coverArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'coverArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#coverArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#coverArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/commentary-entry/index.js b/src/data/composite/things/commentary-entry/index.js
new file mode 100644
index 00000000..091bae1a
--- /dev/null
+++ b/src/data/composite/things/commentary-entry/index.js
@@ -0,0 +1 @@
+export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/commentary-entry/withWebArchiveDate.js b/src/data/composite/things/commentary-entry/withWebArchiveDate.js
new file mode 100644
index 00000000..3aaa4f64
--- /dev/null
+++ b/src/data/composite/things/commentary-entry/withWebArchiveDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withWebArchiveDate`,
+
+  outputs: ['#webArchiveDate'],
+
+  steps: () => [
+    {
+      dependencies: ['annotation'],
+
+      compute: (continuation, {annotation}) =>
+        continuation({
+          ['#dateText']:
+            annotation
+              ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)
+              ?.[1] ??
+            null,
+        }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#dateText',
+      output: input.value({['#webArchiveDate']: null}),
+    }),
+
+    {
+      dependencies: ['#dateText'],
+      compute: (continuation, {['#dateText']: dateText}) =>
+        continuation({
+          ['#webArchiveDate']:
+            new Date(
+              dateText.slice(0, 4) + '/' +
+              dateText.slice(4, 6) + '/' +
+              dateText.slice(6, 8)),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
index af68073e..85d3b92a 100644
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -15,7 +15,8 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
-import {withFlattenedList, withPropertyFromList} from '#composite/data';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
 
 import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
@@ -86,6 +87,13 @@ export default templateCompositeFrom({
       internal: input.value(true),
     }),
 
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#trackArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
     withFlattenedList({
       list: '#trackArtworks.artistContribs',
     }),
@@ -93,17 +101,8 @@ export default templateCompositeFrom({
     withResultOfAvailabilityCheck({
       from: '#flattenedList',
       mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasUniqueCoverArt',
     }),
-
-    {
-      dependencies: ['#availability'],
-      compute: (continuation, {
-        ['#availability']: availability,
-      }) =>
-        continuation({
-          ['#hasUniqueCoverArt']:
-            availability,
-        }),
-    },
   ],
 });
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index ee7411f2..005c68c0 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -16,7 +16,6 @@ export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
 export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
-export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
deleted file mode 100644
index 9bf4278c..00000000
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ /dev/null
@@ -1,260 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isCommentary} from '#validators';
-import {commentaryRegexCaseSensitive} from '#wiki-data';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-export default templateCompositeFrom({
-  annotation: `withParsedCommentaryEntries`,
-
-  inputs: {
-    from: input({validate: isCommentary}),
-  },
-
-  outputs: ['#parsedCommentaryEntries'],
-
-  steps: () => [
-    {
-      dependencies: [input('from')],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-      }) => continuation({
-        ['#rawMatches']:
-          Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches',
-      properties: input.value([
-        '0', // The entire match as a string.
-        'groups',
-        'index',
-      ]),
-    }).outputs({
-      '#rawMatches.0': '#rawMatches.text',
-      '#rawMatches.groups': '#rawMatches.groups',
-      '#rawMatches.index': '#rawMatches.startIndex',
-    }),
-
-    {
-      dependencies: [
-        '#rawMatches.text',
-        '#rawMatches.startIndex',
-      ],
-
-      compute: (continuation, {
-        ['#rawMatches.text']: text,
-        ['#rawMatches.startIndex']: startIndex,
-      }) => continuation({
-        ['#rawMatches.endIndex']:
-          stitchArrays({text, startIndex})
-            .map(({text, startIndex}) => startIndex + text.length),
-      }),
-    },
-
-    {
-      dependencies: [
-        input('from'),
-        '#rawMatches.startIndex',
-        '#rawMatches.endIndex',
-      ],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-        ['#rawMatches.startIndex']: startIndex,
-        ['#rawMatches.endIndex']: endIndex,
-      }) => continuation({
-        ['#entries.body']:
-          stitchArrays({startIndex, endIndex})
-            .map(({endIndex}, index, stitched) =>
-              (index === stitched.length - 1
-                ? commentaryText.slice(endIndex)
-                : commentaryText.slice(
-                    endIndex,
-                    stitched[index + 1].startIndex)))
-            .map(body => body.trim()),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches.groups',
-      prefix: input.value('#entries'),
-      properties: input.value([
-        'artistReferences',
-        'artistDisplayText',
-        'annotation',
-        'date',
-        'secondDate',
-        'dateKind',
-        'accessDate',
-        'accessKind',
-      ]),
-    }),
-
-    // The artistReferences group will always have a value, since it's required
-    // for the line to match in the first place.
-
-    {
-      dependencies: ['#entries.artistReferences'],
-      compute: (continuation, {
-        ['#entries.artistReferences']: artistReferenceTexts,
-      }) => continuation({
-        ['#entries.artistReferences']:
-          artistReferenceTexts
-            .map(text => text.split(',').map(ref => ref.trim())),
-      }),
-    },
-
-    withFlattenedList({
-      list: '#entries.artistReferences',
-    }),
-
-    withResolvedReferenceList({
-      list: '#flattenedList',
-      find: inputSoupyFind.input('artist'),
-      notFoundMode: input.value('null'),
-    }),
-
-    withUnflattenedList({
-      list: '#resolvedReferenceList',
-    }).outputs({
-      '#unflattenedList': '#entries.artists',
-    }),
-
-    fillMissingListItems({
-      list: '#entries.artistDisplayText',
-      fill: input.value(null),
-    }),
-
-    fillMissingListItems({
-      list: '#entries.annotation',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.annotation'],
-      compute: (continuation, {
-        ['#entries.annotation']: annotation,
-      }) => continuation({
-        ['#entries.webArchiveDate']:
-          annotation
-            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
-            .map(match => match?.[1])
-            .map(dateText =>
-              (dateText
-                ? dateText.slice(0, 4) + '/' +
-                  dateText.slice(4, 6) + '/' +
-                  dateText.slice(6, 8)
-                : null)),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.date'],
-      compute: (continuation, {
-        ['#entries.date']: date,
-      }) => continuation({
-        ['#entries.date']:
-          date
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.secondDate'],
-      compute: (continuation, {
-        ['#entries.secondDate']: secondDate,
-      }) => continuation({
-        ['#entries.secondDate']:
-          secondDate
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    fillMissingListItems({
-      list: '#entries.dateKind',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.accessDate', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessDate']: accessDate,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessDate']:
-          stitchArrays({accessDate, webArchiveDate})
-            .map(({accessDate, webArchiveDate}) =>
-              accessDate ??
-              webArchiveDate ??
-              null)
-            .map(date => date ? new Date(date) : date),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.accessKind', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessKind']: accessKind,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessKind']:
-          stitchArrays({accessKind, webArchiveDate})
-            .map(({accessKind, webArchiveDate}) =>
-              accessKind ??
-              (webArchiveDate && 'captured') ??
-              null),
-      }),
-    },
-
-    {
-      dependencies: [
-        '#entries.artists',
-        '#entries.artistDisplayText',
-        '#entries.annotation',
-        '#entries.date',
-        '#entries.secondDate',
-        '#entries.dateKind',
-        '#entries.accessDate',
-        '#entries.accessKind',
-        '#entries.body',
-      ],
-
-      compute: (continuation, {
-        ['#entries.artists']: artists,
-        ['#entries.artistDisplayText']: artistDisplayText,
-        ['#entries.annotation']: annotation,
-        ['#entries.date']: date,
-        ['#entries.secondDate']: secondDate,
-        ['#entries.dateKind']: dateKind,
-        ['#entries.accessDate']: accessDate,
-        ['#entries.accessKind']: accessKind,
-        ['#entries.body']: body,
-      }) => continuation({
-        ['#parsedCommentaryEntries']:
-          stitchArrays({
-            artists,
-            artistDisplayText,
-            annotation,
-            date,
-            secondDate,
-            dateKind,
-            accessDate,
-            accessKind,
-            body,
-          }),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
deleted file mode 100644
index 9625278d..00000000
--- a/src/data/composite/wiki-properties/commentary.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Artist commentary! Generally present on tracks and albums.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isCommentary} from '#validators';
-
-import {exitWithoutDependency, exposeDependency}
-  from '#composite/control-flow';
-import {withParsedCommentaryEntries} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `commentary`,
-
-  compose: false,
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input.updateValue({validate: isCommentary}),
-      mode: input.value('falsy'),
-      value: input.value([]),
-    }),
-
-    withParsedCommentaryEntries({
-      from: input.updateValue(),
-    }),
-
-    exposeDependency({
-      dependency: '#parsedCommentaryEntries',
-    }),
-  ],
-});
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
index c5c14769..54d3e1a5 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -7,7 +7,6 @@ import {exitWithoutDependency, exposeDependency}
   from '#composite/control-flow';
 import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
   from '#composite/data';
-import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `commentatorArtists`,
@@ -21,19 +20,13 @@ export default templateCompositeFrom({
       value: input.value([]),
     }),
 
-    withParsedCommentaryEntries({
-      from: 'commentary',
-    }),
-
     withPropertyFromList({
-      list: '#parsedCommentaryEntries',
+      list: 'commentary',
       property: input.value('artists'),
-    }).outputs({
-      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
     withFlattenedList({
-      list: '#artistLists',
+      list: '#commentary.artists',
     }).outputs({
       '#flattenedList': '#artists',
     }),
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 06a627ec..d5e7657e 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -7,7 +7,6 @@ export {default as additionalFiles} from './additionalFiles.js';
 export {default as additionalNameList} from './additionalNameList.js';
 export {default as annotatedReferenceList} from './annotatedReferenceList.js';
 export {default as color} from './color.js';
-export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
 export {default as constitutibleArtwork} from './constitutibleArtwork.js';
 export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
diff --git a/src/data/things/album.js b/src/data/things/album.js
index e8106e24..8a25a8ac 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,6 +3,7 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
@@ -16,7 +17,9 @@ import {
   parseAdditionalNames,
   parseAnnotatedReferences,
   parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseWallpaperParts,
@@ -32,7 +35,6 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate}
 import {
   additionalFiles,
   additionalNameList,
-  commentary,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -59,7 +61,7 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks} from '#composite/things/album';
+import {withHasCoverArt, withTracks} from '#composite/things/album';
 import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
   from '#composite/things/track-section';
 
@@ -69,6 +71,8 @@ export class Album extends Thing {
   static [Thing.getPropertyDescriptors] = ({
     ArtTag,
     Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Group,
     Track,
     TrackSection,
@@ -188,9 +192,11 @@ export class Album extends Thing {
     ],
 
     coverArtworks: [
+      withHasCoverArt(),
+
       exitWithoutDependency({
-        dependency: 'coverArtistContribs',
-        mode: input.value('empty'),
+        dependency: '#hasCoverArt',
+        mode: input.value('falsy'),
         value: input.value([]),
       }),
 
@@ -202,8 +208,14 @@ export class Album extends Thing {
     isListedOnHomepage: flag(true),
     isListedInGalleries: flag(true),
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
     additionalFiles: additionalFiles(),
 
     trackSections: thingList({
@@ -297,7 +309,11 @@ export class Album extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
@@ -590,8 +606,15 @@ export class Album extends Thing {
         transform: parseDimensions,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -662,7 +685,11 @@ export class Album extends Thing {
       const albumData = [];
       const trackSectionData = [];
       const trackData = [];
+
       const artworkData = [];
+      const commentaryData = [];
+      const creditingSourceData = [];
+      const lyricsData = [];
 
       for (const {header: album, entries} of results) {
         const trackSections = [];
@@ -709,6 +736,13 @@ export class Album extends Thing {
           entry.album = album;
 
           artworkData.push(...entry.trackArtworks);
+          commentaryData.push(...entry.commentary);
+          creditingSourceData.push(...entry.creditSources);
+
+          // TODO: As exposed, Track.lyrics tries to inherit from the main
+          // release, which is impossible before the data's been linked.
+          // We just use the update value here. But it's icky!
+          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
         }
 
         closeCurrentTrackSection();
@@ -725,6 +759,9 @@ export class Album extends Thing {
           artworkData.push(album.wallpaperArtwork);
         }
 
+        commentaryData.push(...album.commentary);
+        creditingSourceData.push(...album.creditSources);
+
         album.trackSections = trackSections;
       }
 
@@ -732,7 +769,11 @@ export class Album extends Thing {
         albumData,
         trackSectionData,
         trackData,
+
         artworkData,
+        commentaryData,
+        creditingSourceData,
+        lyricsData,
       };
     },
 
@@ -763,12 +804,18 @@ export class Album extends Thing {
       ];
     }
 
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
     return [
-      'media.albumCover',
+      'media.trackCover',
+      this.directory,
 
       (artwork.unqualifiedDirectory
-        ? this.directory + '-' + artwork.unqualifiedDirectory
-        : this.directory),
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
 
       artwork.fileExtension,
     ];
diff --git a/src/data/things/content.js b/src/data/things/content.js
new file mode 100644
index 00000000..7f352795
--- /dev/null
+++ b/src/data/things/content.js
@@ -0,0 +1,122 @@
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+import {is, isDate} from '#validators';
+import {parseDate} from '#yaml';
+
+import {contentString, referenceList, simpleDate, soupyFind, thing}
+  from '#composite/wiki-properties';
+
+import {
+  exposeConstant,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+  withResultOfAvailabilityCheck,
+} from '#composite/control-flow';
+
+import {withWebArchiveDate} from '#composite/things/commentary-entry';
+
+export class ContentEntry extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+    // Update & expose
+
+    thing: thing(),
+
+    artists: referenceList({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+    }),
+
+    artistText: contentString(),
+
+    annotation: contentString(),
+
+    dateKind: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: is(...[
+          'sometime',
+          'throughout',
+          'around',
+        ]),
+      },
+    },
+
+    accessKind: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          is(...[
+            'captured',
+            'accessed',
+          ])),
+      }),
+
+      withWebArchiveDate(),
+
+      withResultOfAvailabilityCheck({
+        from: '#webArchiveDate',
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {['#availability']: availability}) =>
+          (availability
+            ? continuation.exit('captured')
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    date: simpleDate(),
+
+    secondDate: simpleDate(),
+
+    accessDate: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withWebArchiveDate(),
+
+      exposeDependencyOrContinue({
+        dependency: '#webArchiveDate',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    body: contentString(),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Artists': {property: 'artists'},
+      'Artist Text': {property: 'artistText'},
+
+      'Annotation': {property: 'annotation'},
+
+      'Date Kind': {property: 'dateKind'},
+      'Access Kind': {property: 'accessKind'},
+
+      'Date': {property: 'date', transform: parseDate},
+      'Second Date': {property: 'secondDate', transform: parseDate},
+      'Access Date': {property: 'accessDate', transform: parseDate},
+
+      'Body': {property: 'body'},
+    },
+  };
+}
+
+export class CommentaryEntry extends ContentEntry {}
+export class LyricsEntry extends ContentEntry {}
+export class CreditingSourcesEntry extends ContentEntry {}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index ace18af9..dac674dd 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -10,7 +10,9 @@ import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
 import {
   parseArtwork,
   parseAdditionalNames,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
 } from '#yaml';
@@ -27,7 +29,6 @@ import {
 import {
   additionalNameList,
   color,
-  commentary,
   commentatorArtists,
   constitutibleArtwork,
   contentString,
@@ -41,6 +42,7 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -52,6 +54,8 @@ export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
   static [Thing.getPropertyDescriptors] = ({
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Track,
     FlashAct,
     WikiInfo,
@@ -125,8 +129,13 @@ export class Flash extends Thing {
 
     additionalNames: additionalNameList(),
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
     // Update only
 
@@ -240,8 +249,15 @@ export class Flash extends Thing {
         transform: parseContributors,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Review Points': {ignore: true},
     },
@@ -441,8 +457,18 @@ export class FlashSide extends Thing {
       const flashSideData = results.filter(x => x instanceof FlashSide);
 
       const artworkData = flashData.map(flash => flash.coverArtwork);
-
-      return {flashData, flashActData, flashSideData, artworkData};
+      const commentaryData = flashData.flatMap(flash => flash.commentary);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+
+      return {
+        flashData,
+        flashActData,
+        flashSideData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+      };
     },
 
     sort({flashData}) {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 96cec88e..b832ab75 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -13,6 +13,7 @@ import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
 import * as artworkClasses from './artwork.js';
+import * as contentClasses from './content.js';
 import * as contributionClasses from './contribution.js';
 import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
@@ -29,6 +30,7 @@ const allClassLists = {
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
   'artwork.js': artworkClasses,
+  'content.js': contentClasses,
   'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
diff --git a/src/data/things/track.js b/src/data/things/track.js
index ca1d69af..ae7be170 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -12,10 +12,13 @@ import {
   parseAdditionalNames,
   parseAnnotatedReferences,
   parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseDuration,
+  parseLyrics,
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
@@ -37,7 +40,6 @@ import {
 import {
   additionalFiles,
   additionalNameList,
-  commentary,
   commentatorArtists,
   constitutibleArtworkList,
   contentString,
@@ -56,6 +58,7 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -86,7 +89,10 @@ export class Track extends Thing {
     Album,
     ArtTag,
     Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Flash,
+    LyricsEntry,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -215,12 +221,23 @@ export class Track extends Thing {
       dimensions(),
     ],
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
     lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
       inheritFromMainRelease(),
-      contentString(),
+
+      thingList({
+        class: input.value(LyricsEntry),
+      }),
     ],
 
     additionalFiles: additionalFiles(),
@@ -480,9 +497,20 @@ export class Track extends Thing {
 
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
 
-      'Lyrics': {property: 'lyrics'},
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
+      },
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 50317238..79602faa 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -11,6 +11,7 @@ import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import {sortByName} from '#sort';
 import Thing from '#thing';
 import thingConstructors from '#things';
+import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data';
 
 import {
   aggregateThrows,
@@ -824,6 +825,73 @@ export function parseArtwork({
   return transform;
 }
 
+export function parseContentEntries(thingClass, sourceText, {subdoc}) {
+  const map = matchEntry => ({
+    'Artists':
+      matchEntry.artistReferences
+        .split(',')
+        .map(ref => ref.trim()),
+
+    'Artist Text':
+      matchEntry.artistDisplayText,
+
+    'Annotation':
+      matchEntry.annotation,
+
+    'Date':
+      matchEntry.date,
+
+    'Second Date':
+      matchEntry.secondDate,
+
+    'Date Kind':
+      matchEntry.dateKind,
+
+    'Access Date':
+      matchEntry.accessDate,
+
+    'Access Kind':
+      matchEntry.accessKind,
+
+    'Body':
+      matchEntry.body,
+  });
+
+  const documents =
+    matchContentEntries(sourceText)
+      .map(matchEntry =>
+        withEntries(
+          map(matchEntry),
+          entries => entries
+            .filter(([key, value]) =>
+              value !== undefined &&
+              value !== null)));
+
+  const subdocs =
+    documents.map(document =>
+      subdoc(thingClass, document, {bindInto: 'thing'}));
+
+  return subdocs;
+}
+
+export function parseCommentary(sourceText, {subdoc, CommentaryEntry}) {
+  return parseContentEntries(CommentaryEntry, sourceText, {subdoc});
+}
+
+export function parseCreditingSources(sourceText, {subdoc, CreditingSourcesEntry}) {
+  return parseContentEntries(CreditingSourcesEntry, sourceText, {subdoc});
+}
+
+export function parseLyrics(sourceText, {subdoc, LyricsEntry}) {
+  if (!multipleLyricsDetectionRegex.test(sourceText)) {
+    const document = {'Body': sourceText};
+
+    return [subdoc(LyricsEntry, document, {bindInto: 'thing'})];
+  }
+
+  return parseContentEntries(LyricsEntry, sourceText, {subdoc});
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -1499,6 +1567,10 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['artworkData', ['artworkData']],
 
+    ['commentaryData', [/* find */]],
+
+    ['creditingSourceData', [/* find */]],
+
     ['flashData', [
       'wikiInfo',
     ]],
@@ -1513,6 +1585,8 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['homepageLayout.sections.rows', [/* find */]],
 
+    ['lyricsData', [/* find */]],
+
     ['trackData', [
       'artworkData',
       'trackData',
@@ -1781,14 +1855,16 @@ export function flattenThingLayoutToDocumentOrder(layout) {
 }
 
 export function* splitDocumentsInYAMLSourceText(sourceText) {
-  const dividerRegex = /^-{3,}\n?/gm;
+  // Not multiline!
+  const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g;
+
   let previousDivider = '';
 
   while (true) {
     const {lastIndex} = dividerRegex;
     const match = dividerRegex.exec(sourceText);
     if (match) {
-      const nextDivider = match[0].trim();
+      const nextDivider = match[0];
 
       yield {
         previousDivider,
@@ -1799,11 +1875,12 @@ export function* splitDocumentsInYAMLSourceText(sourceText) {
       previousDivider = nextDivider;
     } else {
       const nextDivider = '';
+      const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? '';
 
       yield {
         previousDivider,
         nextDivider,
-        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, '\n'),
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak),
       };
 
       return;
@@ -1829,7 +1906,7 @@ export function recombineDocumentsIntoYAMLSourceText(documents) {
 
   for (const document of documents) {
     if (sourceText) {
-      sourceText += divider + '\n';
+      sourceText += divider;
     }
 
     sourceText += document.text;
diff --git a/src/static/css/site.css b/src/static/css/site.css
index ab86915c..0a7e36ae 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -931,7 +931,11 @@ a .normal-content {
 
   background-color: var(--primary-color);
 
-  mask-image: url(/static-4p1/misc/image.svg);
+  /* mask-image is set in content JavaScript,
+   * because we can't identify the correct nor
+   * absolute path to the file from CSS.
+   */
+
   mask-repeat: no-repeat;
   mask-position: calc(100% - 2px);
   vertical-align: text-bottom;
@@ -1119,7 +1123,7 @@ a .normal-content {
   font-size: 0.9rem;
 }
 
-li:not(:first-child:last-child) .tooltip,
+li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .offset-tooltips > :not(:first-child:last-child) .tooltip {
   left: 14px;
 }
@@ -1161,7 +1165,7 @@ li:not(:first-child:last-child) .tooltip,
 .thing-name-tooltip,
 .wiki-edits-tooltip {
   padding: 3px 4px 2px 2px;
-  left: -6px !important;
+  left: -6px;
 }
 
 .thing-name-tooltip .tooltip-content,
@@ -1169,11 +1173,15 @@ li:not(:first-child:last-child) .tooltip,
   font-size: 0.85em;
 }
 
-/* Terrifying?
- * https://stackoverflow.com/a/64424759/4633828
- */
-.thing-name-tooltip { margin-right: -120px; }
-.wiki-edits-tooltip { margin-right: -200px; }
+.thing-name-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 120px;
+}
+
+.wiki-edits-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 200px;
+}
 
 .contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
@@ -1548,14 +1556,13 @@ p.image-details.origin-details {
   margin: 0;
 }
 
+/*
 p.content-heading:has(+ .commentary-entry-heading.dated) {
   clear: right;
 }
+*/
 
 .commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
-
   margin-left: 15px;
   padding-left: 5px;
   max-width: 625px;
@@ -1565,7 +1572,7 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 }
 
 .commentary-entry-heading-text {
-  flex-grow: 1;
+  display: block;
   padding-left: 1.25ch;
   text-indent: -1.25ch;
 }
@@ -1574,20 +1581,6 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   font-style: oblique;
 }
 
-.commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
-
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
-}
-
-.commentary-entry-heading .hoverable {
-  box-shadow: 1px 2px 6px 5px #04040460;
-}
-
 .commentary-entry-body summary {
   list-style-position: outside;
 }
@@ -1596,6 +1589,19 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   color: var(--primary-color);
 }
 
+.commentary-date {
+  float: right;
+  margin-top: -0.8em;
+  margin-left: 0.75ch;
+  padding-left: 0.5ch;
+  padding-right: 0.4em;
+  font-size: 0.9em;
+}
+
+.commentary-date .hoverable {
+  box-shadow: 1px 2px 6px 5px #04040460;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -1610,6 +1616,20 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important;
 }
 
+.lyrics-switcher {
+  padding-left: 20px;
+}
+
+.lyrics-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.lyrics-entry {
+  padding-left: 40px;
+  max-width: 600px;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1834,7 +1854,7 @@ li .by a {
   display: inline-block;
 }
 
-p code {
+p code, li code {
   font-size: 0.95em;
   font-family: "courier new", monospace;
   font-weight: 800;
@@ -3481,12 +3501,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: 375px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+10)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+10) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index 02d3cd96..b65574d0 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -89,7 +89,7 @@ export function getPageReferences() {
 
   info.contentCovers =
     info.contentCoverColumns
-      .map(el => el.querySelector('.cover-artwork'));
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
 
   info.contentCoversReveal =
     info.contentCovers
diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js
index cdab2cb8..b00ed98e 100644
--- a/src/static/js/rectangles.js
+++ b/src/static/js/rectangles.js
@@ -510,4 +510,46 @@ export class WikiRect extends DOMRect {
       height: this.height,
     });
   }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
 }
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 7d50dbb3..7a40bd0d 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -286,7 +286,12 @@ releaseInfo:
   duration: "Duration: {DURATION}."
 
   contributors: "Contributors:"
-  lyrics: "Lyrics:"
+
+  lyrics:
+    _: "Lyrics:"
+
+    switcher: "({ENTRIES})"
+
   note: "Context notes:"
 
   alsoReleasedOn: "Also released on {ALBUMS}."
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index c3bf89eb..74225efd 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -11,7 +11,7 @@ yamlAliases:
   # part of a build. This is so that multiple builds of a wiki can coexist
   # served from the same server / file system root: older builds' HTML files
   # refer to earlier values of STATIC_VERSION, avoiding name collisions.
-  - &staticVersion 4p1
+  - &staticVersion 5p1
 
 data:
   prefix: 'data/'
diff --git a/src/validators.js b/src/validators.js
index 3b23e8f6..5b8227fb 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util';
 import {openAggregate, withAggregate} from '#aggregate';
 import {colors, ENABLE_COLOR} from '#cli';
 import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
-import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
-  from '#wiki-data';
+
+import {
+  commentaryRegexCaseInsensitive,
+  commentaryRegexCaseSensitiveOneShot,
+  multipleLyricsDetectionRegex,
+} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -288,69 +292,108 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentaryText) {
-  isContentString(commentaryText);
-
-  const rawMatches =
-    Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive));
+export function validateContentEntries({
+  headingPhrase,
+  entryPhrase,
 
-  if (empty(rawMatches)) {
-    throw new TypeError(`Expected at least one commentary heading`);
-  }
+  caseInsensitiveRegex,
+  caseSensitiveOneShotRegex,
+}) {
+  return content => {
+    isContentString(content);
 
-  const niceMatches =
-    rawMatches.map(match => ({
-      position: match.index,
-      length: match[0].length,
-    }));
+    const rawMatches =
+      Array.from(content.matchAll(caseInsensitiveRegex));
 
-  validateArrayItems(({position, length}, index) => {
-    if (index === 0 && position > 0) {
-      throw new TypeError(`Expected first commentary heading to be at top`);
+    if (empty(rawMatches)) {
+      throw new TypeError(`Expected at least one ${headingPhrase}`);
     }
 
-    const ownInput = commentaryText.slice(position, position + length);
-    const restOfInput = commentaryText.slice(position + length);
+    const niceMatches =
+      rawMatches.map(match => ({
+        position: match.index,
+        length: match[0].length,
+      }));
 
-    const upToNextLineBreak =
-      (restOfInput.includes('\n')
-        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
-        : restOfInput);
+    validateArrayItems(({position, length}, index) => {
+      if (index === 0 && position > 0) {
+        throw new TypeError(`Expected first ${headingPhrase} to be at top`);
+      }
 
-    if (/\S/.test(upToNextLineBreak)) {
-      throw new TypeError(
-        `Expected commentary heading to occupy entire line, got extra text:\n` +
-        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
-        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
-        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
-    }
+      const ownInput = content.slice(position, position + length);
+      const restOfInput = content.slice(position + length);
 
-    if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) {
-      throw new TypeError(
-        `Miscapitalization in commentary heading:\n` +
-        `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
-        `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
-    }
+      const upToNextLineBreak =
+        (restOfInput.includes('\n')
+          ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+          : restOfInput);
 
-    const nextHeading =
-      (index === niceMatches.length - 1
-        ? commentaryText.length
-        : niceMatches[index + 1].position);
+      if (/\S/.test(upToNextLineBreak)) {
+        throw new TypeError(
+          `Expected ${headingPhrase} to occupy entire line, got extra text:\n` +
+          `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+          `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+          `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+      }
 
-    const upToNextHeading =
-      commentaryText.slice(position + length, nextHeading);
+      if (!caseSensitiveOneShotRegex.test(ownInput)) {
+        throw new TypeError(
+          `Miscapitalization in ${headingPhrase}:\n` +
+          `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
+          `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
+      }
 
-    if (!/\S/.test(upToNextHeading)) {
-      throw new TypeError(
-        `Expected commentary entry to have body text, only got a heading`);
-    }
+      const nextHeading =
+        (index === niceMatches.length - 1
+          ? content.length
+          : niceMatches[index + 1].position);
+
+      const upToNextHeading =
+        content.slice(position + length, nextHeading);
+
+      if (!/\S/.test(upToNextHeading)) {
+        throw new TypeError(
+          `Expected ${entryPhrase} to have body text, only got a heading`);
+      }
+
+      return true;
+    })(niceMatches);
 
     return true;
-  })(niceMatches);
+  };
+}
+
+export const isCommentary =
+  validateContentEntries({
+    headingPhrase: `commentary heading`,
+    entryPhrase: `commentary entry`,
+
+    caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+    caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+  });
+
+export function isOldStyleLyrics(content) {
+  isContentString(content);
+
+  if (multipleLyricsDetectionRegex.test(content)) {
+    throw new TypeError(
+      `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`);
+  }
 
   return true;
 }
 
+export const isLyrics =
+  anyOf(
+    isOldStyleLyrics,
+    validateContentEntries({
+      headingPhrase: `lyrics heading`,
+      entryPhrase: `lyrics entry`,
+
+      caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+      caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+    }));
+
 const isArtistRef = validateReference('artist');
 
 export function validateProperties(spec) {