« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/composite/control-flow/index.js5
-rw-r--r--src/data/composite/data/excludeFromList.js7
-rw-r--r--src/data/composite/data/fillMissingListItems.js7
-rw-r--r--src/data/composite/data/index.js9
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js6
-rw-r--r--src/data/composite/data/withMappedList.js39
-rw-r--r--src/data/composite/data/withPropertiesFromList.js4
-rw-r--r--src/data/composite/data/withPropertyFromList.js4
-rw-r--r--src/data/composite/data/withSortedList.js126
-rw-r--r--src/data/composite/data/withUnflattenedList.js10
-rw-r--r--src/data/composite/data/withUniqueItemsOnly.js40
-rw-r--r--src/data/composite/things/album/withTrackSections.js4
-rw-r--r--src/data/composite/things/album/withTracks.js4
-rw-r--r--src/data/composite/things/track/index.js2
-rw-r--r--src/data/composite/things/track/inferredAdditionalNameList.js67
-rw-r--r--src/data/composite/things/track/sharedAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/wiki-data/index.js8
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js179
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/commentary.js32
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js58
-rw-r--r--src/data/composite/wiki-properties/index.js6
-rw-r--r--src/data/language.js23
-rw-r--r--src/data/serialize.js4
-rw-r--r--src/data/things/album.js8
-rw-r--r--src/data/things/artist.js17
-rw-r--r--src/data/things/index.js5
-rw-r--r--src/data/things/language.js150
-rw-r--r--src/data/things/static-page.js1
-rw-r--r--src/data/things/track.js20
-rw-r--r--src/data/things/validators.js125
-rw-r--r--src/data/things/wiki-info.js12
-rw-r--r--src/data/yaml.js194
36 files changed, 1285 insertions, 153 deletions
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index dfc53db7..7fad88b2 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -1,3 +1,8 @@
+// #composite/control-flow
+//
+// No entries depend on any other entries, except siblings in this directory.
+//
+
 export {default as exitWithoutDependency} from './exitWithoutDependency.js';
 export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js';
 export {default as exposeConstant} from './exposeConstant.js';
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
index 718f2294..d798dcdc 100644
--- a/src/data/composite/data/excludeFromList.js
+++ b/src/data/composite/data/excludeFromList.js
@@ -6,10 +6,9 @@
 //  - fillMissingListItems
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
index c06eceda..4f818a79 100644
--- a/src/data/composite/data/fillMissingListItems.js
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -5,10 +5,9 @@
 //  - excludeFromList
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index ecd05129..256c0490 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -1,8 +1,17 @@
+// #composite/data
+//
+// Entries here may depend on entries in #composite/control-flow.
+//
+
 export {default as excludeFromList} from './excludeFromList.js';
 export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withFilteredList} from './withFilteredList.js';
 export {default as withFlattenedList} from './withFlattenedList.js';
+export {default as withMappedList} from './withMappedList.js';
 export {default as withPropertiesFromList} from './withPropertiesFromList.js';
 export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+export {default as withSortedList} from './withSortedList.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
new file mode 100644
index 00000000..82e56903
--- /dev/null
+++ b/src/data/composite/data/withFilteredList.js
@@ -0,0 +1,50 @@
+// Applies a filter - an array of truthy and falsy values - to the index-
+// corresponding items in a list. Items which correspond to a truthy value
+// are kept, and the rest are excluded from the output list.
+//
+// TODO: It would be neat to apply an availability check here, e.g. to allow
+// not providing a filter at all and performing the check on the contents of
+// the list (though on the filter, if present, is fine too). But that's best
+// done by some shmancy-fancy mapping support in composite.js, so a bit out
+// of reach for now (apart from proving uses built on top of a more boring
+// implementation).
+//
+// TODO: There should be two outputs - one for the items included according to
+// the filter, and one for the items excluded.
+//
+// See also:
+//  - withMappedList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+      }) => continuation({
+        '#filteredList':
+          list.filter((item, index) => filter[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
index b08edb4e..edfa3403 100644
--- a/src/data/composite/data/withFlattenedList.js
+++ b/src/data/composite/data/withFlattenedList.js
@@ -3,13 +3,13 @@
 // successive source array.
 //
 // See also:
-//  - withFlattenedList
+//  - withUnflattenedList
 //
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withPropertyFromList
-//  - withPropertiesFromList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
new file mode 100644
index 00000000..e0a700b2
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,39 @@
+// Applies a map function to each item in a list, just like a normal JavaScript
+// map.
+//
+// See also:
+//  - withFilteredList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+      }) => continuation({
+        ['#mappedList']:
+          list.map(mapFn),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index 76ba696c..08907bab 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -11,8 +11,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 1983ebbc..a2c66d77 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -12,8 +12,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
new file mode 100644
index 00000000..882907f5
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,126 @@
+// Applies a sort function across pairs of items in a list, just like a normal
+// JavaScript sort. Alongside the sorted results, so are outputted the indices
+// which each item in the unsorted list corresponds to in the sorted one,
+// allowing for the results of this sort to be composed in some more involved
+// operation. For example, using an alphabetical sort, the list ['banana',
+// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well
+// as the indices list [1, 0, 2].
+//
+// If two items are equal (in the eyes of the sort operation), their placement
+// in the sorted list is arbitrary, though every input index will be present in
+// '#sortIndices' exactly once (and equal items will be bunched together).
+//
+// The '#sortIndices' output refers to the "true" index which each source item
+// occupies in the sorted list. This sacrifices information about equal items,
+// which can be obtained through '#unstableSortIndices' instead: each mapped
+// index may appear more than once, and rather than represent exact positions
+// in the sorted list, they represent relational values: if items A and B are
+// mapped to indices 3 and 5, then A certainly is positioned before B (and vice
+// versa); but there may be more than one item in-between. If items C and D are
+// both mapped to index 4, then their position relative to each other is
+// arbitrary - they are equal - but they both certainly appear after item A and
+// before item B.
+//
+// This implementation is based on the one used for sortMultipleArrays.
+//
+// See also:
+//  - withFilteredList
+//  - withMappedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withSortedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    sort: input({type: 'function'}),
+  },
+
+  outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('sort')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('sort')]: sortFn,
+      }) {
+        const symbols =
+          Array.from({length: list.length}, () => Symbol());
+
+        const equalSymbols =
+          new Map();
+
+        const indexMap =
+          new Map(Array.from(symbols,
+            (symbol, index) => [symbol, index]));
+
+        symbols.sort((symbol1, symbol2) => {
+          const comparison =
+            sortFn(
+              list[indexMap.get(symbol1)],
+              list[indexMap.get(symbol2)]);
+
+          if (comparison === 0) {
+            if (equalSymbols.has(symbol1)) {
+              equalSymbols.get(symbol1).add(symbol2);
+            } else {
+              equalSymbols.set(symbol1, new Set([symbol2]));
+            }
+
+            if (equalSymbols.has(symbol2)) {
+              equalSymbols.get(symbol2).add(symbol1);
+            } else {
+              equalSymbols.set(symbol2, new Set([symbol1]));
+            }
+          }
+
+          return comparison;
+        });
+
+        const sortIndices =
+          symbols.map(symbol => indexMap.get(symbol));
+
+        const sortedList =
+          sortIndices.map(index => list[index]);
+
+        const stableToUnstable =
+          symbols
+            .map((symbol, index) =>
+              index > 0 &&
+              equalSymbols.get(symbols[index - 1])?.has(symbol))
+            .reduce((accumulator, collapseEqual) => {
+              if (empty(accumulator)) {
+                accumulator.push(0);
+              } else {
+                const last = accumulator[accumulator.length - 1];
+                if (collapseEqual) {
+                  accumulator.push(last);
+                } else {
+                  accumulator.push(last + 1);
+                }
+              }
+              return accumulator;
+            }, []);
+
+        const unstableSortIndices =
+          sortIndices.map(stable => stableToUnstable[stable]);
+
+        return continuation({
+          ['#sortedList']: sortedList,
+          ['#sortIndices']: sortIndices,
+          ['#unstableSortIndices']: unstableSortIndices,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
index 3cfc247b..39a666dc 100644
--- a/src/data/composite/data/withUnflattenedList.js
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -3,6 +3,16 @@
 // of filtering them out), this function allows for recombining them. It will
 // filter out null and undefined items by default (pass {filter: false} to
 // disable this).
+//
+// See also:
+//  - withFlattenedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
+//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isWholeNumber, validateArrayItems} from '#validators';
diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..7ee08b08
--- /dev/null
+++ b/src/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,40 @@
+// Excludes duplicate items from a list and provides the results, overwriting
+// the list in-place, if possible.
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueItemsOnly`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#uniqueItems'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#values']:
+          unique(list),
+      }),
+    },
+
+    {
+      dependencies: ['#values', input.staticDependency('list')],
+      compute: (continuation, {
+        '#values': values,
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        [list ?? '#uniqueItems']:
+          values,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
index baa3cb4a..679a09fd 100644
--- a/src/data/composite/things/album/withTrackSections.js
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -22,7 +22,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     exitWithoutDependency({
-      dependency: 'trackData',
+      dependency: 'ownTrackData',
       value: input.value([]),
     }),
 
@@ -75,7 +75,7 @@ export default templateCompositeFrom({
 
     withResolvedReferenceList({
       list: '#trackRefs',
-      data: 'trackData',
+      data: 'ownTrackData',
       notFoundMode: input.value('null'),
       find: input.value(find.track),
     }).outputs({
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index dcea6593..fff3d5ae 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -12,7 +12,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     exitWithoutDependency({
-      dependency: 'trackData',
+      dependency: 'ownTrackData',
       value: input.value([]),
     }),
 
@@ -35,7 +35,7 @@ export default templateCompositeFrom({
 
     withResolvedReferenceList({
       list: '#trackRefs',
-      data: 'trackData',
+      data: 'ownTrackData',
       find: input.value(find.track),
     }),
 
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index 3354b1c4..cc723a24 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,5 +1,7 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js';
 export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
+export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js';
 export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
 export {default as withAlbum} from './withAlbum.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js
new file mode 100644
index 00000000..9cf158c6
--- /dev/null
+++ b/src/data/composite/things/track/inferredAdditionalNameList.js
@@ -0,0 +1,67 @@
+// Infers additional name entries from other releases that were titled
+// differently; the corresponding releases are stored in eacn entry's "from"
+// array, which will include multiple items, if more than one other release
+// shares the same name differing from this one's.
+
+import {input, templateCompositeFrom} from '#composite';
+import {chunkByProperties} from '#wiki-data';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withThingsSortedAlphabetically} from '#composite/wiki-data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `inferredAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['#otherReleases.name', 'name'],
+      compute: (continuation, {
+        ['#otherReleases.name']: releaseNames,
+        ['name']: ownName,
+      }) => continuation({
+        ['#nameFilter']:
+          releaseNames.map(name => name !== ownName),
+      }),
+    },
+
+    withFilteredList({
+      list: '#otherReleases',
+      filter: '#nameFilter',
+    }).outputs({
+      '#filteredList': '#differentlyNamedReleases',
+    }),
+
+    withThingsSortedAlphabetically({
+      things: '#differentlyNamedReleases',
+    }).outputs({
+      '#sortedThings': '#differentlyNamedReleases',
+    }),
+
+    {
+      dependencies: ['#differentlyNamedReleases'],
+      compute: ({
+        ['#differentlyNamedReleases']: releases,
+      }) =>
+        chunkByProperties(releases, ['name'])
+          .map(({name, chunk}) => ({name, from: chunk})),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js
new file mode 100644
index 00000000..1806ec80
--- /dev/null
+++ b/src/data/composite/things/track/sharedAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names directly provided by other releases.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `sharedAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('additionalNames'),
+    }),
+
+    withFlattenedList({
+      list: '#otherReleases.additionalNames',
+    }),
+
+    exposeDependency({
+      dependency: '#flattenedList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 00000000..65a2263d
--- /dev/null
+++ b/src/data/composite/things/track/trackAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names from various sources.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isAdditionalNameList} from '#validators';
+
+import withInferredAdditionalNames from './withInferredAdditionalNames.js';
+import withSharedAdditionalNames from './withSharedAdditionalNames.js';
+
+export default templateCompositeFrom({
+  annotation: `trackAdditionalNameList`,
+
+  compose: false,
+
+  update: {validate: isAdditionalNameList},
+
+  steps: () => [
+    withInferredAdditionalNames(),
+    withSharedAdditionalNames(),
+
+    {
+      dependencies: [
+        '#inferredAdditionalNames',
+        '#sharedAdditionalNames',
+        input.updateValue(),
+      ],
+
+      compute: ({
+        ['#inferredAdditionalNames']: inferredAdditionalNames,
+        ['#sharedAdditionalNames']: sharedAdditionalNames,
+        [input.updateValue()]: providedAdditionalNames,
+      }) => [
+        ...providedAdditionalNames ?? [],
+        ...sharedAdditionalNames,
+        ...inferredAdditionalNames,
+      ],
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 1d0400fc..a2ff09d8 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -1,7 +1,15 @@
+// #composite/wiki-data
+//
+// Entries here may depend on entries in #composite/control-flow and in
+// #composite/data.
+//
+
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as inputThingClass} from './inputThingClass.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..edfc9e3c
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,179 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegex} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(commentaryRegex)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#rawMatches.groups',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#entries.body']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches.groups',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+      ]),
+    }),
+
+    // 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),
+    }),
+
+    {
+      dependencies: ['#entries.date'],
+      compute: (continuation, {
+        ['#entries.date']: date,
+      }) => continuation({
+        ['#entries.date']:
+          date.map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.body',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.body']: body,
+      }) => continuation({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..d2487e42
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateWikiData} from '#validators';
+import {compareCaseLessSensitive, normalizeName} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..c5971d4a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,14 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+    expose: {transform: value => value ?? []},
+  };
+}
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
index fbea9d5c..cd6b7ac4 100644
--- a/src/data/composite/wiki-properties/commentary.js
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -1,12 +1,30 @@
 // Artist commentary! Generally present on tracks and albums.
 
+import {input, templateCompositeFrom} from '#composite';
 import {isCommentary} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isCommentary},
-  };
-}
+export default templateCompositeFrom({
+  annotation: `commentary`,
+
+  compose: false,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue({validate: isCommentary}),
+      mode: input.value('falsy'),
+      value: input.value(null),
+    }),
+
+    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 52aeb868..c5c14769 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -1,13 +1,13 @@
-// This one's kinda tricky: it parses artist "references" from the
-// commentary content, and finds the matching artist for each reference.
+// List of artists referenced in commentary entries.
 // This is mostly useful for credits and listings on artist pages.
 
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
-import {unique} from '#sugar';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withResolvedReferenceList} from '#composite/wiki-data';
+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,35 +21,29 @@ export default templateCompositeFrom({
       value: input.value([]),
     }),
 
-    {
-      dependencies: ['commentary'],
-      compute: (continuation, {commentary}) =>
-        continuation({
-          '#artistRefs':
-            Array.from(
-              commentary
-                .replace(/<\/?b>/g, '')
-                .matchAll(/<i>(?<who>.*?):<\/i>/g))
-              .map(({groups: {who}}) => who),
-        }),
-    },
-
-    withResolvedReferenceList({
-      list: '#artistRefs',
-      data: 'artistData',
-      find: input.value(find.artist),
+    withParsedCommentaryEntries({
+      from: 'commentary',
+    }),
+
+    withPropertyFromList({
+      list: '#parsedCommentaryEntries',
+      property: input.value('artists'),
     }).outputs({
-      '#resolvedReferenceList': '#artists',
+      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
-    {
-      flags: {expose: true},
+    withFlattenedList({
+      list: '#artistLists',
+    }).outputs({
+      '#flattenedList': '#artists',
+    }),
 
-      expose: {
-        dependencies: ['#artists'],
-        compute: ({'#artists': artists}) =>
-          unique(artists),
-      },
-    },
+    withUniqueItemsOnly({
+      list: '#artists',
+    }),
+
+    exposeDependency({
+      dependency: '#artists',
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 2462b047..17d51bb8 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -1,4 +1,10 @@
+// #composite/wiki-properties
+//
+// Entries here may depend on entries in #composite/control-flow,
+// #composite/data, and #composite/wiki-data.
+
 export {default as additionalFiles} from './additionalFiles.js';
+export {default as additionalNameList} from './additionalNameList.js';
 export {default as color} from './color.js';
 export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
diff --git a/src/data/language.js b/src/data/language.js
index 6ffc31e0..6f774f27 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -1,23 +1,28 @@
 import EventEmitter from 'node:events';
 import {readFile} from 'node:fs/promises';
 import path from 'node:path';
+import {fileURLToPath} from 'node:url';
 
 import chokidar from 'chokidar';
 import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
-import T from '#things';
+import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-
-import {
-  annotateError,
-  annotateErrorWithFile,
-  showAggregate,
-  withAggregate,
-} from '#sugar';
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#sugar';
+import T from '#things';
 
 const {Language} = T;
 
+export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
+
+export const internalDefaultStringsFile =
+  path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    '../',
+    DEFAULT_STRINGS_FILE);
+
 export function processLanguageSpec(spec, {existingCode = null} = {}) {
   const {
     'meta.languageCode': code,
@@ -105,6 +110,8 @@ export function initializeLanguageObject() {
   language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
 
+  language.externalLinkSpec = externalLinkSpec;
+
   return language;
 }
 
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 52aacb07..8cac3309 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -19,6 +19,10 @@ export function toContribRefs(contribs) {
   return contribs?.map(({who, what}) => ({who: toRef(who), what}));
 }
 
+export function toCommentaryRefs(entries) {
+  return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props}));
+}
+
 // Interface
 
 export const serializeDescriptors = Symbol();
diff --git a/src/data/things/album.js b/src/data/things/album.js
index af3eb042..a95ba354 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -133,7 +133,10 @@ export class Album extends Thing {
       class: input.value(Group),
     }),
 
-    trackData: wikiData({
+    // 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),
     }),
 
@@ -181,7 +184,8 @@ export class Album extends Thing {
     hasTrackArt: S.id,
     isListedOnHomepage: S.id,
 
-    commentary: S.id,
+    commentary: S.toCommentaryRefs,
+
     additionalFiles: S.id,
 
     tracks: S.toRefs,
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index e0350b86..a51723c4 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -107,6 +107,23 @@ export class Artist extends Thing {
     albumsAsBannerArtist:
       Artist.filterByContrib('albumData', 'bannerArtistContribs'),
 
+    albumsAsAny: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Artist.instance]: artist}) =>
+          albumData?.filter((album) =>
+            [
+              ...album.artistContribs,
+              ...album.coverArtistContribs,
+              ...album.wallpaperArtistContribs,
+              ...album.bannerArtistContribs,
+            ].some(({who}) => who === artist)) ?? [],
+      },
+    },
+
     albumsAsCommentator: {
       flags: {expose: true},
 
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 4ea1f007..d1143b0a 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -22,11 +22,6 @@ import * as wikiInfoClasses from './wiki-info.js';
 
 export {default as Thing} from './thing.js';
 
-export {
-  default as CacheableObject,
-  CacheableObjectPropertyValueError,
-} from './cacheable-object.js';
-
 const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 0aeb7ad5..c1916313 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,15 @@
-import {Tag} from '#html';
+import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+
 import {isLanguageCode} from '#validators';
+import {Tag} from '#html';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
 
 import {
   externalFunction,
@@ -72,6 +82,13 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
     // Update only
 
     escapeHTML: externalFunction(),
@@ -269,6 +286,108 @@ export class Language extends Thing {
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    days: numDays = 0,
+    approximate = false,
+  }) {
+    let basis;
+
+    const years = this.countYears(numYears, {unit: true});
+    const months = this.countMonths(numMonths, {unit: true});
+    const days = this.countDays(numDays, {unit: true});
+
+    if (numYears && numMonths && numDays)
+      basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days});
+    else if (numYears && numMonths)
+      basis = this.formatString('count.dateDuration.yearsMonths', {years, months});
+    else if (numYears && numDays)
+      basis = this.formatString('count.dateDuration.yearsDays', {years, days});
+    else if (numYears)
+      basis = this.formatString('count.dateDuration.years', {years});
+    else if (numMonths && numDays)
+      basis = this.formatString('count.dateDuration.monthsDays', {months, days});
+    else if (numMonths)
+      basis = this.formatzString('count.dateDuration.months', {months});
+    else if (numDays)
+      basis = this.formatString('count.dateDuration.days', {days});
+    else
+      return this.formatString('count.dateDuration.zero');
+
+    if (approximate) {
+      return this.formatString('count.dateDuration.approximate', {
+        duration: basis,
+      });
+    } else {
+      return basis;
+    }
+  }
+
+  formatRelativeDate(currentDate, referenceDate, {
+    considerRoundingDays = false,
+    approximate = true,
+    absolute = true,
+  } = {}) {
+    const currentInstant = toTemporalInstant.apply(currentDate);
+    const referenceInstant = toTemporalInstant.apply(referenceDate);
+
+    const comparison =
+      Temporal.Instant.compare(currentInstant, referenceInstant);
+
+    if (comparison === 0) {
+      return this.formatString('count.dateDuration.same');
+    }
+
+    const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC');
+    const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC');
+
+    const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ);
+    const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ);
+
+    const {years, months, days} =
+      laterTDZ.since(earlierTDZ, {
+        largestUnit: 'year',
+        smallestUnit:
+          (considerRoundingDays
+            ? (laterTDZ.since(earlierTDZ, {
+                largestUnit: 'year',
+                smallestUnit: 'day',
+              }).years
+                ? 'month'
+                : 'day')
+            : 'day'),
+        roundingMode: 'halfCeil',
+      });
+
+    const duration =
+      this.formatDateDuration({
+        years, months, days,
+        approximate: false,
+      });
+
+    const relative =
+      this.formatString(
+        'count.dateDuration',
+        (approximate && (years || months || days)
+          ? (comparison === -1
+              ? 'approximateEarlier'
+              : 'approximateLater')
+          : (comparison === -1
+              ? 'earlier'
+              : 'later')),
+        {duration});
+
+    if (absolute) {
+      return this.formatString('count.dateDuration.relativeAbsolute', {
+        relative,
+        absolute: this.formatDate(currentDate),
+      });
+    } else {
+      return relative;
+    }
+  }
+
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
@@ -299,6 +418,31 @@ export class Language extends Thing {
       : duration;
   }
 
+  formatExternalLink(url, {
+    style = 'normal',
+    context = 'generic',
+  } = {}) {
+    if (!this.externalLinkSpec) {
+      throw new TypeError(`externalLinkSpec unavailable`);
+    }
+
+    isExternalLinkContext(context);
+
+    if (style === 'all') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    isExternalLinkStyle(style);
+
+    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      language: this,
+      context,
+    });
+  }
+
   formatIndex(value) {
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
@@ -403,7 +547,11 @@ Object.assign(Language.prototype, {
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countCoverArts: countHelper('coverArts'),
+  countDays: countHelper('days'),
+  countMonths: countHelper('months'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
+  countWeeks: countHelper('weeks'),
+  countYears: countHelper('years'),
 });
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index ab9c5f98..8a3fd10e 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -30,5 +30,6 @@ export class StaticPage extends Thing {
     directory: directory(),
     content: simpleString(),
     stylesheet: simpleString(),
+    script: simpleString(),
   });
 }
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8d310611..e3fe0804 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -24,6 +24,7 @@ import {
 
 import {
   additionalFiles,
+  additionalNameList,
   commentary,
   commentatorArtists,
   contributionList,
@@ -42,7 +43,9 @@ import {
 
 import {
   exitWithoutUniqueCoverArt,
+  inferredAdditionalNameList,
   inheritFromOriginalRelease,
+  sharedAdditionalNameList,
   trackReverseReferenceList,
   withAlbum,
   withAlwaysReferenceByDirectory,
@@ -64,6 +67,10 @@ export class Track extends Thing {
     name: name('Unnamed Track'),
     directory: directory(),
 
+    additionalNames: additionalNameList(),
+    sharedAdditionalNames: sharedAdditionalNameList(),
+    inferredAdditionalNames: inferredAdditionalNameList(),
+
     duration: duration(),
     urls: urls(),
     dateFirstReleased: simpleDate(),
@@ -329,12 +336,21 @@ export class Track extends Thing {
     }
 
     let album;
-    if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) {
+
+    if (depth >= 0) {
+      try {
+        album = this.album;
+      } catch (_error) {}
+
+      album ??= this.dataSourceAlbum;
+    }
+
+    if (album) {
       const albumName = album.name;
       const albumIndex = album.tracks.indexOf(this);
       const trackNum =
         (albumIndex === -1
-          ? '#?'
+          ? 'indeterminate position'
           : `#${albumIndex + 1}`);
       parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
     }
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index f60c363c..ac91b456 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -1,7 +1,12 @@
 import {inspect as nodeInspect} from 'node:util';
 
+// Heresy.
+import printable_characters from 'printable-characters';
+const {strlen} = printable_characters;
+
 import {colors, ENABLE_COLOR} from '#cli';
-import {empty, typeAppearance, withAggregate} from '#sugar';
+import {cut, empty, typeAppearance, withAggregate} from '#sugar';
+import {commentaryRegex} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -96,7 +101,10 @@ export function isStringNonEmpty(value) {
 }
 
 export function optional(validator) {
-  return value => value === null || value === undefined || validator(value);
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
 }
 
 // Complex types (non-primitives)
@@ -166,29 +174,42 @@ export function is(...values) {
 }
 
 function validateArrayItemsHelper(itemValidator) {
-  return (item, index) => {
+  return (item, index, array) => {
     try {
-      const value = itemValidator(item);
+      const value = itemValidator(item, index, array);
 
       if (value !== true) {
         throw new Error(`Expected validator to return true`);
       }
     } catch (error) {
-      error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`;
+      const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`;
+
+      error.message =
+        (error.message.includes('\n') || strlen(annotation) > 20
+          ? annotation + '\n' +
+            error.message
+              .split('\n')
+              .map(line => `  ${line}`)
+              .join('\n')
+          : `${annotation} ${error}`);
+
       error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index;
+
       throw error;
     }
   };
 }
 
 export function validateArrayItems(itemValidator) {
-  const fn = validateArrayItemsHelper(itemValidator);
+  const helper = validateArrayItemsHelper(itemValidator);
 
   return (array) => {
     isArray(array);
 
-    withAggregate({message: 'Errors validating array items'}, ({wrap}) => {
-      array.forEach(wrap(fn));
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
     });
 
     return true;
@@ -200,12 +221,12 @@ export function strictArrayOf(itemValidator) {
 }
 
 export function sparseArrayOf(itemValidator) {
-  return validateArrayItems(item => {
+  return validateArrayItems((item, index, array) => {
     if (item === false || item === null) {
       return true;
     }
 
-    return itemValidator(item);
+    return itemValidator(item, index, array);
   });
 }
 
@@ -231,18 +252,56 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentary) {
-  isString(commentary);
+export function isCommentary(commentaryText) {
+  isString(commentaryText);
 
-  const [firstLine] = commentary.match(/.*/);
-  if (!firstLine.replace(/<\/b>/g, '').includes(':</i>')) {
-    throw new TypeError(`Missing commentary citation: "${
-      firstLine.length > 40
-        ? firstLine.slice(0, 40) + '...'
-        : firstLine
-    }"`);
+  const rawMatches =
+    Array.from(commentaryText.matchAll(commentaryRegex));
+
+  if (empty(rawMatches)) {
+    throw new TypeError(`Expected at least one commentary heading`);
   }
 
+  const niceMatches =
+    rawMatches.map(match => ({
+      position: match.index,
+      length: match[0].length,
+    }));
+
+  validateArrayItems(({position, length}, index) => {
+    if (index === 0 && position > 0) {
+      throw new TypeError(`Expected first commentary heading to be at top`);
+    }
+
+    const ownInput = commentaryText.slice(position, position + length);
+    const restOfInput = commentaryText.slice(position + length);
+    const nextLineBreak = restOfInput.indexOf('\n');
+    const upToNextLineBreak = restOfInput.slice(0, nextLineBreak);
+
+    if (/\S/.test(upToNextLineBreak)) {
+      throw new TypeError(
+        `Expected commentary heading to occupy entire line, got extra text:\n` +
+        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+    }
+
+    const nextHeading =
+      (index === niceMatches.length - 1
+        ? commentaryText.length
+        : niceMatches[index + 1].position);
+
+    const upToNextHeading =
+      commentaryText.slice(position + length, nextHeading);
+
+    if (!/\S/.test(upToNextHeading)) {
+      throw new TypeError(
+        `Expected commentary entry to have body text, only got a heading`);
+    }
+
+    return true;
+  })(niceMatches);
+
   return true;
 }
 
@@ -285,20 +344,14 @@ export function validateProperties(spec) {
 
 export const isContribution = validateProperties({
   who: isArtistRef,
-  what: (value) =>
-    value === undefined ||
-    value === null ||
-    isStringNonEmpty(value),
+  what: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
   title: isString,
-  description: (value) =>
-    value === undefined ||
-    value === null ||
-    isString(value),
+  description: optional(isStringNonEmpty),
   files: validateArrayItems(isString),
 });
 
@@ -497,6 +550,24 @@ export function validateWikiData({
   };
 }
 
+export const isAdditionalName = validateProperties({
+  name: isName,
+  annotation: optional(isStringNonEmpty),
+
+  // TODO: This only allows indicating sourcing from a track.
+  // That's okay for the current limited use of "from", but
+  // could be expanded later.
+  from:
+    // Double TODO: Explicitly allowing both references and
+    // live objects to co-exist is definitely weird, and
+    // altogether questions the way we define validators...
+    optional(oneOf(
+      validateReferenceList('track'),
+      validateWikiData({referenceType: 'track'}))),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 // Compositional utilities
 
 export function oneOf(...checks) {
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 89053d62..3db9727b 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,9 +1,8 @@
 import {input} from '#composite';
 import find from '#find';
-import {isLanguageCode, isName, isURL} from '#validators';
+import {isColor, isLanguageCode, isName, isURL} from '#validators';
 
 import {
-  color,
   flag,
   name,
   referenceList,
@@ -32,7 +31,14 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: color(),
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
 
     // One-line description used for <meta rel="description"> tag.
     description: simpleString(),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 1d35bae8..82b7faf2 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -7,15 +7,13 @@ import {inspect as nodeInspect} from 'node:util';
 
 import yaml from 'js-yaml';
 
+import CacheableObject, {CacheableObjectPropertyValueError}
+  from '#cacheable-object';
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import find, {bindFind} from '#find';
 import {traverse} from '#node-utils';
 
-import T, {
-  CacheableObject,
-  CacheableObjectPropertyValueError,
-  Thing,
-} from '#things';
+import T, {Thing} from '#things';
 
 import {
   annotateErrorWithFile,
@@ -23,6 +21,7 @@ import {
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   empty,
+  filterAggregate,
   filterProperties,
   openAggregate,
   showAggregate,
@@ -30,6 +29,7 @@ import {
 } from '#sugar';
 
 import {
+  commentaryRegex,
   sortAlbumsTracksChronologically,
   sortAlphabetically,
   sortChronologically,
@@ -38,8 +38,8 @@ import {
 
 // --> General supporting stuff
 
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
 // --> YAML data repository structure constants
@@ -308,7 +308,12 @@ export class FieldCombinationError extends Error {
   constructor(fields, message) {
     const fieldNames = Object.keys(fields);
 
-    const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`;
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
@@ -329,8 +334,15 @@ export class FieldCombinationError extends Error {
 }
 
 export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
   constructor(thingConstructor, errors) {
-    super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`);
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
   }
 }
 
@@ -341,8 +353,17 @@ export class FieldValueError extends Error {
         ? caughtError.cause
         : caughtError);
 
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const propertyText =
+      colors.green(property);
+
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
     super(
-      `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`,
+      `Failed to set ${fieldText} field (${propertyText}) to ${valueText}`,
       {cause});
   }
 }
@@ -354,13 +375,18 @@ export class SkippedFieldsSummaryError extends Error {
     const lines =
       entries.map(([field, value]) =>
         ` - ${field}: ` +
-        inspect(value)
+        inspect(value, {maxStringLength: 70})
           .split('\n')
           .map((line, index) => index === 0 ? line : `   ${line}`)
           .join('\n'));
 
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
@@ -436,6 +462,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel
 
 export const processTrackDocument = makeProcessDocument(T.Track, {
   fieldTransformations: {
+    'Additional Names': parseAdditionalNames,
     'Duration': parseDuration,
 
     'Date First Released': (value) => new Date(value),
@@ -457,6 +484,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
   propertyFieldMapping: {
     name: 'Track',
     directory: 'Directory',
+    additionalNames: 'Additional Names',
     duration: 'Duration',
     color: 'Color',
     urls: 'URLs',
@@ -618,6 +646,7 @@ export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
     directory: 'Directory',
 
     stylesheet: 'Style',
+    script: 'Script',
     content: 'Content',
   },
 });
@@ -717,26 +746,52 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseContributors(contributors) {
+const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export function parseContributors(contributionStrings) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributors)) {
-    return contributors;
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
   }
 
-  contributors = contributors.map((contrib) => {
-    if (typeof contrib !== 'string') return contrib;
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-    if (!match) return contrib;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    const who = match[1];
-    const what = match[3] || null;
-    return {who, what};
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
+
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
+
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
-  return contributors;
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
 function parseDimensions(string) {
@@ -881,6 +936,7 @@ export const dataSteps = [
         // 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 = {
           name: `Default Track Section`,
@@ -915,13 +971,16 @@ export const dataSteps = [
 
           entry.dataSourceAlbum = albumRef;
 
+          ownTrackData.push(entry);
           currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
 
-        album.trackSections = trackSections;
         albumData.push(album);
+
+        album.trackSections = trackSections;
+        album.ownTrackData = ownTrackData;
       }
 
       return {albumData, trackData};
@@ -1138,7 +1197,10 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
   for (const dataStep of dataSteps) {
     await processDataAggregate.nestAsync(
-      {message: `Errors during data step: ${colors.bright(dataStep.title)}`},
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
       async ({call, callAsync, map, mapAsync, push}) => {
         const {documentMode} = dataStep;
 
@@ -1383,7 +1445,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
         switch (documentMode) {
           case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`},
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
               decorateErrorWithFile(({documents}) => {
                 const headerDocument = documents[0];
                 const entryDocuments = documents.slice(1).filter(Boolean);
@@ -1493,7 +1555,7 @@ export function linkWikiDataArrays(wikiData, {
 
   assignWikiData([WD.wikiInfo], 'groupData');
 
-  assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData');
+  assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData');
   assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData');
   assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData');
   assignWikiData(WD.groupData, 'albumData', 'groupCategoryData');
@@ -1567,8 +1629,7 @@ export function filterDuplicateDirectories(wikiData) {
         call(() => {
           throw new Error(
             `Duplicate directory ${colors.green(directory)}:\n` +
-              places.map((thing) => ` - ` + inspect(thing)).join('\n')
-          );
+            places.map(thing => ` - ` + inspect(thing)).join('\n'));
         });
       }
 
@@ -1618,6 +1679,7 @@ export function filterReferenceErrors(wikiData) {
       bannerArtistContribs: '_contrib',
       groups: 'group',
       artTags: 'artTag',
+      commentary: '_commentary',
     }],
 
     ['trackData', processTrackDocument, {
@@ -1628,6 +1690,7 @@ export function filterReferenceErrors(wikiData) {
       sampledTracks: '_trackNotRerelease',
       artTags: 'artTag',
       originalReleaseTrack: '_trackNotRerelease',
+      commentary: '_commentary',
     }],
 
     ['groupCategoryData', processGroupCategoryDocument, {
@@ -1677,7 +1740,21 @@ export function filterReferenceErrors(wikiData) {
 
         nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
-            const value = CacheableObject.getUpdateValue(thing, property);
+            let value = CacheableObject.getUpdateValue(thing, property);
+            let writeProperty = true;
+
+            switch (findFnKey) {
+              case '_commentary':
+                if (value) {
+                  value =
+                    Array.from(value.matchAll(commentaryRegex))
+                      .map(({groups}) => groups.artistReferences)
+                      .map(text => text.split(',').map(text => text.trim()));
+                }
+
+                writeProperty = false;
+                break;
+            }
 
             if (value === undefined) {
               push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -1690,19 +1767,25 @@ export function filterReferenceErrors(wikiData) {
 
             let findFn;
 
+            const findArtistOrAlias = artistRef => {
+              const alias = find.artist(artistRef, wikiData.artistAliasData, {mode: 'quiet'});
+              if (alias) {
+                // No need to check if the original exists here. Aliases are automatically
+                // created from a field on the original, so the original certainly exists.
+                const original = alias.aliasedArtist;
+                throw new Error(`Reference ${colors.red(artistRef)} is to an alias, should be ${colors.green(original.name)}`);
+              }
+
+              return boundFind.artist(artistRef);
+            };
+
             switch (findFnKey) {
-              case '_contrib':
-                findFn = contribRef => {
-                  const alias = find.artist(contribRef.who, wikiData.artistAliasData, {mode: 'quiet'});
-                  if (alias) {
-                    // No need to check if the original exists here. Aliases are automatically
-                    // created from a field on the original, so the original certainly exists.
-                    const original = alias.aliasedArtist;
-                    throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`);
-                  }
+              case '_commentary':
+                findFn = findArtistOrAlias;
+                break;
 
-                  return boundFind.artist(contribRef.who);
-                };
+              case '_contrib':
+                findFn = contribRef => findArtistOrAlias(contribRef.who);
                 break;
 
               case '_homepageSourceGroup':
@@ -1783,22 +1866,39 @@ export function filterReferenceErrors(wikiData) {
                 ? `Reference errors` + fieldPropertyMessage + findFnMessage
                 : `Reference error` + fieldPropertyMessage + findFnMessage);
 
-            if (Array.isArray(value)) {
-              thing[property] = filter(
-                value,
-                decorateErrorWithIndex(suppress(findFn)),
-                {message: errorMessage});
+            let newPropertyValue = value;
+
+            if (findFnKey === '_commentary') {
+              // Commentary doesn't write a property value, so no need to set.
+              filter(
+                value, {message: errorMessage},
+                decorateErrorWithIndex(refs =>
+                  (refs.length === 1
+                    ? suppress(findFn)(refs[0])
+                    : filterAggregate(
+                        refs, {message: `Errors in entry's artist references`},
+                        decorateErrorWithIndex(suppress(findFn)))
+                          .aggregate
+                          .close())));
+            } else if (Array.isArray(value)) {
+              newPropertyValue = filter(
+                value, {message: errorMessage},
+                decorateErrorWithIndex(suppress(findFn)));
             } else {
               nest({message: errorMessage},
                 suppress(({call}) => {
                   try {
                     call(findFn, value);
                   } catch (error) {
-                    thing[property] = null;
+                    newPropertyValue = null;
                     throw error;
                   }
                 }));
             }
+
+            if (writeProperty) {
+              thing[property] = newPropertyValue;
+            }
           }
         });
       }