« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things')
-rw-r--r--src/data/things/album.js300
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/flash.js183
-rw-r--r--src/data/things/index.js7
-rw-r--r--src/data/things/language.js66
-rw-r--r--src/data/things/track.js21
6 files changed, 469 insertions, 113 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 336d777..e9f55b2 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,20 +1,25 @@
 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 find from '#find';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {empty} from '#sugar';
+import {accumulateSum, empty} from '#sugar';
 import Thing from '#thing';
-import {isDate} from '#validators';
+import {isColor, isDate, validateWikiData} from '#validators';
 import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
   from '#yaml';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
-import {exitWithoutContribs} from '#composite/wiki-data';
+import {withPropertyFromObject} from '#composite/data';
+import {exitWithoutContribs, withDirectory, withResolvedReference}
+  from '#composite/wiki-data';
 
 import {
   additionalFiles,
@@ -31,16 +36,24 @@ import {
   referenceList,
   simpleDate,
   simpleString,
+  singleReference,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks, withTrackSections} from '#composite/things/album';
+import {withTracks} from '#composite/things/album';
+import {withAlbum} from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
-  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artist,
+    Group,
+    Track,
+    TrackSection,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Album'),
@@ -48,6 +61,8 @@ export class Album extends Thing {
     directory: directory(),
     urls: urls(),
 
+    alwaysReferenceTracksByDirectory: flag(false),
+
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
@@ -92,6 +107,11 @@ export class Album extends Thing {
       simpleString(),
     ],
 
+    coverArtDimensions: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      dimensions(),
+    ],
+
     bannerDimensions: [
       exitWithoutContribs({contribs: 'bannerArtistContribs'}),
       dimensions(),
@@ -104,10 +124,11 @@ export class Album extends Thing {
     commentary: commentary(),
     additionalFiles: additionalFiles(),
 
-    trackSections: [
-      withTrackSections(),
-      exposeDependency({dependency: '#trackSections'}),
-    ],
+    trackSections: referenceList({
+      referenceType: input.value('unqualified-track-section'),
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
+    }),
 
     artistContribs: contributionList(),
     coverArtistContribs: contributionList(),
@@ -148,11 +169,8 @@ export class Album extends Thing {
       class: input.value(Group),
     }),
 
-    // Only the tracks which belong to this album.
-    // Necessary for computing the track list, so provide this statically
-    // or keep it updated.
-    ownTrackData: wikiData({
-      class: input.value(Track),
+    ownTrackSectionData: wikiData({
+      class: input.value(TrackSection),
     }),
 
     // Expose only
@@ -221,6 +239,10 @@ export class Album extends Thing {
       'Album': {property: 'name'},
       'Directory': {property: 'directory'},
 
+      'Always Reference Tracks By Directory': {
+        property: 'alwaysReferenceTracksByDirectory',
+      },
+
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
         transform: String,
@@ -261,6 +283,11 @@ export class Album extends Thing {
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
       'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Wallpaper Artists': {
         property: 'wallpaperArtistContribs',
         transform: parseContributors,
@@ -329,68 +356,77 @@ export class Album extends Thing {
     headerDocumentThing: Album,
     entryDocumentThing: document =>
       ('Section' in document
-        ? TrackSectionHelper
+        ? TrackSection
         : Track),
 
     save(results) {
       const albumData = [];
+      const trackSectionData = [];
       const trackData = [];
 
       for (const {header: album, entries} of results) {
-        // We can't mutate an array once it's set as a property value,
-        // so prepare the track sections that will show up in a track list
-        // all the way before actually applying them. (It's okay to mutate
-        // an individual section before applying it, since those are just
-        // generic objects; they aren't Things in and of themselves.)
         const trackSections = [];
-        const ownTrackData = [];
 
-        let currentTrackSection = {
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
           name: `Default Track Section`,
           isDefaultTrackSection: true,
-          tracks: [],
-        };
+        });
 
         const albumRef = Thing.getReference(album);
 
         const closeCurrentTrackSection = () => {
-          if (!empty(currentTrackSection.tracks)) {
-            trackSections.push(currentTrackSection);
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
           }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks
+              .map(track => Thing.getReference(track));
+
+          currentTrackSection.ownTrackData =
+            currentTrackSectionTracks;
+
+          currentTrackSection.ownAlbumData =
+            [album];
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
         };
 
         for (const entry of entries) {
-          if (entry instanceof TrackSectionHelper) {
+          if (entry instanceof TrackSection) {
             closeCurrentTrackSection();
-
-            currentTrackSection = {
-              name: entry.name,
-              color: entry.color,
-              dateOriginallyReleased: entry.dateOriginallyReleased,
-              isDefaultTrackSection: false,
-              tracks: [],
-            };
-
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
             continue;
           }
 
+          currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
           entry.dataSourceAlbum = albumRef;
-
-          ownTrackData.push(entry);
-          currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
-        album.trackSections = trackSections;
-        album.ownTrackData = ownTrackData;
+        album.trackSections =
+          trackSections
+            .map(trackSection =>
+              `unqualified-track-section:` +
+              trackSection.unqualifiedDirectory);
+
+        album.ownTrackSectionData = trackSections;
       }
 
-      return {albumData, trackData};
+      return {albumData, trackSectionData, trackData};
     },
 
     sort({albumData, trackData}) {
@@ -400,15 +436,139 @@ export class Album extends Thing {
   });
 }
 
-export class TrackSectionHelper extends Thing {
+export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
 
-  static [Thing.getPropertyDescriptors] = () => ({
     name: name('Unnamed Track Section'),
-    color: color(),
+
+    unqualifiedDirectory: directory(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
-    isDefaultTrackGroup: flag(false),
-  })
+
+    isDefaultTrackSection: flag(false),
+
+    album: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+
+    tracks: referenceList({
+      class: input.value(Track),
+      data: 'ownTrackData',
+      find: input.value(find.track),
+    }),
+
+    // Update only
+
+    ownAlbumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    ownTrackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    directory: [
+      withAlbum(),
+
+      exitWithoutDependency({
+        dependency: '#album',
+      }),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('directory'),
+      }),
+
+      withDirectory({
+        directory: 'unqualifiedDirectory',
+      }).outputs({
+        '#directory': '#unqualifiedDirectory',
+      }),
+
+      {
+        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        compute: ({
+          ['#album.directory']: albumDirectory,
+          ['#unqualifiedDirectory']: unqualifiedDirectory,
+        }) =>
+          albumDirectory + '/' + unqualifiedDirectory,
+      },
+    ],
+
+    startIndex: [
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('trackSections'),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', input.myself()],
+        compute: (continuation, {
+          ['#album.trackSections']: trackSections,
+          [input.myself()]: myself,
+        }) => continuation({
+          ['#index']:
+            trackSections.indexOf(myself),
+        }),
+      },
+
+      exitWithoutDependency({
+        dependency: '#index',
+        mode: input.value('index'),
+        value: input.value(0),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', '#index'],
+        compute: ({
+          ['#album.trackSections']: trackSections,
+          ['#index']: index,
+        }) =>
+          accumulateSum(
+            trackSections
+              .slice(0, index)
+              .map(section => section.tracks.length)),
+      },
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    trackSection: {
+      referenceTypes: ['track-section'],
+      bindTo: 'trackSectionData',
+    },
+
+    unqualifiedTrackSection: {
+      referenceTypes: ['unqualified-track-section'],
+
+      getMatchableDirectories: trackSection =>
+        [trackSection.unqualifiedDirectory],
+    },
+  };
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
@@ -421,4 +581,48 @@ export class TrackSectionHelper extends Thing {
       },
     },
   };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {}
+
+      let first = null;
+      try {
+        first = this.startIndex;
+      } catch {}
+
+      let length = null;
+      try {
+        length = this.tracks.length;
+      } catch {}
+
+      album ??= CacheableObject.getUpdateValue(this, 'ownAlbumData')?.[0];
+
+      if (album) {
+        const albumName = album.name;
+        const albumIndex = album.trackSections.indexOf(this);
+
+        const num =
+          (albumIndex === -1
+            ? 'indeterminate position'
+            : `#${albumIndex + 1}`);
+
+        const range =
+          (albumIndex >= 0 && first !== null && length !== null
+            ? `: ${first + 1}-${first + length + 1}`
+            : '');
+
+        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
+      }
+    }
+
+    return parts.join('');
+  }
 }
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 73acba6..841d652 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -220,6 +220,11 @@ export class Artist extends Thing {
       data: 'flashData',
       list: input.value('contributorContribs'),
     }),
+
+    flashesAsCommentator: reverseReferenceList({
+      data: 'flashData',
+      list: input.value('commentatorArtists'),
+    }),
   });
 
   static [Thing.getSerializeDescriptors] = ({
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 81de327..ceed79f 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -2,17 +2,26 @@ export const FLASH_DATA_FILE = 'flashes.yaml';
 
 import {input} from '#composite';
 import find from '#find';
+import {empty} from '#sugar';
 import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
-import {anyOf, isColor, isDirectory, isNumber, isString} from '#validators';
+import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
+  from '#validators';
 import {parseDate, parseContributors} from '#yaml';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
 import {
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   color,
+  commentary,
+  commentatorArtists,
   contentString,
   contributionList,
   directory,
@@ -25,6 +34,7 @@ import {
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
+import {withFlashSide} from '#composite/things/flash-act';
 
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
@@ -89,6 +99,8 @@ export class Flash extends Thing {
 
     urls: urls(),
 
+    commentary: commentary(),
+
     // Update only
 
     artistData: wikiData({
@@ -105,16 +117,23 @@ export class Flash extends Thing {
 
     // Expose only
 
-    act: {
-      flags: {expose: true},
+    commentatorArtists: commentatorArtists(),
 
-      expose: {
-        dependencies: ['this', 'flashActData'],
+    act: [
+      withFlashAct(),
+      exposeDependency({dependency: '#flashAct'}),
+    ],
 
-        compute: ({this: flash, flashActData}) =>
-          flashActData.find((act) => act.flashes.includes(flash)) ?? null,
-      },
-    },
+    side: [
+      withFlashAct(),
+
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('side'),
+      }),
+
+      exposeDependency({dependency: '#flashAct.side'}),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -153,11 +172,14 @@ export class Flash extends Thing {
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
       'Featured Tracks': {property: 'featuredTracks'},
+
       'Contributors': {
         property: 'contributorContribs',
         transform: parseContributors,
       },
 
+      'Commentary': {property: 'commentary'},
+
       'Review Points': {ignore: true},
     },
   };
@@ -173,19 +195,27 @@ export class FlashAct extends Thing {
     name: name('Unnamed Flash Act'),
     directory: directory(),
     color: color(),
-    listTerminology: contentString(),
 
-    jump: contentString(),
+    listTerminology: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
 
-    jumpColor: {
-      flags: {update: true, expose: true},
-      update: {validate: isColor},
-      expose: {
-        dependencies: ['color'],
-        transform: (jumpColor, {color}) =>
-          jumpColor ?? color,
-      }
-    },
+      withFlashSide(),
+
+      withPropertyFromObject({
+        object: '#flashSide',
+        property: input.value('listTerminology'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#flashSide.listTerminology',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
 
     flashes: referenceList({
       class: input.value(Flash),
@@ -198,6 +228,17 @@ export class FlashAct extends Thing {
     flashData: wikiData({
       class: input.value(Flash),
     }),
+
+    flashSideData: wikiData({
+      class: input.value(FlashSide),
+    }),
+
+    // Expose only
+
+    side: [
+      withFlashSide(),
+      exposeDependency({dependency: '#flashSide'}),
+    ],
   });
 
   static [Thing.findSpecs] = {
@@ -215,12 +256,51 @@ export class FlashAct extends Thing {
       'Color': {property: 'color'},
       'List Terminology': {property: 'listTerminology'},
 
-      'Jump': {property: 'jump'},
-      'Jump Color': {property: 'jumpColor'},
-
       'Review Points': {ignore: true},
     },
   };
+}
+
+export class FlashSide extends Thing {
+  static [Thing.referenceType] = 'flash-side';
+  static [Thing.friendlyName] = `Flash Side`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Flash Side'),
+    directory: directory(),
+    color: color(),
+    listTerminology: contentString(),
+
+    acts: referenceList({
+      class: input.value(FlashAct),
+      find: input.value(find.flashAct),
+      data: 'flashActData',
+    }),
+
+    // Update only
+
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Side': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+    },
+  };
+
+  static [Thing.findSpecs] = {
+    flashSide: {
+      referenceTypes: ['flash-side'],
+      bindTo: 'flashSideData',
+    },
+  };
 
   static [Thing.getYamlLoadingSpec] = ({
     documentModes: {allInOne},
@@ -231,39 +311,56 @@ export class FlashAct extends Thing {
 
     documentMode: allInOne,
     documentThing: document =>
-      ('Act' in document
+      ('Side' in document
+        ? FlashSide
+     : 'Act' in document
         ? FlashAct
         : Flash),
 
     save(results) {
-      let flashAct;
-      let flashRefs = [];
+      // JavaScript likes you.
 
-      if (results[0] && !(results[0] instanceof FlashAct)) {
-        throw new Error(`Expected an act at top of flash data file`);
+      if (!empty(results) && !(results[0] instanceof FlashSide)) {
+        throw new Error(`Expected a side at top of flash data file`);
       }
 
-      for (const thing of results) {
-        if (thing instanceof FlashAct) {
-          if (flashAct) {
-            Object.assign(flashAct, {flashes: flashRefs});
-          }
+      let index = 0;
+      let thing;
+      for (; thing = results[index]; index++) {
+        const flashSide = thing;
+        const flashActRefs = [];
 
-          flashAct = thing;
-          flashRefs = [];
-        } else {
-          flashRefs.push(Thing.getReference(thing));
+        if (results[index + 1] instanceof Flash) {
+          throw new Error(`Expected an act to immediately follow a side`);
         }
-      }
 
-      if (flashAct) {
-        Object.assign(flashAct, {flashes: flashRefs});
+        for (
+          index++;
+          (thing = results[index]) && thing instanceof FlashAct;
+          index++
+        ) {
+          const flashAct = thing;
+          const flashRefs = [];
+          for (
+            index++;
+            (thing = results[index]) && thing instanceof Flash;
+            index++
+          ) {
+            flashRefs.push(Thing.getReference(thing));
+          }
+          index--;
+          flashAct.flashes = flashRefs;
+          flashActRefs.push(Thing.getReference(flashAct));
+        }
+        index--;
+        flashSide.acts = flashActRefs;
       }
 
       const flashData = results.filter(x => x instanceof Flash);
       const flashActData = results.filter(x => x instanceof FlashAct);
+      const flashSideData = results.filter(x => x instanceof FlashSide);
 
-      return {flashData, flashActData};
+      return {flashData, flashActData, flashSideData};
     },
 
     sort({flashData}) {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3bf8409..4f87f49 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -2,10 +2,10 @@ import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import {openAggregate, showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-
 import Thing from '#thing';
 
 import * as albumClasses from './album.js';
@@ -142,7 +142,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor.propertyDescriptors = results;
+      constructor[CacheableObject.propertyDescriptors] = {
+        ...constructor[CacheableObject.propertyDescriptors] ?? {},
+        ...results,
+      };
     },
 
     showFailedClasses(failedClasses) {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 93ed40b..dbe1ff3 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -2,6 +2,7 @@ import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
+import {logWarn} from '#cli';
 import * as html from '#html';
 import {empty} from '#sugar';
 import {isLanguageCode} from '#validators';
@@ -17,6 +18,8 @@ import {
 
 import {externalFunction, flag, name} from '#composite/wiki-properties';
 
+export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
@@ -60,14 +63,46 @@ export class Language extends Thing {
     strings: {
       flags: {update: true, expose: true},
       update: {validate: (t) => typeof t === 'object'},
+
       expose: {
-        dependencies: ['inheritedStrings'],
-        transform(strings, {inheritedStrings}) {
-          if (strings || inheritedStrings) {
-            return {...(inheritedStrings ?? {}), ...(strings ?? {})};
-          } else {
-            return null;
+        dependencies: ['inheritedStrings', 'code'],
+        transform(strings, {inheritedStrings, code}) {
+          if (!strings && !inheritedStrings) return null;
+          if (!inheritedStrings) return strings;
+
+          const validStrings = {
+            ...inheritedStrings,
+            ...strings,
+          };
+
+          const optionsFromTemplate = template =>
+            Array.from(template.matchAll(languageOptionRegex))
+              .map(({groups}) => groups.name);
+
+          for (const [key, providedTemplate] of Object.entries(strings)) {
+            const inheritedTemplate = inheritedStrings[key];
+            if (!inheritedTemplate) continue;
+
+            const providedOptions = optionsFromTemplate(providedTemplate);
+            const inheritedOptions = optionsFromTemplate(inheritedTemplate);
+
+            const missingOptionNames =
+              inheritedOptions.filter(name => !providedOptions.includes(name));
+
+            const misplacedOptionNames =
+              providedOptions.filter(name => !inheritedOptions.includes(name));
+
+            if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) {
+              logWarn`Not using ${code ?? '(no code)'} string ${key}:`;
+              if (!empty(missingOptionNames))
+                logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
+              if (!empty(misplacedOptionNames))
+                logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+              validStrings[key] = inheritedStrings[key];
+            }
           }
+
+          return validStrings;
         },
       },
     },
@@ -201,7 +236,7 @@ export class Language extends Thing {
     const output = this.#iterateOverTemplate({
       template: this.strings[key],
 
-      match: /{(?<name>[A-Z0-9_]+)}/g,
+      match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
         if (optionsMap.has(optionName)) {
@@ -357,6 +392,7 @@ export class Language extends Thing {
   // contents, if needed.
   #wrapSanitized(content) {
     return html.tags(content, {
+      [html.blessAttributes]: true,
       [html.joinChildren]: '',
       [html.noEdgeWhitespace]: true,
     });
@@ -522,7 +558,7 @@ export class Language extends Thing {
   }
 
   formatExternalLink(url, {
-    style = 'normal',
+    style = 'platform',
     context = 'generic',
   } = {}) {
     if (!this.externalLinkSpec) {
@@ -540,10 +576,16 @@ export class Language extends Thing {
 
     isExternalLinkStyle(style);
 
-    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
-      language: this,
-      context,
-    });
+    const result =
+      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+
+    // It's possible for there to not actually be any string available for the
+    // given URL, style, and context, and we want this to be detectable via
+    // html.blank().
+    return result ?? html.blank();
   }
 
   formatIndex(value) {
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 697dad4..725b1bb 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -13,6 +13,7 @@ import {
   parseAdditionalNames,
   parseContributors,
   parseDate,
+  parseDimensions,
   parseDuration,
 } from '#yaml';
 
@@ -34,6 +35,7 @@ import {
   commentatorArtists,
   contentString,
   contributionList,
+  dimensions,
   directory,
   duration,
   flag,
@@ -158,13 +160,15 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.trackArtDate'}),
     ],
 
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
+      dimensions(),
+    ],
+
     commentary: commentary(),
 
     lyrics: [
-      inheritFromOriginalRelease({
-        property: input.value('lyrics'),
-      }),
-
+      inheritFromOriginalRelease(),
       contentString(),
     ],
 
@@ -189,7 +193,6 @@ export class Track extends Thing {
 
     artistContribs: [
       inheritFromOriginalRelease({
-        property: input.value('artistContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -213,7 +216,6 @@ export class Track extends Thing {
 
     contributorContribs: [
       inheritFromOriginalRelease({
-        property: input.value('contributorContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -248,7 +250,6 @@ export class Track extends Thing {
 
     referencedTracks: [
       inheritFromOriginalRelease({
-        property: input.value('referencedTracks'),
         notFoundValue: input.value([]),
       }),
 
@@ -261,7 +262,6 @@ export class Track extends Thing {
 
     sampledTracks: [
       inheritFromOriginalRelease({
-        property: input.value('sampledTracks'),
         notFoundValue: input.value([]),
       }),
 
@@ -389,6 +389,11 @@ export class Track extends Thing {
 
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Has Cover Art': {
         property: 'disableUniqueCoverArt',
         transform: value =>