« get me outta code hell

wip - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2024-07-25 16:35:02 -0300
committer(quasar) nebula <qznebula@protonmail.com>2025-04-13 22:54:14 -0300
commit48dde4a388fd4c31dd5680f7535419874124e554 (patch)
tree50c0fce8ef19f3bae856e79b1dc92e257e0db4ab /src
parentb1ff1444c47f6bd8c532e3a76eb2a5b92ed82a0e (diff)
wip
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js42
-rw-r--r--src/content/dependencies/generateLyricsSwitcher.js49
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js27
-rw-r--r--src/content/dependencies/transformContent.js35
-rw-r--r--src/data/composite/wiki-data/index.js2
-rw-r--r--src/data/composite/wiki-data/processContentEntryDates.js181
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js77
-rw-r--r--src/data/composite/wiki-data/withParsedLyricsEntries.js130
-rw-r--r--src/data/composite/wiki-properties/commentary.js6
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/composite/wiki-properties/lyrics.js36
-rw-r--r--src/data/things/track.js3
-rw-r--r--src/static/css/site.css13
-rw-r--r--src/static/js/client/index.js2
-rw-r--r--src/static/js/client/lyrics-switcher.js70
-rw-r--r--src/strings-default.yaml7
-rw-r--r--src/validators.js19
18 files changed, 632 insertions, 93 deletions
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..7e7718c7
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,42 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateLyricsEntry',
+    'generateLyricsSwitcher',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateLyricsSwitcher', entries),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tags([
+      relations.heading
+        .slots({
+          attributes: {id: 'lyrics'},
+          title: language.$('releaseInfo.lyrics'),
+        }),
+
+      relations.switcher,
+
+      relations.entries
+        .map((entry, index) =>
+          entry.slots({
+            attributes: [
+              index >= 1 &&
+                {style: 'display: none'},
+            ],
+          })),
+    ]),
+};
diff --git a/src/content/dependencies/generateLyricsSwitcher.js b/src/content/dependencies/generateLyricsSwitcher.js
new file mode 100644
index 00000000..1c9ee6a3
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSwitcher.js
@@ -0,0 +1,49 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  slots: {
+    tag: {type: 'string', default: 'p'},
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag(slots.tag, {class: 'lyrics-switcher'},
+      language.$('releaseInfo.lyrics.switcher', {
+        entries:
+          language.formatListWithoutSeparator(
+            relations.annotations
+              .map((annotation, index) =>
+                html.tag('span', {[html.joinChildren]: ''}, [
+                  html.tag('a',
+                    {href: '#'},
+
+                    index === 0 &&
+                      {style: 'display: none'},
+
+                    annotation
+                      .slots({
+                        mode: 'inline',
+                        textOnly: true,
+                      })),
+
+                  html.tag('a',
+                    {class: 'current'},
+
+                    index >= 1 &&
+                      {style: 'display: none'},
+
+                    annotation
+                      .slots({
+                        mode: 'inline',
+                        textOnly: true,
+                      })),
+                ]))),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 7d531124..ca6f82b9 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,19 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
-          html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'lyrics'},
-                title: language.$('releaseInfo.lyrics'),
-              }),
+          relations.lyricsSection,
 
-            html.tag('blockquote',
-              {[html.onlyIfContent]: true},
-              relations.lyrics.slot('mode', 'lyrics')),
-          ]),
+          // 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()
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/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index d2a60935..1d94f74b 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -11,6 +11,7 @@ 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';
@@ -18,6 +19,7 @@ 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
new file mode 100644
index 00000000..e418a121
--- /dev/null
+++ b/src/data/composite/wiki-data/processContentEntryDates.js
@@ -0,0 +1,181 @@
+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
index 885ea28d..6794c479 100644
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -11,6 +11,7 @@ import {
 } from '#composite/data';
 
 import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
 import withParsedContentEntries from './withParsedContentEntries.js';
 import withResolvedReferenceList from './withResolvedReferenceList.js';
 
@@ -84,81 +85,7 @@ export default templateCompositeFrom({
       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),
-      }),
-    },
+    processContentEntryDates(),
 
     {
       dependencies: [
diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js
new file mode 100644
index 00000000..28e4c9b5
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedLyricsEntries.js
@@ -0,0 +1,130 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {isLyrics} from '#validators';
+import {commentaryRegexCaseSensitive} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedLyricsEntries`,
+
+  inputs: {
+    from: input({validate: isLyrics}),
+  },
+
+  outputs: ['#parsedLyricsEntries'],
+
+  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',
+      data: 'artistData',
+      find: input.value(find.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
index 9625278d..928bbd1b 100644
--- a/src/data/composite/wiki-properties/commentary.js
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -12,9 +12,13 @@ export default templateCompositeFrom({
 
   compose: false,
 
+  update: {
+    validate: isCommentary,
+  },
+
   steps: () => [
     exitWithoutDependency({
-      dependency: input.updateValue({validate: isCommentary}),
+      dependency: input.updateValue(),
       mode: input.value('falsy'),
       value: input.value([]),
     }),
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 06a627ec..892fc44a 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -20,6 +20,7 @@ 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
new file mode 100644
index 00000000..eb5e524a
--- /dev/null
+++ b/src/data/composite/wiki-properties/lyrics.js
@@ -0,0 +1,36 @@
+// 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/track.js b/src/data/things/track.js
index ca1d69af..bcf84aa8 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -46,6 +46,7 @@ import {
   directory,
   duration,
   flag,
+  lyrics,
   name,
   referenceList,
   referencedArtworkList,
@@ -220,7 +221,7 @@ export class Track extends Thing {
 
     lyrics: [
       inheritFromMainRelease(),
-      contentString(),
+      lyrics(),
     ],
 
     additionalFiles: additionalFiles(),
diff --git a/src/static/css/site.css b/src/static/css/site.css
index ab86915c..6b61af72 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -1610,6 +1610,19 @@ 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;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 81ea3415..b2343f07 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -15,6 +15,7 @@ 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';
@@ -37,6 +38,7 @@ 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
new file mode 100644
index 00000000..b350ea50
--- /dev/null
+++ b/src/static/js/client/lyrics-switcher.js
@@ -0,0 +1,70 @@
+/* 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/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/validators.js b/src/validators.js
index 9b34cc04..5300d4ad 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -368,9 +368,28 @@ export const isCommentary =
     caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
   });
 
+export function isOldStyleLyrics(content) {
+  isContentString(content);
+
+  if (/^<i>/m.test(content)) {
+    throw new TypeError(
+      `Expected old-style lyrics block not to include <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) {