« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/common-util/wiki-data.js29
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js8
-rw-r--r--src/content/dependencies/generatePageLayout.js6
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js12
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/data/checks.js42
-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/wiki-data/index.js4
-rw-r--r--src/data/composite/wiki-data/processContentEntryDates.js181
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js129
-rw-r--r--src/data/composite/wiki-data/withParsedContentEntries.js111
-rw-r--r--src/data/composite/wiki-data/withParsedLyricsEntries.js157
-rw-r--r--src/data/composite/wiki-properties/commentary.js34
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js11
-rw-r--r--src/data/composite/wiki-properties/index.js2
-rw-r--r--src/data/composite/wiki-properties/lyrics.js36
-rw-r--r--src/data/things/album.js45
-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.js43
-rw-r--r--src/data/yaml.js85
-rw-r--r--src/static/css/site.css64
-rw-r--r--src/static/js/client/index.js2
-rw-r--r--src/static/js/client/lyrics-switcher.js70
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/urls-default.yaml2
-rw-r--r--src/validators.js4
30 files changed, 492 insertions, 836 deletions
diff --git a/package.json b/package.json
index ed18fdba..d19da806 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
         "#composite/things/art-tag": "./src/data/composite/things/art-tag/index.js",
         "#composite/things/artist": "./src/data/composite/things/artist/index.js",
         "#composite/things/artwork": "./src/data/composite/things/artwork/index.js",
+        "#composite/things/commentary-entry": "./src/data/composite/things/commentary-entry/index.js",
         "#composite/things/contribution": "./src/data/composite/things/contribution/index.js",
         "#composite/things/flash": "./src/data/composite/things/flash/index.js",
         "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index 0aa18ddb..a4c6b3bd 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -103,10 +103,35 @@ export const commentaryRegexCaseSensitiveOneShot =
   new RegExp(commentaryRegexRaw);
 
 // The #validators function isOldStyleLyrics() describes
-// what this regular expression detects.
-export const oldStyleLyricsDetectionRegex =
+// 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/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/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 ca6f82b9..11d179ad 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -311,18 +311,6 @@ export default {
 
           relations.lyricsSection,
 
-          // html.tags([
-          //   relations.contentHeading.clone()
-          //     .slots({
-          //       attributes: {id: 'lyrics'},
-          //       title: language.$('releaseInfo.lyrics'),
-          //     }),
-
-          //   html.tag('blockquote',
-          //     {[html.onlyIfContent]: true},
-          //     relations.lyrics.slot('mode', 'lyrics')),
-          // ]),
-
           html.tags([
             relations.contentHeading.clone()
               .slots({
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/data/checks.js b/src/data/checks.js
index 25863d2d..52024144 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -19,12 +19,6 @@ import {
   withAggregate,
 } from '#aggregate';
 
-import {
-  combineWikiDataArrays,
-  commentaryRegexCaseSensitive,
-  oldStyleLyricsDetectionRegex,
-} from '#wiki-data';
-
 function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
@@ -190,7 +184,8 @@ export function filterReferenceErrors(wikiData, {
       groups: 'group',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['artTagData', {
@@ -198,7 +193,8 @@ export function filterReferenceErrors(wikiData, {
     }],
 
     ['flashData', {
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -238,7 +234,9 @@ export function filterReferenceErrors(wikiData, {
       artTags: '_artTag',
       referencedArtworks: '_artwork',
       mainReleaseTrack: '_trackMainReleasesOnly',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
+      lyrics: '_content',
     }],
 
     ['wikiInfo', {
@@ -273,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;
@@ -334,7 +332,7 @@ export function filterReferenceErrors(wikiData, {
                 findFn = boundFind.artTag;
                 break;
 
-              case '_commentary':
+              case '_content':
                 findFn = findArtistOrAlias;
                 break;
 
@@ -466,7 +464,7 @@ export function filterReferenceErrors(wikiData, {
                 }
               }
 
-              if (findFnKey === '_commentary') {
+              if (findFnKey === '_content') {
                 filter(
                   value, {message: errorMessage},
                   decorateErrorWithIndex(refs =>
@@ -573,7 +571,7 @@ export function reportContentTextErrors(wikiData, {
     annotation: 'commentary annotation',
   };
 
-  const newStyleLyricsShape = {
+  const lyricsShape = {
     body: 'lyrics body',
     artistDisplayText: 'lyrics artist display text',
     annotation: 'lyrics annotation',
@@ -625,7 +623,7 @@ export function reportContentTextErrors(wikiData, {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
       creditSources: commentaryShape,
-      lyrics: '_lyrics',
+      lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
     }],
@@ -749,7 +747,6 @@ export function reportContentTextErrors(wikiData, {
           nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
 
             for (let [property, shape] of Object.entries(propSpec)) {
-              const rawValue = CacheableObject.getUpdateValue(thing, property);
               let value = thing[property];
 
               if (value === undefined) {
@@ -761,15 +758,6 @@ export function reportContentTextErrors(wikiData, {
                 continue;
               }
 
-              if (shape === '_lyrics') {
-                if (oldStyleLyricsDetectionRegex.test(rawValue)) {
-                  value = rawValue;
-                  shape = '_content';
-                } else {
-                  shape = newStyleLyricsShape;
-                }
-              }
-
               const fieldPropertyMessage =
                 getFieldPropertyMessage(
                   thing.constructor[Thing.yamlDocumentSpec],
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/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 1d94f74b..005c68c0 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -11,15 +11,11 @@ export {default as inputNotFoundMode} from './inputNotFoundMode.js';
 export {default as inputSoupyFind} from './inputSoupyFind.js';
 export {default as inputSoupyReverse} from './inputSoupyReverse.js';
 export {default as inputWikiData} from './inputWikiData.js';
-export {default as processContentEntryDates} from './processContentEntryDates.js';
 export {default as withClonedThings} from './withClonedThings.js';
 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 withParsedContentEntries} from './withParsedContentEntries.js';
-export {default as withParsedLyricsEntries} from './withParsedLyricsEntries.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/processContentEntryDates.js b/src/data/composite/wiki-data/processContentEntryDates.js
deleted file mode 100644
index e418a121..00000000
--- a/src/data/composite/wiki-data/processContentEntryDates.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isContentString, isString, looseArrayOf} from '#validators';
-
-import {fillMissingListItems} from '#composite/data';
-
-// Important note: These two kinds of inputs have the exact same shape!!
-// This isn't on purpose (besides that they *are* both supposed to be strings).
-// They just don't have any more particular validation, yet.
-
-const inputDateList = defaultDependency =>
-  input({
-    validate: looseArrayOf(isString),
-    defaultDependency,
-  });
-
-const inputKindList = defaultDependency =>
-  input.staticDependency({
-    validate: looseArrayOf(isString),
-    defaultDependency: defaultDependency,
-  });
-
-export default templateCompositeFrom({
-  annotation: `processContentEntryDates`,
-
-  inputs: {
-    annotations: input({
-      validate: looseArrayOf(isContentString),
-      defaultDependency: '#entries.annotation',
-    }),
-
-    dates: inputDateList('#entries.date'),
-    secondDates: inputDateList('#entries.secondDate'),
-    accessDates: inputDateList('#entries.accessDate'),
-
-    dateKinds: inputKindList('#entries.dateKind'),
-    accessKinds: inputKindList('#entries.accessKind'),
-  },
-
-  outputs: ({
-    [input.staticDependency('dates')]: dates,
-    [input.staticDependency('secondDates')]: secondDates,
-    [input.staticDependency('accessDates')]: accessDates,
-    [input.staticDependency('dateKinds')]: dateKinds,
-    [input.staticDependency('accessKinds')]: accessKinds,
-  }) => [
-    dates ?? '#processedContentEntryDates',
-    secondDates ?? '#processedContentEntrySecondDates',
-    accessDates ?? '#processedContentEntryAccessDates',
-    dateKinds ?? '#processedContentEntryDateKinds',
-    accessKinds ?? '#processedContentEntryAccessKinds',
-  ],
-
-  steps: () => [
-    {
-      dependencies: [input('annotations')],
-      compute: (continuation, {
-        [input('annotations')]: annotations,
-      }) => continuation({
-        ['#webArchiveDates']:
-          annotations
-            .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: [input('dates')],
-      compute: (continuation, {
-        [input('dates')]: dates,
-      }) => continuation({
-        ['#processedContentEntryDates']:
-          dates
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    {
-      dependencies: [input('secondDates')],
-      compute: (continuation, {
-        [input('secondDates')]: secondDates,
-      }) => continuation({
-        ['#processedContentEntrySecondDates']:
-          secondDates
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    fillMissingListItems({
-      list: input('dateKinds'),
-      fill: input.value(null),
-    }).outputs({
-      '#list': '#processedContentEntryDateKinds',
-    }),
-
-    {
-      dependencies: [input('accessDates'), '#webArchiveDates'],
-      compute: (continuation, {
-        [input('accessDates')]: accessDates,
-        ['#webArchiveDates']: webArchiveDates,
-      }) => continuation({
-        ['#processedContentEntryAccessDates']:
-          stitchArrays({
-            accessDate: accessDates,
-            webArchiveDate: webArchiveDates
-          }).map(({accessDate, webArchiveDate}) =>
-              accessDate ??
-              webArchiveDate ??
-              null)
-            .map(date => date ? new Date(date) : date),
-      }),
-    },
-
-    {
-      dependencies: [input('accessKinds'), '#webArchiveDates'],
-      compute: (continuation, {
-        [input('accessKinds')]: accessKinds,
-        ['#webArchiveDates']: webArchiveDates,
-      }) => continuation({
-        ['#processedContentEntryAccessKinds']:
-          stitchArrays({
-            accessKind: accessKinds,
-            webArchiveDate: webArchiveDates,
-          }).map(({accessKind, webArchiveDate}) =>
-              accessKind ??
-              (webArchiveDate && 'captured') ??
-              null),
-      }),
-    },
-
-    // TODO: Annoying conversion step for outputs, would be nice to avoid.
-    {
-      dependencies: [
-        '#processedContentEntryDates',
-        '#processedContentEntrySecondDates',
-        '#processedContentEntryAccessDates',
-        '#processedContentEntryDateKinds',
-        '#processedContentEntryAccessKinds',
-        input.staticDependency('dates'),
-        input.staticDependency('secondDates'),
-        input.staticDependency('accessDates'),
-        input.staticDependency('dateKinds'),
-        input.staticDependency('accessKinds'),
-      ],
-
-      compute: (continuation, {
-        ['#processedContentEntryDates']: processedContentEntryDates,
-        ['#processedContentEntrySecondDates']: processedContentEntrySecondDates,
-        ['#processedContentEntryAccessDates']: processedContentEntryAccessDates,
-        ['#processedContentEntryDateKinds']: processedContentEntryDateKinds,
-        ['#processedContentEntryAccessKinds']: processedContentEntryAccessKinds,
-        [input.staticDependency('dates')]: dates,
-        [input.staticDependency('secondDates')]: secondDates,
-        [input.staticDependency('accessDates')]: accessDates,
-        [input.staticDependency('dateKinds')]: dateKinds,
-        [input.staticDependency('accessKinds')]: accessKinds,
-      }) => continuation({
-        [dates ?? '#processedContentEntryDates']:
-          processedContentEntryDates,
-
-        [secondDates ?? '#processedContentEntrySecondDates']:
-          processedContentEntrySecondDates,
-
-        [accessDates ?? '#processedContentEntryAccessDates']:
-          processedContentEntryAccessDates,
-
-        [dateKinds ?? '#processedContentEntryDateKinds']:
-          processedContentEntryDateKinds,
-
-        [accessKinds ?? '#processedContentEntryAccessKinds']:
-          processedContentEntryAccessKinds,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
deleted file mode 100644
index 6794c479..00000000
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ /dev/null
@@ -1,129 +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 processContentEntryDates from './processContentEntryDates.js';
-import withParsedContentEntries from './withParsedContentEntries.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-export default templateCompositeFrom({
-  annotation: `withParsedCommentaryEntries`,
-
-  inputs: {
-    from: input({validate: isCommentary}),
-  },
-
-  outputs: ['#parsedCommentaryEntries'],
-
-  steps: () => [
-    withParsedContentEntries({
-      from: input('from'),
-      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
-    }),
-
-    withPropertiesFromList({
-      list: '#parsedContentEntryHeadings',
-      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),
-    }),
-
-    processContentEntryDates(),
-
-    {
-      dependencies: [
-        '#entries.artists',
-        '#entries.artistDisplayText',
-        '#entries.annotation',
-        '#entries.date',
-        '#entries.secondDate',
-        '#entries.dateKind',
-        '#entries.accessDate',
-        '#entries.accessKind',
-        '#parsedContentEntryBodies',
-      ],
-
-      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,
-        ['#parsedContentEntryBodies']: body,
-      }) => continuation({
-        ['#parsedCommentaryEntries']:
-          stitchArrays({
-            artists,
-            artistDisplayText,
-            annotation,
-            date,
-            secondDate,
-            dateKind,
-            accessDate,
-            accessKind,
-            body,
-          }),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withParsedContentEntries.js b/src/data/composite/wiki-data/withParsedContentEntries.js
deleted file mode 100644
index 2a9b3f6a..00000000
--- a/src/data/composite/wiki-data/withParsedContentEntries.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isContentString, validateInstanceOf} from '#validators';
-
-import {withPropertiesFromList} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `withParsedContentEntries`,
-
-  inputs: {
-    // TODO: Is there any way to validate this input based on the *other*
-    // inputs proivded, i.e. regexes? This kind of just assumes the string
-    // has already been validated according to the form the regex expects,
-    // which *is* always the case (as used), but it seems a bit awkward.
-    from: input({validate: isContentString}),
-
-    caseSensitiveRegex: input({
-      validate: validateInstanceOf(RegExp),
-    }),
-  },
-
-  outputs: [
-    '#parsedContentEntryHeadings',
-    '#parsedContentEntryBodies',
-  ],
-
-  steps: () => [
-    {
-      dependencies: [
-        input('from'),
-        input('caseSensitiveRegex'),
-      ],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-        [input('caseSensitiveRegex')]: caseSensitiveRegex,
-      }) => continuation({
-        ['#rawMatches']:
-          Array.from(commentaryText.matchAll(caseSensitiveRegex)),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches',
-      properties: input.value([
-        '0', // The entire match as a string.
-        'groups',
-        'index',
-      ]),
-    }).outputs({
-      '#rawMatches.0': '#rawMatches.text',
-      '#rawMatches.groups': '#parsedContentEntryHeadings',
-      '#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({
-        ['#parsedContentEntryBodies']:
-          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()),
-      }),
-    },
-
-    {
-      dependencies: [
-        '#parsedContentEntryHeadings',
-        '#parsedContentEntryBodies',
-      ],
-
-      compute: (continuation, {
-        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
-        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
-      }) => continuation({
-        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
-        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
-      })
-    }
-  ],
-});
diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js
deleted file mode 100644
index d13bfbaa..00000000
--- a/src/data/composite/wiki-data/withParsedLyricsEntries.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isLyrics} from '#validators';
-import {commentaryRegexCaseSensitive, oldStyleLyricsDetectionRegex}
-  from '#wiki-data';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import processContentEntryDates from './processContentEntryDates.js';
-import withParsedContentEntries from './withParsedContentEntries.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-function constituteLyricsEntry(text) {
-  return {
-    artists: [],
-    artistDisplayText: null,
-    annotation: null,
-    date: null,
-    secondDate: null,
-    dateKind: null,
-    accessDate: null,
-    accessKind: null,
-    body: text,
-  };
-}
-
-export default templateCompositeFrom({
-  annotation: `withParsedLyricsEntries`,
-
-  inputs: {
-    from: input({validate: isLyrics}),
-  },
-
-  outputs: ['#parsedLyricsEntries'],
-
-  steps: () => [
-    {
-      dependencies: [input('from')],
-      compute: (continuation, {
-        [input('from')]: lyrics,
-      }) =>
-        (oldStyleLyricsDetectionRegex.test(lyrics)
-          ? continuation()
-          : continuation.raiseOutput({
-              ['#parsedLyricsEntries']:
-                [constituteLyricsEntry(lyrics)],
-            })),
-    },
-
-    withParsedContentEntries({
-      from: input('from'),
-      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
-    }),
-
-    withPropertiesFromList({
-      list: '#parsedContentEntryHeadings',
-      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),
-    }),
-
-    processContentEntryDates(),
-
-    {
-      dependencies: [
-        '#entries.artists',
-        '#entries.artistDisplayText',
-        '#entries.annotation',
-        '#entries.date',
-        '#entries.secondDate',
-        '#entries.dateKind',
-        '#entries.accessDate',
-        '#entries.accessKind',
-        '#parsedContentEntryBodies',
-      ],
-
-      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,
-        ['#parsedContentEntryBodies']: body,
-      }) => continuation({
-        ['#parsedLyricsEntries']:
-          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 928bbd1b..00000000
--- a/src/data/composite/wiki-properties/commentary.js
+++ /dev/null
@@ -1,34 +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,
-
-  update: {
-    validate: isCommentary,
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input.updateValue(),
-      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 892fc44a..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';
@@ -20,7 +19,6 @@ export {default as duration} from './duration.js';
 export {default as externalFunction} from './externalFunction.js';
 export {default as fileExtension} from './fileExtension.js';
 export {default as flag} from './flag.js';
-export {default as lyrics} from './lyrics.js';
 export {default as name} from './name.js';
 export {default as referenceList} from './referenceList.js';
 export {default as referencedArtworkList} from './referencedArtworkList.js';
diff --git a/src/data/composite/wiki-properties/lyrics.js b/src/data/composite/wiki-properties/lyrics.js
deleted file mode 100644
index eb5e524a..00000000
--- a/src/data/composite/wiki-properties/lyrics.js
+++ /dev/null
@@ -1,36 +0,0 @@
-// Lyrics! This comes in two styles - "old", where there's just one set of
-// lyrics, or the newer/standard one, with multiple sets that are each
-// annotated, credited, etc.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isLyrics} from '#validators';
-
-import {exitWithoutDependency, exposeDependency}
-  from '#composite/control-flow';
-import {withParsedLyricsEntries} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `lyrics`,
-
-  compose: false,
-
-  update: {
-    validate: isLyrics,
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input.updateValue(),
-      mode: input.value('falsy'),
-      value: input.value([]),
-    }),
-
-    withParsedLyricsEntries({
-      from: input.updateValue(),
-    }),
-
-    exposeDependency({
-      dependency: '#parsedLyricsEntries',
-    }),
-  ],
-});
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 4c85ddfa..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,
@@ -69,6 +71,8 @@ export class Album extends Thing {
   static [Thing.getPropertyDescriptors] = ({
     ArtTag,
     Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Group,
     Track,
     TrackSection,
@@ -204,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({
@@ -596,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',
@@ -668,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 = [];
@@ -715,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();
@@ -731,6 +759,9 @@ export class Album extends Thing {
           artworkData.push(album.wallpaperArtwork);
         }
 
+        commentaryData.push(...album.commentary);
+        creditingSourceData.push(...album.creditSources);
+
         album.trackSections = trackSections;
       }
 
@@ -738,7 +769,11 @@ export class Album extends Thing {
         albumData,
         trackSectionData,
         trackData,
+
         artworkData,
+        commentaryData,
+        creditingSourceData,
+        lyricsData,
       };
     },
 
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 bcf84aa8..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,
@@ -46,7 +48,6 @@ import {
   directory,
   duration,
   flag,
-  lyrics,
   name,
   referenceList,
   referencedArtworkList,
@@ -57,6 +58,7 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -87,7 +89,10 @@ export class Track extends Thing {
     Album,
     ArtTag,
     Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Flash,
+    LyricsEntry,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -216,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(),
-      lyrics(),
+
+      thingList({
+        class: input.value(LyricsEntry),
+      }),
     ],
 
     additionalFiles: additionalFiles(),
@@ -481,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 a4139624..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%;
@@ -1848,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;
@@ -3495,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/index.js b/src/static/js/client/index.js
index b2343f07..81ea3415 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -15,7 +15,6 @@ import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
 import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js';
 import * as liveMousePositionModule from './live-mouse-position.js';
-import * as lyricsSwitcherModule from './lyrics-switcher.js';
 import * as quickDescriptionModule from './quick-description.js';
 import * as scriptedLinkModule from './scripted-link.js';
 import * as sidebarSearchModule from './sidebar-search.js';
@@ -38,7 +37,6 @@ export const modules = [
   imageOverlayModule,
   intrapageDotSwitcherModule,
   liveMousePositionModule,
-  lyricsSwitcherModule,
   quickDescriptionModule,
   scriptedLinkModule,
   sidebarSearchModule,
diff --git a/src/static/js/client/lyrics-switcher.js b/src/static/js/client/lyrics-switcher.js
deleted file mode 100644
index b350ea50..00000000
--- a/src/static/js/client/lyrics-switcher.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/* eslint-env browser */
-
-import {stitchArrays} from '../../shared-util/sugar.js';
-
-import {cssProp} from '../client-util.js';
-
-export const info = {
-  id: 'lyricsSwitcherInfo',
-
-  entries: null,
-  switchLinks: null,
-  currentLinks: null,
-};
-
-export function getPageReferences() {
-  const content = document.getElementById('content');
-
-  if (!content) return;
-
-  const switcher = content.querySelector('.lyrics-switcher');
-
-  if (!switcher) return;
-
-  info.entries =
-    Array.from(content.querySelectorAll('.lyrics-entry'));
-
-  info.currentLinks =
-    Array.from(switcher.querySelectorAll('a.current'));
-
-  info.switchLinks =
-    Array.from(switcher.querySelectorAll('a:not(.current)'));
-}
-
-export function addPageListeners() {
-  if (!info.switchLinks) return;
-
-  for (const {switchLink, entry} of stitchArrays({
-    switchLink: info.switchLinks,
-    entry: info.entries,
-  })) {
-    switchLink.addEventListener('click', domEvent => {
-      domEvent.preventDefault();
-      showLyricsEntry(entry);
-    });
-  }
-}
-
-function showLyricsEntry(entry) {
-  const entryToShow = entry;
-
-  stitchArrays({
-    entry: info.entries,
-    currentLink: info.currentLinks,
-    switchLink: info.switchLinks,
-  }).forEach(({
-      entry,
-      currentLink,
-      switchLink,
-    }) => {
-      if (entry === entryToShow) {
-        cssProp(entry, 'display', null);
-        cssProp(currentLink, 'display', null);
-        cssProp(switchLink, 'display', 'none');
-      } else {
-        cssProp(entry, 'display', 'none');
-        cssProp(currentLink, 'display', 'none');
-        cssProp(switchLink, 'display', null);
-      }
-    });
-}
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/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 6badc93a..5b8227fb 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -7,7 +7,7 @@ import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
 import {
   commentaryRegexCaseInsensitive,
   commentaryRegexCaseSensitiveOneShot,
-  oldStyleLyricsDetectionRegex,
+  multipleLyricsDetectionRegex,
 } from '#wiki-data';
 
 function inspect(value) {
@@ -375,7 +375,7 @@ export const isCommentary =
 export function isOldStyleLyrics(content) {
   isContentString(content);
 
-  if (oldStyleLyricsDetectionRegex.test(content)) {
+  if (multipleLyricsDetectionRegex.test(content)) {
     throw new TypeError(
       `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`);
   }