« get me outta code hell

data, test: refactor utilities into own file - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-10-01 17:01:21 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-10-01 17:04:16 -0300
commitab7591e45e7e31b4e2c0e2f81e224672145993fa (patch)
tree11dcccc57e71424baa3b73a3eca58dabc56dca05
parentdfcf911501211bbfc64b8ce6a964b70c6406447f (diff)
data, test: refactor utilities into own file
Primarily for more precies test coverage mapping, but also to make
navigation a bit easier and consolidate complex functions with
lots of imports out of the same space as other, more simple or
otherwise specialized files.
-rw-r--r--coverage-map.js9
-rw-r--r--package.json6
-rw-r--r--src/data/composite/control-flow/exitWithoutDependency.js35
-rw-r--r--src/data/composite/control-flow/exitWithoutUpdateValue.js24
-rw-r--r--src/data/composite/control-flow/exposeConstant.js26
-rw-r--r--src/data/composite/control-flow/exposeDependency.js28
-rw-r--r--src/data/composite/control-flow/exposeDependencyOrContinue.js34
-rw-r--r--src/data/composite/control-flow/exposeUpdateValueOrContinue.js40
-rw-r--r--src/data/composite/control-flow/index.js9
-rw-r--r--src/data/composite/control-flow/inputAvailabilityCheckMode.js9
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutDependency.js39
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js47
-rw-r--r--src/data/composite/control-flow/withResultOfAvailabilityCheck.js66
-rw-r--r--src/data/composite/data/excludeFromList.js56
-rw-r--r--src/data/composite/data/fillMissingListItems.js51
-rw-r--r--src/data/composite/data/index.js8
-rw-r--r--src/data/composite/data/withFlattenedList.js47
-rw-r--r--src/data/composite/data/withPropertiesFromList.js92
-rw-r--r--src/data/composite/data/withPropertiesFromObject.js87
-rw-r--r--src/data/composite/data/withPropertyFromList.js56
-rw-r--r--src/data/composite/data/withPropertyFromObject.js69
-rw-r--r--src/data/composite/data/withUnflattenedList.js62
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withTrackSections.js119
-rw-r--r--src/data/composite/things/album/withTracks.js51
-rw-r--r--src/data/composite/things/track/exitWithoutUniqueCoverArt.js26
-rw-r--r--src/data/composite/things/track/index.js9
-rw-r--r--src/data/composite/things/track/inheritFromOriginalRelease.js43
-rw-r--r--src/data/composite/things/track/trackReverseReferenceList.js38
-rw-r--r--src/data/composite/things/track/withAlbum.js57
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js52
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js63
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js61
-rw-r--r--src/data/composite/things/track/withOriginalRelease.js59
-rw-r--r--src/data/composite/things/track/withOtherReleases.js40
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js49
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js47
-rw-r--r--src/data/composite/wiki-data/index.js7
-rw-r--r--src/data/composite/wiki-data/inputThingClass.js23
-rw-r--r--src/data/composite/wiki-data/inputWikiData.js17
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js77
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js73
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js101
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js40
-rw-r--r--src/data/composite/wiki-properties/additionalFiles.js30
-rw-r--r--src/data/composite/wiki-properties/color.js12
-rw-r--r--src/data/composite/wiki-properties/commentary.js12
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js55
-rw-r--r--src/data/composite/wiki-properties/contribsPresent.js30
-rw-r--r--src/data/composite/wiki-properties/contributionList.js35
-rw-r--r--src/data/composite/wiki-properties/dimensions.js13
-rw-r--r--src/data/composite/wiki-properties/directory.js23
-rw-r--r--src/data/composite/wiki-properties/duration.js13
-rw-r--r--src/data/composite/wiki-properties/externalFunction.js11
-rw-r--r--src/data/composite/wiki-properties/fileExtension.js13
-rw-r--r--src/data/composite/wiki-properties/flag.js19
-rw-r--r--src/data/composite/wiki-properties/index.js20
-rw-r--r--src/data/composite/wiki-properties/name.js11
-rw-r--r--src/data/composite/wiki-properties/referenceList.js47
-rw-r--r--src/data/composite/wiki-properties/reverseReferenceList.js30
-rw-r--r--src/data/composite/wiki-properties/simpleDate.js14
-rw-r--r--src/data/composite/wiki-properties/simpleString.js14
-rw-r--r--src/data/composite/wiki-properties/singleReference.js47
-rw-r--r--src/data/composite/wiki-properties/urls.js14
-rw-r--r--src/data/composite/wiki-properties/wikiData.js17
-rw-r--r--src/data/things/album.js157
-rw-r--r--src/data/things/art-tag.js10
-rw-r--r--src/data/things/artist.js6
-rw-r--r--src/data/things/composite.js727
-rw-r--r--src/data/things/flash.js6
-rw-r--r--src/data/things/group.js6
-rw-r--r--src/data/things/homepage-layout.js16
-rw-r--r--src/data/things/language.js9
-rw-r--r--src/data/things/news-entry.js6
-rw-r--r--src/data/things/static-page.js6
-rw-r--r--src/data/things/thing.js713
-rw-r--r--src/data/things/track.js471
-rw-r--r--src/data/things/wiki-info.js6
-rw-r--r--test/unit/data/composite/control-flow/exposeConstant.js (renamed from test/unit/data/composite/common-utilities/exposeConstant.js)8
-rw-r--r--test/unit/data/composite/control-flow/exposeDependency.js (renamed from test/unit/data/composite/common-utilities/exposeDependency.js)8
-rw-r--r--test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js (renamed from test/unit/data/composite/common-utilities/withResultOfAvailabilityCheck.js)8
-rw-r--r--test/unit/data/composite/data/withPropertiesFromObject.js (renamed from test/unit/data/composite/common-utilities/withPropertiesFromObject.js)9
-rw-r--r--test/unit/data/composite/data/withPropertyFromObject.js (renamed from test/unit/data/composite/common-utilities/withPropertyFromObject.js)9
83 files changed, 2536 insertions, 2079 deletions
diff --git a/coverage-map.js b/coverage-map.js
index 8bbf3057..beff9e8a 100644
--- a/coverage-map.js
+++ b/coverage-map.js
@@ -22,14 +22,9 @@ export default function map(F) {
   if (match) {
     const f = match[1];
 
-    match = f.match(/^composite\/(.*?)\//);
+    match = f.match(/^composite\/(.*)$/);
     if (match) {
-      switch (match[1]) {
-        case 'common-utilities':
-          return `src/data/things/composite.js`;
-        default:
-          return null;
-      }
+      return `src/data/composite/${match[1]}`;
     }
 
     match = f.match(/^things\/(.*)\.js$/);
diff --git a/package.json b/package.json
index 535405a2..16261f92 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,12 @@
     "imports": {
         "#colors": "./src/util/colors.js",
         "#composite": "./src/data/things/composite.js",
+        "#composite/control-flow": "./src/data/composite/control-flow/index.js",
+        "#composite/data": "./src/data/composite/data/index.js",
+        "#composite/wiki-data": "./src/data/composite/wiki-data/index.js",
+        "#composite/wiki-properties": "./src/data/composite/wiki-properties/index.js",
+        "#composite/things/album": "./src/data/composite/things/album/index.js",
+        "#composite/things/track": "./src/data/composite/things/track/index.js",
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
         "#cli": "./src/util/cli.js",
diff --git a/src/data/composite/control-flow/exitWithoutDependency.js b/src/data/composite/control-flow/exitWithoutDependency.js
new file mode 100644
index 00000000..c660a7ef
--- /dev/null
+++ b/src/data/composite/control-flow/exitWithoutDependency.js
@@ -0,0 +1,35 @@
+// Early exits if a dependency isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutDependency`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('value')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('value')]: value,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.exit(value)),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exitWithoutUpdateValue.js b/src/data/composite/control-flow/exitWithoutUpdateValue.js
new file mode 100644
index 00000000..244b3233
--- /dev/null
+++ b/src/data/composite/control-flow/exitWithoutUpdateValue.js
@@ -0,0 +1,24 @@
+// Early exits if this property's update value isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import exitWithoutDependency from './exitWithoutDependency.js';
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutUpdateValue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input('mode'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeConstant.js b/src/data/composite/control-flow/exposeConstant.js
new file mode 100644
index 00000000..e0435478
--- /dev/null
+++ b/src/data/composite/control-flow/exposeConstant.js
@@ -0,0 +1,26 @@
+// Exposes a constant value exactly as it is; like exposeDependency, this
+// is typically the base of a composition serving as a particular property
+// descriptor. It generally follows steps which will conditionally early
+// exit with some other value, with the exposeConstant base serving as the
+// fallback default value.
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `exposeConstant`,
+
+  compose: false,
+
+  inputs: {
+    value: input.staticValue(),
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('value')],
+      compute: ({
+        [input('value')]: value,
+      }) => value,
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeDependency.js b/src/data/composite/control-flow/exposeDependency.js
new file mode 100644
index 00000000..3aa3d03a
--- /dev/null
+++ b/src/data/composite/control-flow/exposeDependency.js
@@ -0,0 +1,28 @@
+// Exposes a dependency exactly as it is; this is typically the base of a
+// composition which was created to serve as one property's descriptor.
+//
+// Please note that this *doesn't* verify that the dependency exists, so
+// if you provide the wrong name or it hasn't been set by a previous
+// compositional step, the property will be exposed as undefined instead
+// of null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `exposeDependency`,
+
+  compose: false,
+
+  inputs: {
+    dependency: input.staticDependency({acceptsNull: true}),
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('dependency')],
+      compute: ({
+        [input('dependency')]: dependency
+      }) => dependency,
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeDependencyOrContinue.js b/src/data/composite/control-flow/exposeDependencyOrContinue.js
new file mode 100644
index 00000000..0f7f223e
--- /dev/null
+++ b/src/data/composite/control-flow/exposeDependencyOrContinue.js
@@ -0,0 +1,34 @@
+// Exposes a dependency as it is, or continues if it's unavailable.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeDependencyOrContinue`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('dependency')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('dependency')]: dependency,
+      }) =>
+        (availability
+          ? continuation.exit(dependency)
+          : continuation()),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeUpdateValueOrContinue.js b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js
new file mode 100644
index 00000000..1f94b332
--- /dev/null
+++ b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js
@@ -0,0 +1,40 @@
+// Exposes the update value of an {update: true} property as it is,
+// or continues if it's unavailable.
+//
+// See withResultOfAvailabilityCheck for {mode} options.
+//
+// Provide {validate} here to conveniently set a custom validation check
+// for this property's update value.
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import exposeDependencyOrContinue from './exposeDependencyOrContinue.js';
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeUpdateValueOrContinue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+
+    validate: input({
+      type: 'function',
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('validate')]: validate,
+  }) =>
+    (validate
+      ? {validate}
+      : {}),
+
+  steps: () => [
+    exposeDependencyOrContinue({
+      dependency: input.updateValue(),
+      mode: input('mode'),
+    }),
+  ],
+});
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
new file mode 100644
index 00000000..dfc53db7
--- /dev/null
+++ b/src/data/composite/control-flow/index.js
@@ -0,0 +1,9 @@
+export {default as exitWithoutDependency} from './exitWithoutDependency.js';
+export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js';
+export {default as exposeConstant} from './exposeConstant.js';
+export {default as exposeDependency} from './exposeDependency.js';
+export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
+export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
+export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
+export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
+export {default as withResultOfAvailabilityCheck} from './withResultOfAvailabilityCheck.js';
diff --git a/src/data/composite/control-flow/inputAvailabilityCheckMode.js b/src/data/composite/control-flow/inputAvailabilityCheckMode.js
new file mode 100644
index 00000000..d74a1149
--- /dev/null
+++ b/src/data/composite/control-flow/inputAvailabilityCheckMode.js
@@ -0,0 +1,9 @@
+import {input} from '#composite';
+import {is} from '#validators';
+
+export default function inputAvailabilityCheckMode() {
+  return input({
+    validate: is('null', 'empty', 'falsy'),
+    defaultValue: 'null',
+  });
+}
diff --git a/src/data/composite/control-flow/raiseOutputWithoutDependency.js b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
new file mode 100644
index 00000000..03d8036a
--- /dev/null
+++ b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
@@ -0,0 +1,39 @@
+// Raises if a dependency isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `raiseOutputWithoutDependency`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+    output: input.staticValue({defaultValue: {}}),
+  },
+
+  outputs: ({
+    [input.staticValue('output')]: output,
+  }) => Object.keys(output),
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('output')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('output')]: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutputAbove(output)),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
new file mode 100644
index 00000000..3c39f5ba
--- /dev/null
+++ b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
@@ -0,0 +1,47 @@
+// Raises if this property's update value isn't available.
+// See withResultOfAvailabilityCheck for {mode} options!
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `raiseOutputWithoutUpdateValue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+    output: input.staticValue({defaultValue: {}}),
+  },
+
+  outputs: ({
+    [input.staticValue('output')]: output,
+  }) => Object.keys(output),
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input.updateValue(),
+      mode: input('mode'),
+    }),
+
+    // TODO: A bit of a kludge, below. Other "do something with the update
+    // value" type functions can get by pretty much just passing that value
+    // as an input (input.updateValue()) into the corresponding "do something
+    // with a dependency/arbitrary value" function. But we can't do that here,
+    // because the special behavior, raiseOutputAbove(), only works to raise
+    // output above the composition it's *directly* nested in. Other languages
+    // have a throw/catch system that might serve as inspiration for something
+    // better here.
+
+    {
+      dependencies: ['#availability', input('output')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('output')]: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutputAbove(output)),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
new file mode 100644
index 00000000..bcbd0b37
--- /dev/null
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -0,0 +1,66 @@
+// Checks the availability of a dependency and provides the result to later
+// steps under '#availability' (by default). This is mainly intended for use
+// by the more specific utilities, which you should consider using instead.
+//
+// Customize {mode} to select one of these modes, or default to 'null':
+//
+// * 'null':  Check that the value isn't null (and not undefined either).
+// * 'empty': Check that the value is neither null, undefined, nor an empty
+//            array.
+// * 'falsy': Check that the value isn't false when treated as a boolean
+//            (nor an empty array). Keep in mind this will also be false
+//            for values like zero and the empty string!
+//
+// See also:
+//  - exitWithoutDependency
+//  - exitWithoutUpdateValue
+//  - exposeDependencyOrContinue
+//  - exposeUpdateValueOrContinue
+//  - raiseOutputWithoutDependency
+//  - raiseOutputWithoutUpdateValue
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+export default templateCompositeFrom({
+  annotation: `withResultOfAvailabilityCheck`,
+
+  inputs: {
+    from: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  outputs: ['#availability'],
+
+  steps: () => [
+    {
+      dependencies: [input('from'), input('mode')],
+
+      compute: (continuation, {
+        [input('from')]: value,
+        [input('mode')]: mode,
+      }) => {
+        let availability;
+
+        switch (mode) {
+          case 'null':
+            availability = value !== undefined && value !== null;
+            break;
+
+          case 'empty':
+            availability = value !== undefined && !empty(value);
+            break;
+
+          case 'falsy':
+            availability = !!value && (!Array.isArray(value) || !empty(value));
+            break;
+        }
+
+        return continuation({'#availability': availability});
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
new file mode 100644
index 00000000..718f2294
--- /dev/null
+++ b/src/data/composite/data/excludeFromList.js
@@ -0,0 +1,56 @@
+// Filters particular values out of a list. Note that this will always
+// completely skip over null, but can be used to filter out any other
+// primitive or object value.
+//
+// See also:
+//  - fillMissingListItems
+//
+// More list utilities:
+//  - withFlattenedList
+//  - withPropertyFromList
+//  - withPropertiesFromList
+//  - withUnflattenedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `excludeFromList`,
+
+  inputs: {
+    list: input(),
+
+    item: input({defaultValue: null}),
+    items: input({type: 'array', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#list'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+        input('item'),
+        input('items'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listName,
+        [input('list')]: listContents,
+        [input('item')]: excludeItem,
+        [input('items')]: excludeItems,
+      }) => continuation({
+        [listName ?? '#list']:
+          listContents.filter(item => {
+            if (excludeItem !== null && item === excludeItem) return false;
+            if (!empty(excludeItems) && excludeItems.includes(item)) return false;
+            return true;
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
new file mode 100644
index 00000000..c06eceda
--- /dev/null
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -0,0 +1,51 @@
+// Replaces items of a list, which are null or undefined, with some fallback
+// value. By default, this replaces the passed dependency.
+//
+// See also:
+//  - excludeFromList
+//
+// More list utilities:
+//  - withFlattenedList
+//  - withPropertyFromList
+//  - withPropertiesFromList
+//  - withUnflattenedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `fillMissingListItems`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    fill: input({acceptsNull: true}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#list'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('fill')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('fill')]: fill,
+      }) => continuation({
+        ['#filled']:
+          list.map(item => item ?? fill),
+      }),
+    },
+
+    {
+      dependencies: [input.staticDependency('list'), '#filled'],
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        ['#filled']: filled,
+      }) => continuation({
+        [list ?? '#list']:
+          filled,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
new file mode 100644
index 00000000..ecd05129
--- /dev/null
+++ b/src/data/composite/data/index.js
@@ -0,0 +1,8 @@
+export {default as excludeFromList} from './excludeFromList.js';
+export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withFlattenedList} from './withFlattenedList.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 withUnflattenedList} from './withUnflattenedList.js';
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
new file mode 100644
index 00000000..b08edb4e
--- /dev/null
+++ b/src/data/composite/data/withFlattenedList.js
@@ -0,0 +1,47 @@
+// Flattens an array with one level of nested arrays, providing as dependencies
+// both the flattened array as well as the original starting indices of each
+// successive source array.
+//
+// See also:
+//  - withFlattenedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withPropertyFromList
+//  - withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFlattenedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ['#flattenedList', '#flattenedIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute(continuation, {
+        [input('list')]: sourceList,
+      }) {
+        const flattenedList = sourceList.flat();
+        const indices = [];
+        let lastEndIndex = 0;
+        for (const {length} of sourceList) {
+          indices.push(lastEndIndex);
+          lastEndIndex += length;
+        }
+
+        return continuation({
+          ['#flattenedList']: flattenedList,
+          ['#flattenedIndices']: indices,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
new file mode 100644
index 00000000..76ba696c
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -0,0 +1,92 @@
+// Gets the listed properties from each of a list of objects, providing lists
+// of property values each into a dependency prefixed with the same name as the
+// list (by default).
+//
+// Like withPropertyFromList, this doesn't alter indices.
+//
+// See also:
+//  - withPropertiesFromObject
+//  - withPropertyFromList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList
+//  - withUnflattenedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isString, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withPropertiesFromList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+
+    properties: input({
+      validate: validateArrayItems(isString),
+    }),
+
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+    [input.staticValue('properties')]: properties,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    (properties
+      ? properties.map(property =>
+          (prefix
+            ? `${prefix}.${property}`
+         : list
+            ? `${list}.${property}`
+            : `#list.${property}`))
+      : ['#lists']),
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('properties')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('properties')]: properties,
+      }) => continuation({
+        ['#lists']:
+          Object.fromEntries(
+            properties.map(property => [
+              property,
+              list.map(item => item[property] ?? null),
+            ])),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input.staticValue('properties'),
+        input.staticValue('prefix'),
+        '#lists',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        [input.staticValue('properties')]: properties,
+        [input.staticValue('prefix')]: prefix,
+        ['#lists']: lists,
+      }) =>
+        (properties
+          ? continuation(
+              Object.fromEntries(
+                properties.map(property => [
+                  (prefix
+                    ? `${prefix}.${property}`
+                 : list
+                    ? `${list}.${property}`
+                    : `#list.${property}`),
+                  lists[property],
+                ])))
+          : continuation({'#lists': lists})),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromObject.js b/src/data/composite/data/withPropertiesFromObject.js
new file mode 100644
index 00000000..21726b58
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromObject.js
@@ -0,0 +1,87 @@
+// Gets the listed properties from some object, providing each property's value
+// as a dependency prefixed with the same name as the object (by default).
+// If the object itself is null, all provided dependencies will be null;
+// if it's missing only select properties, those will be provided as null.
+//
+// See also:
+//  - withPropertiesFromList
+//  - withPropertyFromObject
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isString, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withPropertiesFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+
+    properties: input({
+      type: 'array',
+      validate: validateArrayItems(isString),
+    }),
+
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('object')]: object,
+    [input.staticValue('properties')]: properties,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    (properties
+      ? properties.map(property =>
+          (prefix
+            ? `${prefix}.${property}`
+         : object
+            ? `${object}.${property}`
+            : `#object.${property}`))
+      : ['#object']),
+
+  steps: () => [
+    {
+      dependencies: [input('object'), input('properties')],
+      compute: (continuation, {
+        [input('object')]: object,
+        [input('properties')]: properties,
+      }) => continuation({
+        ['#entries']:
+          (object === null
+            ? properties.map(property => [property, null])
+            : properties.map(property => [property, object[property]])),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('object'),
+        input.staticValue('properties'),
+        input.staticValue('prefix'),
+        '#entries',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('object')]: object,
+        [input.staticValue('properties')]: properties,
+        [input.staticValue('prefix')]: prefix,
+        ['#entries']: entries,
+      }) =>
+        (properties
+          ? continuation(
+              Object.fromEntries(
+                entries.map(([property, value]) => [
+                  (prefix
+                    ? `${prefix}.${property}`
+                 : object
+                    ? `${object}.${property}`
+                    : `#object.${property}`),
+                  value ?? null,
+                ])))
+          : continuation({
+              ['#object']:
+                Object.fromEntries(entries),
+            })),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
new file mode 100644
index 00000000..3ce05fdf
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -0,0 +1,56 @@
+// Gets a property from each of a list of objects (in a dependency) and
+// provides the results.
+//
+// This doesn't alter any list indices, so positions which were null in the
+// original list are kept null here. Objects which don't have the specified
+// property are retained in-place as null.
+//
+// See also:
+//  - withPropertiesFromList
+//  - withPropertyFromObject
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList
+//  - withUnflattenedList
+//
+
+import {empty} from '#sugar';
+
+// todo: OUHHH THIS ONE'S NOT UPDATED YET LOL
+export default function({
+  list,
+  property,
+  into = null,
+}) {
+  into ??=
+    (list.startsWith('#')
+      ? `${list}.${property}`
+      : `#${list}.${property}`);
+
+  return {
+    annotation: `withPropertyFromList`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapDependencies: {list},
+      mapContinuation: {into},
+      options: {property},
+
+      compute(continuation, {list, '#options': {property}}) {
+        if (list === undefined || empty(list)) {
+          return continuation({into: []});
+        }
+
+        return continuation({
+          into:
+            list.map(item =>
+              (item === null || item === undefined
+                ? null
+                : item[property] ?? null)),
+        });
+      },
+    },
+  };
+}
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
new file mode 100644
index 00000000..b31bab15
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -0,0 +1,69 @@
+// Gets a property of some object (in a dependency) and provides that value.
+// If the object itself is null, or the object doesn't have the listed property,
+// the provided dependency will also be null.
+//
+// See also:
+//  - withPropertiesFromObject
+//  - withPropertyFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('object')]: object,
+    [input.staticValue('property')]: property,
+  }) =>
+    (object && property
+      ? (object.startsWith('#')
+          ? [`${object}.${property}`]
+          : [`#${object}.${property}`])
+      : ['#value']),
+
+  steps: () => [
+    {
+      dependencies: [
+        input.staticDependency('object'),
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('object')]: object,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        '#output':
+          (object && property
+            ? (object.startsWith('#')
+                ? `${object}.${property}`
+                : `#${object}.${property}`)
+            : '#value'),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#output',
+        input('object'),
+        input('property'),
+      ],
+
+      compute: (continuation, {
+        ['#output']: output,
+        [input('object')]: object,
+        [input('property')]: property,
+      }) => continuation({
+        [output]:
+          (object === null
+            ? null
+            : object[property] ?? null),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
new file mode 100644
index 00000000..3cfc247b
--- /dev/null
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -0,0 +1,62 @@
+// After mapping the contents of a flattened array in-place (being careful to
+// retain the original indices by replacing unmatched results with null instead
+// 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).
+
+import {input, templateCompositeFrom} from '#composite';
+import {isWholeNumber, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withUnflattenedList`,
+
+  inputs: {
+    list: input({
+      type: 'array',
+      defaultDependency: '#flattenedList',
+    }),
+
+    indices: input({
+      validate: validateArrayItems(isWholeNumber),
+      defaultDependency: '#flattenedIndices',
+    }),
+
+    filter: input({
+      type: 'boolean',
+      defaultValue: true,
+    }),
+  },
+
+  outputs: ['#unflattenedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('indices'), input('filter')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('indices')]: indices,
+        [input('filter')]: filter,
+      }) {
+        const unflattenedList = [];
+
+        for (let i = 0; i < indices.length; i++) {
+          const startIndex = indices[i];
+          const endIndex =
+            (i === indices.length - 1
+              ? list.length
+              : indices[i + 1]);
+
+          const values = list.slice(startIndex, endIndex);
+          unflattenedList.push(
+            (filter
+              ? values.filter(value => value !== null && value !== undefined)
+              : values));
+        }
+
+        return continuation({
+          ['#unflattenedList']: unflattenedList,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
new file mode 100644
index 00000000..8139f10e
--- /dev/null
+++ b/src/data/composite/things/album/index.js
@@ -0,0 +1,2 @@
+export {default as withTracks} from './withTracks.js';
+export {default as withTrackSections} from './withTrackSections.js';
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
new file mode 100644
index 00000000..c99b94d2
--- /dev/null
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -0,0 +1,119 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {empty, stitchArrays} from '#sugar';
+import {isTrackSectionList} from '#validators';
+import {filterMultipleArrays} from '#wiki-data';
+
+import {exitWithoutDependency, exitWithoutUpdateValue}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withTrackSections`,
+
+  outputs: ['#trackSections'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'trackData',
+      value: input.value([]),
+    }),
+
+    exitWithoutUpdateValue({
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    // TODO: input.updateValue description down here is a kludge.
+    withPropertiesFromList({
+      list: input.updateValue({
+        validate: isTrackSectionList,
+      }),
+      prefix: input.value('#sections'),
+      properties: input.value([
+        'tracks',
+        'dateOriginallyReleased',
+        'isDefaultTrackSection',
+        'color',
+      ]),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.tracks',
+      fill: input.value([]),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.isDefaultTrackSection',
+      fill: input.value(false),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.color',
+      fill: input.dependency('color'),
+    }),
+
+    withFlattenedList({
+      list: '#sections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#trackRefs',
+      ['#flattenedIndices']: '#sections.startIndex',
+    }),
+
+    withResolvedReferenceList({
+      list: '#trackRefs',
+      data: 'trackData',
+      notFoundMode: input.value('null'),
+      find: input.value(find.track),
+    }).outputs({
+      ['#resolvedReferenceList']: '#tracks',
+    }),
+
+    withUnflattenedList({
+      list: '#tracks',
+      indices: '#sections.startIndex',
+    }).outputs({
+      ['#unflattenedList']: '#sections.tracks',
+    }),
+
+    {
+      dependencies: [
+        '#sections.tracks',
+        '#sections.color',
+        '#sections.dateOriginallyReleased',
+        '#sections.isDefaultTrackSection',
+        '#sections.startIndex',
+      ],
+
+      compute: (continuation, {
+        '#sections.tracks': tracks,
+        '#sections.color': color,
+        '#sections.dateOriginallyReleased': dateOriginallyReleased,
+        '#sections.isDefaultTrackSection': isDefaultTrackSection,
+        '#sections.startIndex': startIndex,
+      }) => {
+        filterMultipleArrays(
+          tracks, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
+          tracks => !empty(tracks));
+
+        return continuation({
+          ['#trackSections']:
+            stitchArrays({
+              tracks,
+              color,
+              dateOriginallyReleased,
+              isDefaultTrackSection,
+              startIndex,
+            }),
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
new file mode 100644
index 00000000..dcea6593
--- /dev/null
+++ b/src/data/composite/things/album/withTracks.js
@@ -0,0 +1,51 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withTracks`,
+
+  outputs: ['#tracks'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'trackData',
+      value: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackSections',
+      mode: input.value('empty'),
+      output: input.value({
+        ['#tracks']: [],
+      }),
+    }),
+
+    {
+      dependencies: ['trackSections'],
+      compute: (continuation, {trackSections}) =>
+        continuation({
+          '#trackRefs': trackSections
+            .flatMap(section => section.tracks ?? []),
+        }),
+    },
+
+    withResolvedReferenceList({
+      list: '#trackRefs',
+      data: 'trackData',
+      find: input.value(find.track),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: resolvedReferenceList,
+      }) => continuation({
+        ['#tracks']: resolvedReferenceList,
+      })
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
new file mode 100644
index 00000000..f47086d9
--- /dev/null
+++ b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
@@ -0,0 +1,26 @@
+// Shorthand for checking if the track has unique cover art and exposing a
+// fallback value if it isn't.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutUniqueCoverArt`,
+
+  inputs: {
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withHasUniqueCoverArt(),
+
+    exitWithoutDependency({
+      dependency: '#hasUniqueCoverArt',
+      mode: input.value('falsy'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
new file mode 100644
index 00000000..3354b1c4
--- /dev/null
+++ b/src/data/composite/things/track/index.js
@@ -0,0 +1,9 @@
+export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
+export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
+export {default as withAlbum} from './withAlbum.js';
+export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
+export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
+export {default as withOtherReleases} from './withOtherReleases.js';
+export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js
new file mode 100644
index 00000000..a9d57f86
--- /dev/null
+++ b/src/data/composite/things/track/inheritFromOriginalRelease.js
@@ -0,0 +1,43 @@
+// Early exits with a value inherited from the original release, if
+// this track is a rerelease, and otherwise continues with no further
+// dependencies provided. If allowOverride is true, then the continuation
+// will also be called if the original release exposed the requested
+// property as null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromOriginalRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+    allowOverride: input({type: 'boolean', defaultValue: false}),
+  },
+
+  steps: () => [
+    withOriginalRelease(),
+
+    {
+      dependencies: [
+        '#originalRelease',
+        input('property'),
+        input('allowOverride'),
+      ],
+
+      compute: (continuation, {
+        ['#originalRelease']: originalRelease,
+        [input('property')]: originalProperty,
+        [input('allowOverride')]: allowOverride,
+      }) => {
+        if (!originalRelease) return continuation();
+
+        const value = originalRelease[originalProperty];
+        if (allowOverride && value === null) return continuation();
+
+        return continuation.exit(value);
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js
new file mode 100644
index 00000000..e7bfedf3
--- /dev/null
+++ b/src/data/composite/things/track/trackReverseReferenceList.js
@@ -0,0 +1,38 @@
+// Like a normal reverse reference list ("objects which reference this object
+// under a specified property"), only excluding re-releases from the possible
+// outputs. While it's useful to travel from a re-release to the tracks it
+// references, re-releases aren't generally relevant from the perspective of
+// the tracks *being* referenced. Apart from hiding re-releases from lists on
+// the site, it also excludes keeps them from relational data processing, such
+// as on the "Tracks - by Times Referenced" listing page.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withReverseReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `trackReverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    list: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: 'trackData',
+      list: input('list'),
+    }),
+
+    {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['#reverseReferenceList'],
+        compute: ({
+          ['#reverseReferenceList']: reverseReferenceList,
+        }) =>
+          reverseReferenceList.filter(track => !track.originalReleaseTrack),
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js
new file mode 100644
index 00000000..34845ab0
--- /dev/null
+++ b/src/data/composite/things/track/withAlbum.js
@@ -0,0 +1,57 @@
+// Gets the track's album. This will early exit if albumData is missing.
+// By default, if there's no album whose list of tracks includes this track,
+// the output dependency will be null; set {notFoundMode: 'exit'} to early
+// exit instead.
+
+import {input, templateCompositeFrom} from '#composite';
+import {is} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  inputs: {
+    notFoundMode: input({
+      validate: is('exit', 'null'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ['#album'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'albumData',
+      mode: input.value('empty'),
+      output: input.value({
+        ['#album']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input.myself(), 'albumData'],
+      compute: (continuation, {
+        [input.myself()]: track,
+        ['albumData']: albumData,
+      }) =>
+        continuation({
+          ['#album']:
+            albumData.find(album => album.tracks.includes(track)),
+        }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#album',
+      output: input.value({
+        ['#album']: null,
+      }),
+    }),
+
+    {
+      dependencies: ['#album'],
+      compute: (continuation, {'#album': album}) =>
+        continuation.raiseOutput({'#album': album}),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
new file mode 100644
index 00000000..0aeac788
--- /dev/null
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -0,0 +1,52 @@
+// Controls how find.track works - it'll never be matched by a reference
+// just to the track's name, which means you don't have to always reference
+// some *other* (much more commonly referenced) track by directory instead
+// of more naturally by name.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isBoolean} from '#validators';
+
+import {exitWithoutDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {excludeFromList, withPropertyFromObject} from '#composite/data';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withAlwaysReferenceByDirectory`,
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(isBoolean),
+    }),
+
+    excludeFromList({
+      list: 'trackData',
+      item: input.myself(),
+    }),
+
+    withOriginalRelease({
+      data: '#trackData',
+    }),
+
+    exitWithoutDependency({
+      dependency: '#originalRelease',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: '#originalRelease',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['name', '#originalRelease.name'],
+      compute: (continuation, {
+        name,
+        ['#originalRelease.name']: originalName,
+      }) => continuation({
+        ['#alwaysReferenceByDirectory']: name === originalName,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
new file mode 100644
index 00000000..b2e5f2b3
--- /dev/null
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -0,0 +1,63 @@
+// Gets the track section containing this track from its album's track list.
+// If notFoundMode is set to 'exit', this will early exit if the album can't be
+// found or if none of its trackSections includes the track for some reason.
+
+import {input, templateCompositeFrom} from '#composite';
+import {is} from '#validators';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withContainingTrackSection`,
+
+  inputs: {
+    notFoundMode: input({
+      validate: is('exit', 'null'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ['#trackSection'],
+
+  steps: () => [
+    withPropertyFromAlbum({
+      property: input.value('trackSections'),
+      notFoundMode: input('notFoundMode'),
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        input('notFoundMode'),
+        '#album.trackSections',
+      ],
+
+      compute(continuation, {
+        [input.myself()]: track,
+        [input('notFoundMode')]: notFoundMode,
+        ['#album.trackSections']: trackSections,
+      }) {
+        if (!trackSections) {
+          return continuation.raiseOutput({
+            ['#trackSection']: null,
+          });
+        }
+
+        const trackSection =
+          trackSections.find(({tracks}) => tracks.includes(track));
+
+        if (trackSection) {
+          return continuation.raiseOutput({
+            ['#trackSection']: trackSection,
+          });
+        } else if (notFoundMode === 'exit') {
+          return continuation.exit(null);
+        } else {
+          return continuation.raiseOutput({
+            ['#trackSection']: null,
+          });
+        }
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
new file mode 100644
index 00000000..96078d5f
--- /dev/null
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -0,0 +1,61 @@
+// Whether or not the track has "unique" cover artwork - a cover which is
+// specifically associated with this track in particular, rather than with
+// the track's album as a whole. This is typically used to select between
+// displaying the track artwork and a fallback, such as the album artwork
+// or a placeholder. (This property is named hasUniqueCoverArt instead of
+// the usual hasCoverArt to emphasize that it does not inherit from the
+// album.)
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+import {withResolvedContribs} from '#composite/wiki-data';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: 'withHasUniqueCoverArt',
+
+  outputs: ['#hasUniqueCoverArt'],
+
+  steps: () => [
+    {
+      dependencies: ['disableUniqueCoverArt'],
+      compute: (continuation, {disableUniqueCoverArt}) =>
+        (disableUniqueCoverArt
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: false,
+            })
+          : continuation()),
+    },
+
+    withResolvedContribs({from: 'coverArtistContribs'}),
+
+    {
+      dependencies: ['#resolvedContribs'],
+      compute: (continuation, {
+        ['#resolvedContribs']: contribsFromTrack,
+      }) =>
+        (empty(contribsFromTrack)
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
+      }) =>
+        continuation.raiseOutput({
+          ['#hasUniqueCoverArt']:
+            !empty(contribsFromAlbum),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withOriginalRelease.js
new file mode 100644
index 00000000..d2ee39df
--- /dev/null
+++ b/src/data/composite/things/track/withOriginalRelease.js
@@ -0,0 +1,59 @@
+// Just includes the original release of this track as a dependency.
+// If this track isn't a rerelease, then it'll provide null, unless the
+// {selfIfOriginal} option is set, in which case it'll provide this track
+// itself. Note that this will early exit if the original release is
+// specified by reference and that reference doesn't resolve to anything.
+// Outputs to '#originalRelease' by default.
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {validateWikiData} from '#validators';
+
+import {withResolvedReference} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withOriginalRelease`,
+
+  inputs: {
+    selfIfOriginal: input({type: 'boolean', defaultValue: false}),
+
+    data: input({
+      validate: validateWikiData({referenceType: 'track'}),
+      defaultDependency: 'trackData',
+    }),
+  },
+
+  outputs: ['#originalRelease'],
+
+  steps: () => [
+    withResolvedReference({
+      ref: 'originalReleaseTrack',
+      data: input('data'),
+      find: input.value(find.track),
+      notFoundMode: input.value('exit'),
+    }).outputs({
+      ['#resolvedReference']: '#originalRelease',
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        input('selfIfOriginal'),
+        '#originalRelease',
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: track,
+        [input('selfIfOriginal')]: selfIfOriginal,
+        ['#originalRelease']: originalRelease,
+      }) =>
+        continuation({
+          ['#originalRelease']:
+            (originalRelease ??
+              (selfIfOriginal
+                ? track
+                : null)),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
new file mode 100644
index 00000000..84420cf8
--- /dev/null
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -0,0 +1,40 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withOtherReleases`,
+
+  outputs: ['#otherReleases'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'trackData',
+      mode: input.value('empty'),
+    }),
+
+    withOriginalRelease({
+      selfIfOriginal: input.value(true),
+    }),
+
+    {
+      dependencies: [input.myself(), '#originalRelease', 'trackData'],
+      compute: (continuation, {
+        [input.myself()]: thisTrack,
+        ['#originalRelease']: originalRelease,
+        trackData,
+      }) => continuation({
+        ['#otherReleases']:
+          (originalRelease === thisTrack
+            ? []
+            : [originalRelease])
+            .concat(trackData.filter(track =>
+              track !== originalRelease &&
+              track !== thisTrack &&
+              track.originalReleaseTrack === originalRelease)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
new file mode 100644
index 00000000..b236a6e8
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -0,0 +1,49 @@
+// Gets a single property from this track's album, providing it as the same
+// property name prefixed with '#album.' (by default). If the track's album
+// isn't available, then by default, the property will be provided as null;
+// set {notFoundMode: 'exit'} to early exit instead.
+
+import {input, templateCompositeFrom} from '#composite';
+import {is} from '#validators';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAlbum`,
+
+  inputs: {
+    property: input.staticValue({type: 'string'}),
+
+    notFoundMode: input({
+      validate: is('exit', 'null'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) => ['#album.' + property],
+
+  steps: () => [
+    withAlbum({
+      notFoundMode: input('notFoundMode'),
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#value', input.staticValue('property')],
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        ['#album.' + property]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js
new file mode 100644
index 00000000..2c8219fc
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -0,0 +1,47 @@
+// Shorthand for exiting if the contribution list (usually a property's update
+// value) resolves to empty - ensuring that the later computed results are only
+// returned if these contributions are present.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutContribs`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withResolvedContribs({
+      from: input('contribs'),
+    }),
+
+    // TODO: Fairly certain exitWithoutDependency would be sufficient here.
+
+    withResultOfAvailabilityCheck({
+      from: '#resolvedContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', input('value')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('value')]: value,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.exit(value)),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
new file mode 100644
index 00000000..1d0400fc
--- /dev/null
+++ b/src/data/composite/wiki-data/index.js
@@ -0,0 +1,7 @@
+export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as inputThingClass} from './inputThingClass.js';
+export {default as inputWikiData} from './inputWikiData.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';
diff --git a/src/data/composite/wiki-data/inputThingClass.js b/src/data/composite/wiki-data/inputThingClass.js
new file mode 100644
index 00000000..d70480e6
--- /dev/null
+++ b/src/data/composite/wiki-data/inputThingClass.js
@@ -0,0 +1,23 @@
+// Please note that this input, used in a variety of #composite/wiki-data
+// utilities, is basically always a kludge. Any usage of it depends on
+// referencing Thing class values defined outside of the #composite folder.
+
+import {input} from '#composite';
+import {isType} from '#validators';
+
+// TODO: Kludge.
+import Thing from '../../things/thing.js';
+
+export default function inputThingClass() {
+  return input.staticValue({
+    validate(thingClass) {
+      isType(thingClass, 'function');
+
+      if (!Object.hasOwn(thingClass, Thing.referenceType)) {
+        throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
+      }
+
+      return true;
+    },
+  });
+}
diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js
new file mode 100644
index 00000000..cf7a7c2c
--- /dev/null
+++ b/src/data/composite/wiki-data/inputWikiData.js
@@ -0,0 +1,17 @@
+import {input} from '#composite';
+import {validateWikiData} from '#validators';
+
+// TODO: This doesn't access a class's own ThingSubclass[Thing.referenceType]
+// value because classes aren't initialized by when templateCompositeFrom gets
+// called (see: circular imports). So the reference types have to be hard-coded,
+// which somewhat defeats the point of storing them on the class in the first
+// place...
+export default function inputWikiData({
+  referenceType = '',
+  allowMixedTypes = false,
+} = {}) {
+  return input({
+    validate: validateWikiData({referenceType, allowMixedTypes}),
+    acceptsNull: true,
+  });
+}
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
new file mode 100644
index 00000000..eda24160
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -0,0 +1,77 @@
+// Resolves the contribsByRef contained in the provided dependency,
+// providing (named by the second argument) the result. "Resolving"
+// means mapping the "who" reference of each contribution to an artist
+// object, and filtering out those whose "who" doesn't match any artist.
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {is, isContributionList} from '#validators';
+import {filterMultipleArrays} from '#wiki-data';
+
+import {
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import {
+  withPropertiesFromList,
+} from '#composite/data';
+
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedContribs`,
+
+  inputs: {
+    from: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: input({
+      validate: is('exit', 'filter', 'null'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ['#resolvedContribs'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('from'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedContribs']: [],
+      }),
+    }),
+
+    withPropertiesFromList({
+      list: input('from'),
+      properties: input.value(['who', 'what']),
+      prefix: input.value('#contribs'),
+    }),
+
+    withResolvedReferenceList({
+      list: '#contribs.who',
+      data: 'artistData',
+      find: input.value(find.artist),
+      notFoundMode: input('notFoundMode'),
+    }).outputs({
+      ['#resolvedReferenceList']: '#contribs.who',
+    }),
+
+    {
+      dependencies: ['#contribs.who', '#contribs.what'],
+
+      compute(continuation, {
+        ['#contribs.who']: who,
+        ['#contribs.what']: what,
+      }) {
+        filterMultipleArrays(who, what, (who, _what) => who);
+        return continuation({
+          ['#resolvedContribs']: stitchArrays({who, what}),
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
new file mode 100644
index 00000000..0fa5c554
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -0,0 +1,73 @@
+// Resolves a reference by using the provided find function to match it
+// within the provided thingData dependency. This will early exit if the
+// data dependency is null, or, if notFoundMode is set to 'exit', if the find
+// function doesn't match anything for the reference. Otherwise, the data
+// object is provided on the output dependency; or null, if the reference
+// doesn't match anything or itself was null to begin with.
+
+import {input, templateCompositeFrom} from '#composite';
+import {is} from '#validators';
+
+import {
+  exitWithoutDependency,
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReference`,
+
+  inputs: {
+    ref: input({type: 'string', acceptsNull: true}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+
+    notFoundMode: input({
+      validate: is('null', 'exit'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ['#resolvedReference'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('ref'),
+      output: input.value({
+        ['#resolvedReference']: null,
+      }),
+    }),
+
+    exitWithoutDependency({
+      dependency: input('data'),
+    }),
+
+    {
+      dependencies: [
+        input('ref'),
+        input('data'),
+        input('find'),
+        input('notFoundMode'),
+      ],
+
+      compute(continuation, {
+        [input('ref')]: ref,
+        [input('data')]: data,
+        [input('find')]: findFunction,
+        [input('notFoundMode')]: notFoundMode,
+      }) {
+        const match = findFunction(ref, data, {mode: 'quiet'});
+
+        if (match === null && notFoundMode === 'exit') {
+          return continuation.exit(null);
+        }
+
+        return continuation.raiseOutput({
+          ['#resolvedReference']: match ?? null,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js
new file mode 100644
index 00000000..1d39e5b2
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -0,0 +1,101 @@
+// Resolves a list of references, with each reference matched with provided
+// data in the same way as withResolvedReference. This will early exit if the
+// data dependency is null (even if the reference list is empty). By default
+// it will filter out references which don't match, but this can be changed
+// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null').
+
+import {input, templateCompositeFrom} from '#composite';
+import {is, isString, validateArrayItems} from '#validators';
+
+import {
+  exitWithoutDependency,
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isString),
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+
+    notFoundMode: input({
+      validate: is('exit', 'filter', 'null'),
+      defaultValue: 'filter',
+    }),
+  },
+
+  outputs: ['#resolvedReferenceList'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input('data'),
+      value: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedReferenceList']: [],
+      }),
+    }),
+
+    {
+      dependencies: [input('list'), input('data'), input('find')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('data')]: data,
+        [input('find')]: findFunction,
+      }) =>
+        continuation({
+          '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})),
+        }),
+    },
+
+    {
+      dependencies: ['#matches'],
+      compute: (continuation, {'#matches': matches}) =>
+        (matches.every(match => match)
+          ? continuation.raiseOutput({
+              ['#resolvedReferenceList']: matches,
+            })
+          : continuation()),
+    },
+
+    {
+      dependencies: ['#matches', input('notFoundMode')],
+      compute(continuation, {
+        ['#matches']: matches,
+        [input('notFoundMode')]: notFoundMode,
+      }) {
+        switch (notFoundMode) {
+          case 'exit':
+            return continuation.exit([]);
+
+          case 'filter':
+            return continuation.raiseOutput({
+              ['#resolvedReferenceList']:
+                matches.filter(match => match),
+            });
+
+          case 'null':
+            return continuation.raiseOutput({
+              ['#resolvedReferenceList']:
+                matches.map(match => match ?? null),
+            });
+
+          default:
+            throw new TypeError(`Expected notFoundMode to be exit, filter, or null`);
+        }
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
new file mode 100644
index 00000000..113a6c40
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -0,0 +1,40 @@
+// Check out the info on reverseReferenceList!
+// This is its composable form.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  outputs: ['#reverseReferenceList'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input('data'),
+      value: input.value([]),
+    }),
+
+    {
+      dependencies: [input.myself(), input('data'), input('list')],
+
+      compute: (continuation, {
+        [input.myself()]: thisThing,
+        [input('data')]: data,
+        [input('list')]: refListProperty,
+      }) =>
+        continuation({
+          ['#reverseReferenceList']:
+            data.filter(thing => thing[refListProperty].includes(thisThing)),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js
new file mode 100644
index 00000000..6760527a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalFiles.js
@@ -0,0 +1,30 @@
+// This is a somewhat more involved data structure - it's for additional
+// or "bonus" files associated with albums or tracks (or anything else).
+// It's got this form:
+//
+//   [
+//     {title: 'Booklet', files: ['Booklet.pdf']},
+//     {
+//       title: 'Wallpaper',
+//       description: 'Cool Wallpaper!',
+//       files: ['1440x900.png', '1920x1080.png']
+//     },
+//     {title: 'Alternate Covers', description: null, files: [...]},
+//     ...
+//   ]
+//
+
+import {isAdditionalFileList} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalFileList},
+    expose: {
+      transform: (additionalFiles) =>
+        additionalFiles ?? [],
+    },
+  };
+}
diff --git a/src/data/composite/wiki-properties/color.js b/src/data/composite/wiki-properties/color.js
new file mode 100644
index 00000000..1bc9888b
--- /dev/null
+++ b/src/data/composite/wiki-properties/color.js
@@ -0,0 +1,12 @@
+// A color! This'll be some CSS-ready value.
+
+import {isColor} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isColor},
+  };
+}
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
new file mode 100644
index 00000000..fbea9d5c
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -0,0 +1,12 @@
+// Artist commentary! Generally present on tracks and albums.
+
+import {isCommentary} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isCommentary},
+  };
+}
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
new file mode 100644
index 00000000..52aeb868
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -0,0 +1,55 @@
+// This one's kinda tricky: it parses artist "references" from the
+// commentary content, and finds the matching artist for each reference.
+// 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';
+
+export default templateCompositeFrom({
+  annotation: `commentatorArtists`,
+
+  compose: false,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'commentary',
+      mode: input.value('falsy'),
+      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),
+    }).outputs({
+      '#resolvedReferenceList': '#artists',
+    }),
+
+    {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['#artists'],
+        compute: ({'#artists': artists}) =>
+          unique(artists),
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/contribsPresent.js b/src/data/composite/wiki-properties/contribsPresent.js
new file mode 100644
index 00000000..24f302a5
--- /dev/null
+++ b/src/data/composite/wiki-properties/contribsPresent.js
@@ -0,0 +1,30 @@
+// Nice 'n simple shorthand for an exposed-only flag which is true when any
+// contributions are present in the specified property.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `contribsPresent`,
+
+  compose: false,
+
+  inputs: {
+    contribs: input.staticDependency({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('contribs'),
+      mode: input.value('empty'),
+    }),
+
+    exposeDependency({dependency: '#availability'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
new file mode 100644
index 00000000..8fde2caa
--- /dev/null
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -0,0 +1,35 @@
+// Strong 'n sturdy contribution list, rolling a list of references (provided
+// as this property's update value) and the resolved results (as get exposed)
+// into one property. Update value will look something like this:
+//
+//   [
+//     {who: 'Artist Name', what: 'Viola'},
+//     {who: 'artist:john-cena', what: null},
+//     ...
+//   ]
+//
+// ...typically as processed from YAML, spreadsheet, or elsewhere.
+// Exposes as the same, but with the "who" replaced with matches found in
+// artistData - which means this always depends on an `artistData` property
+// also existing on this object!
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `contributionList`,
+
+  compose: false,
+
+  update: {validate: isContributionList},
+
+  steps: () => [
+    withResolvedContribs({from: input.updateValue()}),
+    exposeDependencyOrContinue({dependency: '#resolvedContribs'}),
+    exposeConstant({value: input.value([])}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/dimensions.js b/src/data/composite/wiki-properties/dimensions.js
new file mode 100644
index 00000000..57a01279
--- /dev/null
+++ b/src/data/composite/wiki-properties/dimensions.js
@@ -0,0 +1,13 @@
+// Plain ol' image dimensions. This is a two-item array of positive integers,
+// corresponding to width and height respectively.
+
+import {isDimensions} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDimensions},
+  };
+}
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
new file mode 100644
index 00000000..0b2181c9
--- /dev/null
+++ b/src/data/composite/wiki-properties/directory.js
@@ -0,0 +1,23 @@
+// The all-encompassing "directory" property, used as the unique identifier for
+// almost any data object. Also corresponds to a part of the URL which pages of
+// such objects are visited at.
+
+import {isDirectory} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDirectory},
+    expose: {
+      dependencies: ['name'],
+      transform(directory, {name}) {
+        if (directory === null && name === null) return null;
+        else if (directory === null) return getKebabCase(name);
+        else return directory;
+      },
+    },
+  };
+}
diff --git a/src/data/composite/wiki-properties/duration.js b/src/data/composite/wiki-properties/duration.js
new file mode 100644
index 00000000..827f282d
--- /dev/null
+++ b/src/data/composite/wiki-properties/duration.js
@@ -0,0 +1,13 @@
+// Duration! This is a number of seconds, possibly floating point, always
+// at minimum zero.
+
+import {isDuration} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDuration},
+  };
+}
diff --git a/src/data/composite/wiki-properties/externalFunction.js b/src/data/composite/wiki-properties/externalFunction.js
new file mode 100644
index 00000000..c388da6c
--- /dev/null
+++ b/src/data/composite/wiki-properties/externalFunction.js
@@ -0,0 +1,11 @@
+// External function. These should only be used as dependencies for other
+// properties, so they're left unexposed.
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true},
+    update: {validate: (t) => typeof t === 'function'},
+  };
+}
diff --git a/src/data/composite/wiki-properties/fileExtension.js b/src/data/composite/wiki-properties/fileExtension.js
new file mode 100644
index 00000000..c926fa8b
--- /dev/null
+++ b/src/data/composite/wiki-properties/fileExtension.js
@@ -0,0 +1,13 @@
+// A file extension! Or the default, if provided when calling this.
+
+import {isFileExtension} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function(defaultFileExtension = null) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isFileExtension},
+    expose: {transform: (value) => value ?? defaultFileExtension},
+  };
+}
diff --git a/src/data/composite/wiki-properties/flag.js b/src/data/composite/wiki-properties/flag.js
new file mode 100644
index 00000000..076e663f
--- /dev/null
+++ b/src/data/composite/wiki-properties/flag.js
@@ -0,0 +1,19 @@
+// Straightforward flag descriptor for a variety of property purposes.
+// Provide a default value, true or false!
+
+import {isBoolean} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+// TODO: The description is a lie. This defaults to false. Bad.
+
+export default function(defaultValue = false) {
+  if (typeof defaultValue !== 'boolean') {
+    throw new TypeError(`Always set explicit defaults for flags!`);
+  }
+
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isBoolean, default: defaultValue},
+  };
+}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
new file mode 100644
index 00000000..2462b047
--- /dev/null
+++ b/src/data/composite/wiki-properties/index.js
@@ -0,0 +1,20 @@
+export {default as additionalFiles} from './additionalFiles.js';
+export {default as color} from './color.js';
+export {default as commentary} from './commentary.js';
+export {default as commentatorArtists} from './commentatorArtists.js';
+export {default as contribsPresent} from './contribsPresent.js';
+export {default as contributionList} from './contributionList.js';
+export {default as dimensions} from './dimensions.js';
+export {default as directory} from './directory.js';
+export {default as duration} from './duration.js';
+export {default as externalFunction} from './externalFunction.js';
+export {default as fileExtension} from './fileExtension.js';
+export {default as flag} from './flag.js';
+export {default as name} from './name.js';
+export {default as referenceList} from './referenceList.js';
+export {default as reverseReferenceList} from './reverseReferenceList.js';
+export {default as simpleDate} from './simpleDate.js';
+export {default as simpleString} from './simpleString.js';
+export {default as singleReference} from './singleReference.js';
+export {default as urls} from './urls.js';
+export {default as wikiData} from './wikiData.js';
diff --git a/src/data/composite/wiki-properties/name.js b/src/data/composite/wiki-properties/name.js
new file mode 100644
index 00000000..5146488b
--- /dev/null
+++ b/src/data/composite/wiki-properties/name.js
@@ -0,0 +1,11 @@
+// A wiki data object's name! Its directory (i.e. unique identifier) will be
+// computed based on this value if not otherwise specified.
+
+import {isName} from '#validators';
+
+export default function(defaultName) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isName, default: defaultName},
+  };
+}
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
new file mode 100644
index 00000000..f5b6c58e
--- /dev/null
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -0,0 +1,47 @@
+// Stores and exposes a list of references to other data objects; all items
+// must be references to the same type, which is specified on the class input.
+//
+// See also:
+//  - singleReference
+//  - withResolvedReferenceList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateReferenceList} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputThingClass, inputWikiData, withResolvedReferenceList}
+  from '#composite/wiki-data';
+
+// TODO: Kludge.
+import Thing from '../../things/thing.js';
+
+export default templateCompositeFrom({
+  annotation: `referenceList`,
+
+  compose: false,
+
+  inputs: {
+    class: inputThingClass(),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => {
+    const {[Thing.referenceType]: referenceType} = thingClass;
+    return {validate: validateReferenceList(referenceType)};
+  },
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js
new file mode 100644
index 00000000..84ba67df
--- /dev/null
+++ b/src/data/composite/wiki-properties/reverseReferenceList.js
@@ -0,0 +1,30 @@
+// Neat little shortcut for "reversing" the reference lists stored on other
+// things - for example, tracks specify a "referenced tracks" property, and
+// you would use this to compute a corresponding "referenced *by* tracks"
+// property. Naturally, the passed ref list property is of the things in the
+// wiki data provided, not the requesting Thing itself.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `reverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: input('data'),
+      list: input('list'),
+    }),
+
+    exposeDependency({dependency: '#reverseReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/simpleDate.js b/src/data/composite/wiki-properties/simpleDate.js
new file mode 100644
index 00000000..f08d8323
--- /dev/null
+++ b/src/data/composite/wiki-properties/simpleDate.js
@@ -0,0 +1,14 @@
+// General date type, used as the descriptor for a bunch of properties.
+// This isn't dynamic though - it won't inherit from a date stored on
+// another object, for example.
+
+import {isDate} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDate},
+  };
+}
diff --git a/src/data/composite/wiki-properties/simpleString.js b/src/data/composite/wiki-properties/simpleString.js
new file mode 100644
index 00000000..18d65146
--- /dev/null
+++ b/src/data/composite/wiki-properties/simpleString.js
@@ -0,0 +1,14 @@
+// General string type. This should probably generally be avoided in favor
+// of more specific validation, but using it makes it easy to find where we
+// might want to improve later, and it's a useful shorthand meanwhile.
+
+import {isString} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isString},
+  };
+}
diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js
new file mode 100644
index 00000000..34bd2e6d
--- /dev/null
+++ b/src/data/composite/wiki-properties/singleReference.js
@@ -0,0 +1,47 @@
+// Stores and exposes one connection, or reference, to another data object.
+// The reference must be to a specific type, which is specified on the class
+// input.
+//
+// See also:
+//  - referenceList
+//  - withResolvedReference
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateReference} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputThingClass, inputWikiData, withResolvedReference}
+  from '#composite/wiki-data';
+
+// TODO: Kludge.
+import Thing from '../../things/thing.js';
+
+export default templateCompositeFrom({
+  annotation: `singleReference`,
+
+  compose: false,
+
+  inputs: {
+    class: inputThingClass(),
+    find: input({type: 'function'}),
+    data: inputWikiData({allowMixedTypes: false}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => {
+    const {[Thing.referenceType]: referenceType} = thingClass;
+    return {validate: validateReference(referenceType)};
+  },
+
+  steps: () => [
+    withResolvedReference({
+      ref: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReference'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/urls.js b/src/data/composite/wiki-properties/urls.js
new file mode 100644
index 00000000..3160a0bf
--- /dev/null
+++ b/src/data/composite/wiki-properties/urls.js
@@ -0,0 +1,14 @@
+// A list of URLs! This will always be present on the data object, even if set
+// to an empty array or null.
+
+import {isURL, validateArrayItems} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: validateArrayItems(isURL)},
+    expose: {transform: value => value ?? []},
+  };
+}
diff --git a/src/data/composite/wiki-properties/wikiData.js b/src/data/composite/wiki-properties/wikiData.js
new file mode 100644
index 00000000..4ea47785
--- /dev/null
+++ b/src/data/composite/wiki-properties/wikiData.js
@@ -0,0 +1,17 @@
+// General purpose wiki data constructor, for properties like artistData,
+// trackData, etc.
+
+import {validateArrayItems, validateInstanceOf} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+// TODO: This should validate with validateWikiData.
+
+export default function(thingClass) {
+  return {
+    flags: {update: true},
+    update: {
+      validate: validateArrayItems(validateInstanceOf(thingClass)),
+    },
+  };
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index fd8a71d3..e3ac1651 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,21 +1,12 @@
+import {input} from '#composite';
 import find from '#find';
-import {empty, stitchArrays} from '#sugar';
-import {isDate, isTrackSectionList} from '#validators';
-import {filterMultipleArrays} from '#wiki-data';
+import {isDate} from '#validators';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {exitWithoutContribs} from '#composite/wiki-data';
 
 import {
-  exitWithoutDependency,
-  exitWithoutUpdateValue,
-  exposeDependency,
-  exposeUpdateValueOrContinue,
-  input,
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite';
-
-import Thing, {
   additionalFiles,
   commentary,
   color,
@@ -24,7 +15,6 @@ import Thing, {
   contributionList,
   dimensions,
   directory,
-  exitWithoutContribs,
   fileExtension,
   flag,
   name,
@@ -33,8 +23,14 @@ import Thing, {
   simpleString,
   urls,
   wikiData,
-  withResolvedReferenceList,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import {
+  withTracks,
+  withTrackSections,
+} from '#composite/things/album';
+
+import Thing from './thing.js';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
@@ -101,100 +97,8 @@ export class Album extends Thing {
     additionalFiles: additionalFiles(),
 
     trackSections: [
-      exitWithoutDependency({
-        dependency: 'trackData',
-        value: input.value([]),
-      }),
-
-      exitWithoutUpdateValue({
-        mode: input.value('empty'),
-        value: input.value([]),
-      }),
-
-      withPropertiesFromList({
-        list: input.updateValue(),
-        prefix: input.value('#sections'),
-        properties: input.value([
-          'tracks',
-          'dateOriginallyReleased',
-          'isDefaultTrackSection',
-          'color',
-        ]),
-      }),
-
-      fillMissingListItems({
-        list: '#sections.tracks',
-        fill: input.value([]),
-      }),
-
-      fillMissingListItems({
-        list: '#sections.isDefaultTrackSection',
-        fill: input.value(false),
-      }),
-
-      fillMissingListItems({
-        list: '#sections.color',
-        fill: input.dependency('color'),
-      }),
-
-      withFlattenedList({
-        list: '#sections.tracks',
-      }).outputs({
-        ['#flattenedList']: '#trackRefs',
-        ['#flattenedIndices']: '#sections.startIndex',
-      }),
-
-      withResolvedReferenceList({
-        list: '#trackRefs',
-        data: 'trackData',
-        notFoundMode: input.value('null'),
-        find: input.value(find.track),
-      }).outputs({
-        ['#resolvedReferenceList']: '#tracks',
-      }),
-
-      withUnflattenedList({
-        list: '#tracks',
-        indices: '#sections.startIndex',
-      }).outputs({
-        ['#unflattenedList']: '#sections.tracks',
-      }),
-
-      {
-        flags: {update: true, expose: true},
-
-        update: {validate: isTrackSectionList},
-
-        expose: {
-          dependencies: [
-            '#sections.tracks',
-            '#sections.color',
-            '#sections.dateOriginallyReleased',
-            '#sections.isDefaultTrackSection',
-            '#sections.startIndex',
-          ],
-
-          transform(trackSections, {
-            '#sections.tracks': tracks,
-            '#sections.color': color,
-            '#sections.dateOriginallyReleased': dateOriginallyReleased,
-            '#sections.isDefaultTrackSection': isDefaultTrackSection,
-            '#sections.startIndex': startIndex,
-          }) {
-            filterMultipleArrays(
-              tracks, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
-              tracks => !empty(tracks));
-
-            return stitchArrays({
-              tracks,
-              color,
-              dateOriginallyReleased,
-              isDefaultTrackSection,
-              startIndex,
-            });
-          }
-        },
-      },
+      withTrackSections(),
+      exposeDependency({dependency: '#trackSections'}),
     ],
 
     artistContribs: contributionList(),
@@ -231,33 +135,8 @@ export class Album extends Thing {
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
     tracks: [
-      exitWithoutDependency({
-        dependency: 'trackData',
-        value: input.value([]),
-      }),
-
-      exitWithoutDependency({
-        dependency: 'trackSections',
-        mode: input.value('empty'),
-        value: input.value([]),
-      }),
-
-      {
-        dependencies: ['trackSections'],
-        compute: (continuation, {trackSections}) =>
-          continuation({
-            '#trackRefs': trackSections
-              .flatMap(section => section.tracks ?? []),
-          }),
-      },
-
-      withResolvedReferenceList({
-        list: '#trackRefs',
-        data: 'trackData',
-        find: input.value(find.track),
-      }),
-
-      exposeDependency({dependency: '#resolvedReferenceList'}),
+      withTracks(),
+      exposeDependency({dependency: '#tracks'}),
     ],
   });
 
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index ba3cbd0d..1266a4e0 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,14 +1,18 @@
-import {exposeUpdateValueOrContinue, input} from '#composite';
+import {input} from '#composite';
 import {sortAlbumsTracksChronologically} from '#wiki-data';
 import {isName} from '#validators';
 
-import Thing, {
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+
+import {
   color,
   directory,
   flag,
   name,
   wikiData,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 085e5663..ff9f8aee 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -2,7 +2,7 @@ import {input} from '#composite';
 import find from '#find';
 import {isName, validateArrayItems} from '#validators';
 
-import Thing, {
+import {
   directory,
   fileExtension,
   flag,
@@ -11,7 +11,9 @@ import Thing, {
   singleReference,
   urls,
   wikiData,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
index c03f8833..7e068dce 100644
--- a/src/data/things/composite.js
+++ b/src/data/things/composite.js
@@ -2,14 +2,7 @@ import {inspect} from 'node:util';
 
 import {colors} from '#cli';
 import {TupleMap} from '#wiki-data';
-
-import {
-  a,
-  is,
-  isString,
-  isWholeNumber,
-  validateArrayItems,
-} from '#validators';
+import {a} from '#validators';
 
 import {
   decorateErrorWithIndex,
@@ -1639,721 +1632,3 @@ export function debugComposite(fn) {
   compositeFrom.debug = false;
   return value;
 }
-
-// Exposes a dependency exactly as it is; this is typically the base of a
-// composition which was created to serve as one property's descriptor.
-//
-// Please note that this *doesn't* verify that the dependency exists, so
-// if you provide the wrong name or it hasn't been set by a previous
-// compositional step, the property will be exposed as undefined instead
-// of null.
-//
-export const exposeDependency = templateCompositeFrom({
-  annotation: `exposeDependency`,
-
-  compose: false,
-
-  inputs: {
-    dependency: input.staticDependency({acceptsNull: true}),
-  },
-
-  steps: () => [
-    {
-      dependencies: [input('dependency')],
-      compute: ({
-        [input('dependency')]: dependency
-      }) => dependency,
-    },
-  ],
-});
-
-// Exposes a constant value exactly as it is; like exposeDependency, this
-// is typically the base of a composition serving as a particular property
-// descriptor. It generally follows steps which will conditionally early
-// exit with some other value, with the exposeConstant base serving as the
-// fallback default value.
-export const exposeConstant = templateCompositeFrom({
-  annotation: `exposeConstant`,
-
-  compose: false,
-
-  inputs: {
-    value: input.staticValue(),
-  },
-
-  steps: () => [
-    {
-      dependencies: [input('value')],
-      compute: ({
-        [input('value')]: value,
-      }) => value,
-    },
-  ],
-});
-
-// Checks the availability of a dependency and provides the result to later
-// steps under '#availability' (by default). This is mainly intended for use
-// by the more specific utilities, which you should consider using instead.
-// Customize {mode} to select one of these modes, or default to 'null':
-//
-// * 'null':  Check that the value isn't null (and not undefined either).
-// * 'empty': Check that the value is neither null, undefined, nor an empty
-//            array.
-// * 'falsy': Check that the value isn't false when treated as a boolean
-//            (nor an empty array). Keep in mind this will also be false
-//            for values like zero and the empty string!
-//
-
-const inputAvailabilityCheckMode = () => input({
-  validate: is('null', 'empty', 'falsy'),
-  defaultValue: 'null',
-});
-
-export const withResultOfAvailabilityCheck = templateCompositeFrom({
-  annotation: `withResultOfAvailabilityCheck`,
-
-  inputs: {
-    from: input({acceptsNull: true}),
-    mode: inputAvailabilityCheckMode(),
-  },
-
-  outputs: ['#availability'],
-
-  steps: () => [
-    {
-      dependencies: [input('from'), input('mode')],
-
-      compute: (continuation, {
-        [input('from')]: value,
-        [input('mode')]: mode,
-      }) => {
-        let availability;
-
-        switch (mode) {
-          case 'null':
-            availability = value !== undefined && value !== null;
-            break;
-
-          case 'empty':
-            availability = value !== undefined && !empty(value);
-            break;
-
-          case 'falsy':
-            availability = !!value && (!Array.isArray(value) || !empty(value));
-            break;
-        }
-
-        return continuation({'#availability': availability});
-      },
-    },
-  ],
-});
-
-// Exposes a dependency as it is, or continues if it's unavailable.
-// See withResultOfAvailabilityCheck for {mode} options!
-export const exposeDependencyOrContinue = templateCompositeFrom({
-  annotation: `exposeDependencyOrContinue`,
-
-  inputs: {
-    dependency: input({acceptsNull: true}),
-    mode: inputAvailabilityCheckMode(),
-  },
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('dependency'),
-      mode: input('mode'),
-    }),
-
-    {
-      dependencies: ['#availability', input('dependency')],
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input('dependency')]: dependency,
-      }) =>
-        (availability
-          ? continuation.exit(dependency)
-          : continuation()),
-    },
-  ],
-});
-
-// Exposes the update value of an {update: true} property as it is,
-// or continues if it's unavailable. See withResultOfAvailabilityCheck
-// for {mode} options! Also provide {validate} here to conveniently
-// set a custom validation check for this property's update value.
-export const exposeUpdateValueOrContinue = templateCompositeFrom({
-  annotation: `exposeUpdateValueOrContinue`,
-
-  inputs: {
-    mode: inputAvailabilityCheckMode(),
-
-    validate: input({
-      type: 'function',
-      defaultValue: null,
-    }),
-  },
-
-  update: ({
-    [input.staticValue('validate')]: validate,
-  }) =>
-    (validate
-      ? {validate}
-      : {}),
-
-  steps: () => [
-    exposeDependencyOrContinue({
-      dependency: input.updateValue(),
-      mode: input('mode'),
-    }),
-  ],
-});
-
-// Early exits if a dependency isn't available.
-// See withResultOfAvailabilityCheck for {mode} options!
-export const exitWithoutDependency = templateCompositeFrom({
-  annotation: `exitWithoutDependency`,
-
-  inputs: {
-    dependency: input({acceptsNull: true}),
-    mode: inputAvailabilityCheckMode(),
-    value: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('dependency'),
-      mode: input('mode'),
-    }),
-
-    {
-      dependencies: ['#availability', input('value')],
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input('value')]: value,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.exit(value)),
-    },
-  ],
-});
-
-// Early exits if this property's update value isn't available.
-// See withResultOfAvailabilityCheck for {mode} options!
-export const exitWithoutUpdateValue = templateCompositeFrom({
-  annotation: `exitWithoutUpdateValue`,
-
-  inputs: {
-    mode: inputAvailabilityCheckMode(),
-    value: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input.updateValue(),
-      mode: input('mode'),
-      value: input('value'),
-    }),
-  ],
-});
-
-// Raises if a dependency isn't available.
-// See withResultOfAvailabilityCheck for {mode} options!
-export const raiseOutputWithoutDependency = templateCompositeFrom({
-  annotation: `raiseOutputWithoutDependency`,
-
-  inputs: {
-    dependency: input({acceptsNull: true}),
-    mode: inputAvailabilityCheckMode(),
-    output: input.staticValue({defaultValue: {}}),
-  },
-
-  outputs: ({
-    [input.staticValue('output')]: output,
-  }) => Object.keys(output),
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('dependency'),
-      mode: input('mode'),
-    }),
-
-    {
-      dependencies: ['#availability', input('output')],
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input('output')]: output,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutputAbove(output)),
-    },
-  ],
-});
-
-// Raises if this property's update value isn't available.
-// See withResultOfAvailabilityCheck for {mode} options!
-export const raiseOutputWithoutUpdateValue = templateCompositeFrom({
-  annotation: `raiseOutputWithoutUpdateValue`,
-
-  inputs: {
-    mode: inputAvailabilityCheckMode(),
-    output: input.staticValue({defaultValue: {}}),
-  },
-
-  outputs: ({
-    [input.staticValue('output')]: output,
-  }) => Object.keys(output),
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input.updateValue(),
-      mode: input('mode'),
-    }),
-
-    {
-      dependencies: ['#availability', input('output')],
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input('output')]: output,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutputAbove(output)),
-    },
-  ],
-});
-
-// Gets a property of some object (in a dependency) and provides that value.
-// If the object itself is null, or the object doesn't have the listed property,
-// the provided dependency will also be null.
-export const withPropertyFromObject = templateCompositeFrom({
-  annotation: `withPropertyFromObject`,
-
-  inputs: {
-    object: input({type: 'object', acceptsNull: true}),
-    property: input({type: 'string'}),
-  },
-
-  outputs: ({
-    [input.staticDependency('object')]: object,
-    [input.staticValue('property')]: property,
-  }) =>
-    (object && property
-      ? (object.startsWith('#')
-          ? [`${object}.${property}`]
-          : [`#${object}.${property}`])
-      : ['#value']),
-
-  steps: () => [
-    {
-      dependencies: [
-        input.staticDependency('object'),
-        input.staticValue('property'),
-      ],
-
-      compute: (continuation, {
-        [input.staticDependency('object')]: object,
-        [input.staticValue('property')]: property,
-      }) => continuation({
-        '#output':
-          (object && property
-            ? (object.startsWith('#')
-                ? `${object}.${property}`
-                : `#${object}.${property}`)
-            : '#value'),
-      }),
-    },
-
-    {
-      dependencies: [
-        '#output',
-        input('object'),
-        input('property'),
-      ],
-
-      compute: (continuation, {
-        ['#output']: output,
-        [input('object')]: object,
-        [input('property')]: property,
-      }) => continuation({
-        [output]:
-          (object === null
-            ? null
-            : object[property] ?? null),
-      }),
-    },
-  ],
-});
-
-// Gets the listed properties from some object, providing each property's value
-// as a dependency prefixed with the same name as the object (by default).
-// If the object itself is null, all provided dependencies will be null;
-// if it's missing only select properties, those will be provided as null.
-export const withPropertiesFromObject = templateCompositeFrom({
-  annotation: `withPropertiesFromObject`,
-
-  inputs: {
-    object: input({type: 'object', acceptsNull: true}),
-
-    properties: input({
-      type: 'array',
-      validate: validateArrayItems(isString),
-    }),
-
-    prefix: input.staticValue({type: 'string', defaultValue: null}),
-  },
-
-  outputs: ({
-    [input.staticDependency('object')]: object,
-    [input.staticValue('properties')]: properties,
-    [input.staticValue('prefix')]: prefix,
-  }) =>
-    (properties
-      ? properties.map(property =>
-          (prefix
-            ? `${prefix}.${property}`
-         : object
-            ? `${object}.${property}`
-            : `#object.${property}`))
-      : ['#object']),
-
-  steps: () => [
-    {
-      dependencies: [input('object'), input('properties')],
-      compute: (continuation, {
-        [input('object')]: object,
-        [input('properties')]: properties,
-      }) => continuation({
-        ['#entries']:
-          (object === null
-            ? properties.map(property => [property, null])
-            : properties.map(property => [property, object[property]])),
-      }),
-    },
-
-    {
-      dependencies: [
-        input.staticDependency('object'),
-        input.staticValue('properties'),
-        input.staticValue('prefix'),
-        '#entries',
-      ],
-
-      compute: (continuation, {
-        [input.staticDependency('object')]: object,
-        [input.staticValue('properties')]: properties,
-        [input.staticValue('prefix')]: prefix,
-        ['#entries']: entries,
-      }) =>
-        (properties
-          ? continuation(
-              Object.fromEntries(
-                entries.map(([property, value]) => [
-                  (prefix
-                    ? `${prefix}.${property}`
-                 : object
-                    ? `${object}.${property}`
-                    : `#object.${property}`),
-                  value ?? null,
-                ])))
-          : continuation({
-              ['#object']:
-                Object.fromEntries(entries),
-            })),
-    },
-  ],
-});
-
-// Gets a property from each of a list of objects (in a dependency) and
-// provides the results. This doesn't alter any list indices, so positions
-// which were null in the original list are kept null here. Objects which don't
-// have the specified property are retained in-place as null.
-export function withPropertyFromList({
-  list,
-  property,
-  into = null,
-}) {
-  into ??=
-    (list.startsWith('#')
-      ? `${list}.${property}`
-      : `#${list}.${property}`);
-
-  return {
-    annotation: `withPropertyFromList`,
-    flags: {expose: true, compose: true},
-
-    expose: {
-      mapDependencies: {list},
-      mapContinuation: {into},
-      options: {property},
-
-      compute(continuation, {list, '#options': {property}}) {
-        if (list === undefined || empty(list)) {
-          return continuation({into: []});
-        }
-
-        return continuation({
-          into:
-            list.map(item =>
-              (item === null || item === undefined
-                ? null
-                : item[property] ?? null)),
-        });
-      },
-    },
-  };
-}
-
-// Gets the listed properties from each of a list of objects, providing lists
-// of property values each into a dependency prefixed with the same name as the
-// list (by default). Like withPropertyFromList, this doesn't alter indices.
-export const withPropertiesFromList = templateCompositeFrom({
-  annotation: `withPropertiesFromList`,
-
-  inputs: {
-    list: input({type: 'array'}),
-
-    properties: input({
-      validate: validateArrayItems(isString),
-    }),
-
-    prefix: input.staticValue({type: 'string', defaultValue: null}),
-  },
-
-  outputs: ({
-    [input.staticDependency('list')]: list,
-    [input.staticValue('properties')]: properties,
-    [input.staticValue('prefix')]: prefix,
-  }) =>
-    (properties
-      ? properties.map(property =>
-          (prefix
-            ? `${prefix}.${property}`
-         : list
-            ? `${list}.${property}`
-            : `#list.${property}`))
-      : ['#lists']),
-
-  steps: () => [
-    {
-      dependencies: [input('list'), input('properties')],
-      compute: (continuation, {
-        [input('list')]: list,
-        [input('properties')]: properties,
-      }) => continuation({
-        ['#lists']:
-          Object.fromEntries(
-            properties.map(property => [
-              property,
-              list.map(item => item[property] ?? null),
-            ])),
-      }),
-    },
-
-    {
-      dependencies: [
-        input.staticDependency('list'),
-        input.staticValue('properties'),
-        input.staticValue('prefix'),
-        '#lists',
-      ],
-
-      compute: (continuation, {
-        [input.staticDependency('list')]: list,
-        [input.staticValue('properties')]: properties,
-        [input.staticValue('prefix')]: prefix,
-        ['#lists']: lists,
-      }) =>
-        (properties
-          ? continuation(
-              Object.fromEntries(
-                properties.map(property => [
-                  (prefix
-                    ? `${prefix}.${property}`
-                 : list
-                    ? `${list}.${property}`
-                    : `#list.${property}`),
-                  lists[property],
-                ])))
-          : continuation({'#lists': lists})),
-    },
-  ],
-});
-
-// Replaces items of a list, which are null or undefined, with some fallback
-// value. By default, this replaces the passed dependency.
-export const fillMissingListItems = templateCompositeFrom({
-  annotation: `fillMissingListItems`,
-
-  inputs: {
-    list: input({type: 'array'}),
-    fill: input({acceptsNull: true}),
-  },
-
-  outputs: ({
-    [input.staticDependency('list')]: list,
-  }) => [list ?? '#list'],
-
-  steps: () => [
-    {
-      dependencies: [input('list'), input('fill')],
-      compute: (continuation, {
-        [input('list')]: list,
-        [input('fill')]: fill,
-      }) => continuation({
-        ['#filled']:
-          list.map(item => item ?? fill),
-      }),
-    },
-
-    {
-      dependencies: [input.staticDependency('list'), '#filled'],
-      compute: (continuation, {
-        [input.staticDependency('list')]: list,
-        ['#filled']: filled,
-      }) => continuation({
-        [list ?? '#list']:
-          filled,
-      }),
-    },
-  ],
-});
-
-// Filters particular values out of a list. Note that this will always
-// completely skip over null, but can be used to filter out any other
-// primitive or object value.
-export const excludeFromList = templateCompositeFrom({
-  annotation: `excludeFromList`,
-
-  inputs: {
-    list: input(),
-
-    item: input({defaultValue: null}),
-    items: input({type: 'array', defaultValue: null}),
-  },
-
-  outputs: ({
-    [input.staticDependency('list')]: list,
-  }) => [list ?? '#list'],
-
-  steps: () => [
-    {
-      dependencies: [
-        input.staticDependency('list'),
-        input('list'),
-        input('item'),
-        input('items'),
-      ],
-
-      compute: (continuation, {
-        [input.staticDependency('list')]: listName,
-        [input('list')]: listContents,
-        [input('item')]: excludeItem,
-        [input('items')]: excludeItems,
-      }) => continuation({
-        [listName ?? '#list']:
-          listContents.filter(item => {
-            if (excludeItem !== null && item === excludeItem) return false;
-            if (!empty(excludeItems) && excludeItems.includes(item)) return false;
-            return true;
-          }),
-      }),
-    },
-  ],
-});
-
-// Flattens an array with one level of nested arrays, providing as dependencies
-// both the flattened array as well as the original starting indices of each
-// successive source array.
-export const withFlattenedList = templateCompositeFrom({
-  annotation: `withFlattenedList`,
-
-  inputs: {
-    list: input({type: 'array'}),
-  },
-
-  outputs: ['#flattenedList', '#flattenedIndices'],
-
-  steps: () => [
-    {
-      dependencies: [input('list')],
-      compute(continuation, {
-        [input('list')]: sourceList,
-      }) {
-        const flattenedList = sourceList.flat();
-        const indices = [];
-        let lastEndIndex = 0;
-        for (const {length} of sourceList) {
-          indices.push(lastEndIndex);
-          lastEndIndex += length;
-        }
-
-        return continuation({
-          ['#flattenedList']: flattenedList,
-          ['#flattenedIndices']: indices,
-        });
-      },
-    },
-  ],
-});
-
-// After mapping the contents of a flattened array in-place (being careful to
-// retain the original indices by replacing unmatched results with null instead
-// 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).
-export const withUnflattenedList = templateCompositeFrom({
-  annotation: `withUnflattenedList`,
-
-  inputs: {
-    list: input({
-      type: 'array',
-      defaultDependency: '#flattenedList',
-    }),
-
-    indices: input({
-      validate: validateArrayItems(isWholeNumber),
-      defaultDependency: '#flattenedIndices',
-    }),
-
-    filter: input({
-      type: 'boolean',
-      defaultValue: true,
-    }),
-  },
-
-  outputs: ['#unflattenedList'],
-
-  steps: () => [
-    {
-      dependencies: [input('list'), input('indices'), input('filter')],
-      compute(continuation, {
-        [input('list')]: list,
-        [input('indices')]: indices,
-        [input('filter')]: filter,
-      }) {
-        const unflattenedList = [];
-
-        for (let i = 0; i < indices.length; i++) {
-          const startIndex = indices[i];
-          const endIndex =
-            (i === indices.length - 1
-              ? list.length
-              : indices[i + 1]);
-
-          const values = list.slice(startIndex, endIndex);
-          unflattenedList.push(
-            (filter
-              ? values.filter(value => value !== null && value !== undefined)
-              : values));
-        }
-
-        return continuation({
-          ['#unflattenedList']: unflattenedList,
-        });
-      },
-    },
-  ],
-});
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index c3f90260..8fb1edfa 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -9,7 +9,7 @@ import {
   oneOf,
 } from '#validators';
 
-import Thing, {
+import {
   color,
   contributionList,
   fileExtension,
@@ -19,7 +19,9 @@ import Thing, {
   simpleString,
   urls,
   wikiData,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 0b117801..d5ae03e7 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,7 +1,7 @@
 import {input} from '#composite';
 import find from '#find';
 
-import Thing, {
+import {
   color,
   directory,
   name,
@@ -9,7 +9,9 @@ import Thing, {
   simpleString,
   urls,
   wikiData,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index bcf99e80..de9d0e50 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -1,11 +1,7 @@
+import {input} from '#composite';
 import find from '#find';
 
 import {
-  exposeDependency,
-  input,
-} from '#composite';
-
-import {
   is,
   isCountingNumber,
   isString,
@@ -16,14 +12,18 @@ import {
   validateReference,
 } from '#validators';
 
-import Thing, {
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+
+import {
   color,
   name,
   referenceList,
   simpleString,
   wikiData,
-  withResolvedReference,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class HomepageLayout extends Thing {
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
diff --git a/src/data/things/language.js b/src/data/things/language.js
index a325d6a6..fe74f7bf 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,13 +1,14 @@
 import {Tag} from '#html';
 import {isLanguageCode} from '#validators';
 
-import CacheableObject from './cacheable-object.js';
-
-import Thing, {
+import {
   externalFunction,
   flag,
   simpleString,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import CacheableObject from './cacheable-object.js';
+import Thing from './thing.js';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 6984874e..ba065c25 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,9 +1,11 @@
-import Thing, {
+import {
   directory,
   name,
   simpleDate,
   simpleString,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class NewsEntry extends Thing {
   static [Thing.referenceType] = 'news-entry';
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 0133e0b6..f03e4405 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -1,10 +1,12 @@
 import {isName} from '#validators';
 
-import Thing, {
+import {
   directory,
   name,
   simpleString,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class StaticPage extends Thing {
   static [Thing.referenceType] = 'static';
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index f1302e17..a47f8506 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -1,48 +1,9 @@
-// Thing: base class for wiki data types, providing wiki-specific utility
-// functions on top of essential CacheableObject behavior.
+// Thing: base class for wiki data types, providing interfaces generally useful
+// to all wiki data objects on top of foundational CacheableObject behavior.
 
 import {inspect} from 'node:util';
 
 import {colors} from '#cli';
-import find from '#find';
-import {stitchArrays, unique} from '#sugar';
-import {filterMultipleArrays, getKebabCase} from '#wiki-data';
-import {is} from '#validators';
-
-import {
-  compositeFrom,
-  exitWithoutDependency,
-  exposeConstant,
-  exposeDependency,
-  exposeDependencyOrContinue,
-  input,
-  raiseOutputWithoutDependency,
-  templateCompositeFrom,
-  withResultOfAvailabilityCheck,
-  withPropertiesFromList,
-} from '#composite';
-
-import {
-  isAdditionalFileList,
-  isBoolean,
-  isColor,
-  isCommentary,
-  isContributionList,
-  isDate,
-  isDimensions,
-  isDirectory,
-  isDuration,
-  isFileExtension,
-  isName,
-  isString,
-  isType,
-  isURL,
-  validateArrayItems,
-  validateInstanceOf,
-  validateReference,
-  validateReferenceList,
-  validateWikiData,
-} from '#validators';
 
 import CacheableObject from './cacheable-object.js';
 
@@ -77,673 +38,3 @@ export default class Thing extends CacheableObject {
     return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
   }
 }
-
-// Property descriptor templates
-//
-// Regularly reused property descriptors, for ease of access and generally
-// duplicating less code across wiki data types. These are specialized utility
-// functions, so check each for how its own arguments behave!
-
-export function name(defaultName) {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isName, default: defaultName},
-  };
-}
-
-export function color() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isColor},
-  };
-}
-
-export function directory() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDirectory},
-    expose: {
-      dependencies: ['name'],
-      transform(directory, {name}) {
-        if (directory === null && name === null) return null;
-        else if (directory === null) return getKebabCase(name);
-        else return directory;
-      },
-    },
-  };
-}
-
-export function urls() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: validateArrayItems(isURL)},
-    expose: {transform: (value) => value ?? []},
-  };
-}
-
-// A file extension! Or the default, if provided when calling this.
-export function fileExtension(defaultFileExtension = null) {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isFileExtension},
-    expose: {transform: (value) => value ?? defaultFileExtension},
-  };
-}
-
-// Plain ol' image dimensions. This is a two-item array of positive integers,
-// corresponding to width and height respectively.
-export function dimensions() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDimensions},
-  };
-}
-
-// Duration! This is a number of seconds, possibly floating point, always
-// at minimum zero.
-export function duration() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDuration},
-  };
-}
-
-// Straightforward flag descriptor for a variety of property purposes.
-// Provide a default value, true or false!
-export function flag(defaultValue = false) {
-  // TODO:                        ^ Are you actually kidding me
-  if (typeof defaultValue !== 'boolean') {
-    throw new TypeError(`Always set explicit defaults for flags!`);
-  }
-
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isBoolean, default: defaultValue},
-  };
-}
-
-// General date type, used as the descriptor for a bunch of properties.
-// This isn't dynamic though - it won't inherit from a date stored on
-// another object, for example.
-export function simpleDate() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDate},
-  };
-}
-
-// General string type. This should probably generally be avoided in favor
-// of more specific validation, but using it makes it easy to find where we
-// might want to improve later, and it's a useful shorthand meanwhile.
-export function simpleString() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isString},
-  };
-}
-
-// External function. These should only be used as dependencies for other
-// properties, so they're left unexposed.
-export function externalFunction() {
-  return {
-    flags: {update: true},
-    update: {validate: (t) => typeof t === 'function'},
-  };
-}
-
-// Strong 'n sturdy contribution list, rolling a list of references (provided
-// as this property's update value) and the resolved results (as get exposed)
-// into one property. Update value will look something like this:
-//
-//   [
-//     {who: 'Artist Name', what: 'Viola'},
-//     {who: 'artist:john-cena', what: null},
-//     ...
-//   ]
-//
-// ...typically as processed from YAML, spreadsheet, or elsewhere.
-// Exposes as the same, but with the "who" replaced with matches found in
-// artistData - which means this always depends on an `artistData` property
-// also existing on this object!
-//
-export function contributionList() {
-  return compositeFrom({
-    annotation: `contributionList`,
-
-    compose: false,
-
-    update: {validate: isContributionList},
-
-    steps: [
-      withResolvedContribs({from: input.updateValue()}),
-      exposeDependencyOrContinue({dependency: '#resolvedContribs'}),
-      exposeConstant({value: input.value([])}),
-    ],
-  });
-}
-
-// Artist commentary! Generally present on tracks and albums.
-export function commentary() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isCommentary},
-  };
-}
-
-// This is a somewhat more involved data structure - it's for additional
-// or "bonus" files associated with albums or tracks (or anything else).
-// It's got this form:
-//
-//     [
-//         {title: 'Booklet', files: ['Booklet.pdf']},
-//         {
-//             title: 'Wallpaper',
-//             description: 'Cool Wallpaper!',
-//             files: ['1440x900.png', '1920x1080.png']
-//         },
-//         {title: 'Alternate Covers', description: null, files: [...]},
-//         ...
-//     ]
-//
-export function additionalFiles() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isAdditionalFileList},
-    expose: {
-      transform: (additionalFiles) =>
-        additionalFiles ?? [],
-    },
-  };
-}
-
-const thingClassInput = {
-  validate(thingClass) {
-    isType(thingClass, 'function');
-
-    if (!Object.hasOwn(thingClass, Thing.referenceType)) {
-      throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
-    }
-
-    return true;
-  },
-};
-
-// A reference list! Keep in mind this is for general references to wiki
-// objects of (usually) other Thing subclasses, not specifically leitmotif
-// references in tracks (although that property uses referenceList too!).
-//
-// The underlying function validateReferenceList expects a string like
-// 'artist' or 'track', but this utility keeps from having to hard-code the
-// string in multiple places by referencing the value saved on the class
-// instead.
-export const referenceList = templateCompositeFrom({
-  annotation: `referenceList`,
-
-  compose: false,
-
-  inputs: {
-    class: input.staticValue(thingClassInput),
-
-    data: inputWikiData({allowMixedTypes: false}),
-    find: input({type: 'function'}),
-  },
-
-  update: ({
-    [input.staticValue('class')]: thingClass,
-  }) => {
-    const {[Thing.referenceType]: referenceType} = thingClass;
-    return {validate: validateReferenceList(referenceType)};
-  },
-
-  steps: () => [
-    withResolvedReferenceList({
-      list: input.updateValue(),
-      data: input('data'),
-      find: input('find'),
-    }),
-
-    exposeDependency({dependency: '#resolvedReferenceList'}),
-  ],
-});
-
-// Corresponding function for a single reference.
-export const singleReference = templateCompositeFrom({
-  annotation: `singleReference`,
-
-  compose: false,
-
-  inputs: {
-    class: input(thingClassInput),
-    find: input({type: 'function'}),
-    data: inputWikiData({allowMixedTypes: false}),
-  },
-
-  update: ({
-    [input.staticValue('class')]: thingClass,
-  }) => {
-    const {[Thing.referenceType]: referenceType} = thingClass;
-    return {validate: validateReference(referenceType)};
-  },
-
-  steps: () => [
-    withResolvedReference({
-      ref: input.updateValue(),
-      data: input('data'),
-      find: input('find'),
-    }),
-
-    exposeDependency({dependency: '#resolvedReference'}),
-  ],
-});
-
-// Nice 'n simple shorthand for an exposed-only flag which is true when any
-// contributions are present in the specified property.
-export const contribsPresent = templateCompositeFrom({
-  annotation: `contribsPresent`,
-
-  compose: false,
-
-  inputs: {
-    contribs: input.staticDependency({
-      validate: isContributionList,
-      acceptsNull: true,
-    }),
-  },
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('contribs'),
-      mode: input.value('empty'),
-    }),
-
-    exposeDependency({dependency: '#availability'}),
-  ],
-});
-
-// Neat little shortcut for "reversing" the reference lists stored on other
-// things - for example, tracks specify a "referenced tracks" property, and
-// you would use this to compute a corresponding "referenced *by* tracks"
-// property. Naturally, the passed ref list property is of the things in the
-// wiki data provided, not the requesting Thing itself.
-export const reverseReferenceList = templateCompositeFrom({
-  annotation: `reverseReferenceList`,
-
-  compose: false,
-
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  steps: () => [
-    withReverseReferenceList({
-      data: input('data'),
-      list: input('list'),
-    }),
-
-    exposeDependency({dependency: '#reverseReferenceList'}),
-  ],
-});
-
-// General purpose wiki data constructor, for properties like artistData,
-// trackData, etc.
-export function wikiData(thingClass) {
-  return {
-    flags: {update: true},
-    update: {
-      validate: validateArrayItems(validateInstanceOf(thingClass)),
-    },
-  };
-}
-
-// This one's kinda tricky: it parses artist "references" from the
-// commentary content, and finds the matching artist for each reference.
-// This is mostly useful for credits and listings on artist pages.
-export const commentatorArtists = templateCompositeFrom({
-  annotation: `commentatorArtists`,
-
-  compose: false,
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: 'commentary',
-      mode: input.value('falsy'),
-      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),
-    }).outputs({
-      '#resolvedReferenceList': '#artists',
-    }),
-
-    {
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['#artists'],
-        compute: ({'#artists': artists}) =>
-          unique(artists),
-      },
-    },
-  ],
-});
-
-// Compositional utilities
-
-// TODO: This doesn't access a class's own ThingSubclass[Thing.referenceType]
-// value because classes aren't initialized by when templateCompositeFrom gets
-// called (see: circular imports). So the reference types have to be hard-coded,
-// which somewhat defeats the point of storing them on the class in the first
-// place...
-export function inputWikiData({
-  referenceType = '',
-  allowMixedTypes = false,
-} = {}) {
-  return input({
-    validate: validateWikiData({referenceType, allowMixedTypes}),
-    acceptsNull: true,
-  });
-}
-
-// Resolves the contribsByRef contained in the provided dependency,
-// providing (named by the second argument) the result. "Resolving"
-// means mapping the "who" reference of each contribution to an artist
-// object, and filtering out those whose "who" doesn't match any artist.
-export const withResolvedContribs = templateCompositeFrom({
-  annotation: `withResolvedContribs`,
-
-  inputs: {
-    from: input({
-      validate: isContributionList,
-      acceptsNull: true,
-    }),
-
-    notFoundMode: input({
-      validate: is('exit', 'filter', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
-  outputs: ['#resolvedContribs'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: input('from'),
-      mode: input.value('empty'),
-      output: input.value({
-        ['#resolvedContribs']: [],
-      }),
-    }),
-
-    withPropertiesFromList({
-      list: input('from'),
-      properties: input.value(['who', 'what']),
-      prefix: input.value('#contribs'),
-    }),
-
-    withResolvedReferenceList({
-      list: '#contribs.who',
-      data: 'artistData',
-      find: input.value(find.artist),
-      notFoundMode: input('notFoundMode'),
-    }).outputs({
-      ['#resolvedReferenceList']: '#contribs.who',
-    }),
-
-    {
-      dependencies: ['#contribs.who', '#contribs.what'],
-
-      compute(continuation, {
-        ['#contribs.who']: who,
-        ['#contribs.what']: what,
-      }) {
-        filterMultipleArrays(who, what, (who, _what) => who);
-        return continuation({
-          ['#resolvedContribs']: stitchArrays({who, what}),
-        });
-      },
-    },
-  ],
-});
-
-// Shorthand for exiting if the contribution list (usually a property's update
-// value) resolves to empty - ensuring that the later computed results are only
-// returned if these contributions are present.
-export const exitWithoutContribs = templateCompositeFrom({
-  annotation: `exitWithoutContribs`,
-
-  inputs: {
-    contribs: input({
-      validate: isContributionList,
-      acceptsNull: true,
-    }),
-
-    value: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    withResolvedContribs({
-      from: input('contribs'),
-    }),
-
-    withResultOfAvailabilityCheck({
-      from: '#resolvedContribs',
-      mode: input.value('empty'),
-    }),
-
-    {
-      dependencies: ['#availability', input('value')],
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input('value')]: value,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.exit(value)),
-    },
-  ],
-});
-
-// Resolves a reference by using the provided find function to match it
-// within the provided thingData dependency. This will early exit if the
-// data dependency is null, or, if notFoundMode is set to 'exit', if the find
-// function doesn't match anything for the reference. Otherwise, the data
-// object is provided on the output dependency; or null, if the reference
-// doesn't match anything or itself was null to begin with.
-export const withResolvedReference = templateCompositeFrom({
-  annotation: `withResolvedReference`,
-
-  inputs: {
-    ref: input({type: 'string', acceptsNull: true}),
-
-    data: inputWikiData({allowMixedTypes: false}),
-    find: input({type: 'function'}),
-
-    notFoundMode: input({
-      validate: is('null', 'exit'),
-      defaultValue: 'null',
-    }),
-  },
-
-  outputs: ['#resolvedReference'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: input('ref'),
-      output: input.value({
-        ['#resolvedReference']: null,
-      }),
-    }),
-
-    exitWithoutDependency({
-      dependency: input('data'),
-    }),
-
-    {
-      dependencies: [
-        input('ref'),
-        input('data'),
-        input('find'),
-        input('notFoundMode'),
-      ],
-
-      compute(continuation, {
-        [input('ref')]: ref,
-        [input('data')]: data,
-        [input('find')]: findFunction,
-        [input('notFoundMode')]: notFoundMode,
-      }) {
-        const match = findFunction(ref, data, {mode: 'quiet'});
-
-        if (match === null && notFoundMode === 'exit') {
-          return continuation.exit(null);
-        }
-
-        return continuation.raiseOutput({
-          ['#resolvedReference']: match ?? null,
-        });
-      },
-    },
-  ],
-});
-
-// Resolves a list of references, with each reference matched with provided
-// data in the same way as withResolvedReference. This will early exit if the
-// data dependency is null (even if the reference list is empty). By default
-// it will filter out references which don't match, but this can be changed
-// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null').
-export const withResolvedReferenceList = templateCompositeFrom({
-  annotation: `withResolvedReferenceList`,
-
-  inputs: {
-    list: input({
-      validate: validateArrayItems(isString),
-      acceptsNull: true,
-    }),
-
-    data: inputWikiData({allowMixedTypes: false}),
-    find: input({type: 'function'}),
-
-    notFoundMode: input({
-      validate: is('exit', 'filter', 'null'),
-      defaultValue: 'filter',
-    }),
-  },
-
-  outputs: ['#resolvedReferenceList'],
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: input('list'),
-      mode: input.value('empty'),
-      output: input.value({
-        ['#resolvedReferenceList']: [],
-      }),
-    }),
-
-    {
-      dependencies: [input('list'), input('data'), input('find')],
-      compute: (continuation, {
-        [input('list')]: list,
-        [input('data')]: data,
-        [input('find')]: findFunction,
-      }) =>
-        continuation({
-          '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})),
-        }),
-    },
-
-    {
-      dependencies: ['#matches'],
-      compute: (continuation, {'#matches': matches}) =>
-        (matches.every(match => match)
-          ? continuation.raiseOutput({
-              ['#resolvedReferenceList']: matches,
-            })
-          : continuation()),
-    },
-
-    {
-      dependencies: ['#matches', input('notFoundMode')],
-      compute(continuation, {
-        ['#matches']: matches,
-        [input('notFoundMode')]: notFoundMode,
-      }) {
-        switch (notFoundMode) {
-          case 'exit':
-            return continuation.exit([]);
-
-          case 'filter':
-            return continuation.raiseOutput({
-              ['#resolvedReferenceList']:
-                matches.filter(match => match),
-            });
-
-          case 'null':
-            return continuation.raiseOutput({
-              ['#resolvedReferenceList']:
-                matches.map(match => match ?? null),
-            });
-
-          default:
-            throw new TypeError(`Expected notFoundMode to be exit, filter, or null`);
-        }
-      },
-    },
-  ],
-});
-
-// Check out the info on reverseReferenceList!
-// This is its composable form.
-export const withReverseReferenceList = templateCompositeFrom({
-  annotation: `withReverseReferenceList`,
-
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  outputs: ['#reverseReferenceList'],
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-    }),
-
-    {
-      dependencies: [input.myself(), input('data'), input('list')],
-
-      compute: (continuation, {
-        [input.myself()]: thisThing,
-        [input('data')]: data,
-        [input('list')]: refListProperty,
-      }) =>
-        continuation({
-          ['#reverseReferenceList']:
-            data.filter(thing => thing[refListProperty].includes(thisThing)),
-        }),
-    },
-  ],
-});
diff --git a/src/data/things/track.js b/src/data/things/track.js
index c77bf889..193ad891 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -1,35 +1,28 @@
 import {inspect} from 'node:util';
 
 import {colors} from '#cli';
+import {input} from '#composite';
 import find from '#find';
-import {empty} from '#sugar';
 
 import {
-  exitWithoutDependency,
-  excludeFromList,
-  exposeConstant,
-  exposeDependency,
-  exposeDependencyOrContinue,
-  exposeUpdateValueOrContinue,
-  input,
-  raiseOutputWithoutDependency,
-  templateCompositeFrom,
-  withPropertyFromObject,
-} from '#composite';
-
-import {
-  is,
-  isBoolean,
   isColor,
   isContributionList,
   isDate,
   isFileExtension,
-  validateWikiData,
 } from '#validators';
 
-import CacheableObject from './cacheable-object.js';
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
-import Thing, {
+import {
   additionalFiles,
   commentary,
   commentatorArtists,
@@ -45,10 +38,22 @@ import Thing, {
   simpleString,
   urls,
   wikiData,
-  withResolvedContribs,
-  withResolvedReference,
-  withReverseReferenceList,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import {
+  exitWithoutUniqueCoverArt,
+  inheritFromOriginalRelease,
+  trackReverseReferenceList,
+  withAlbum,
+  withAlwaysReferenceByDirectory,
+  withContainingTrackSection,
+  withHasUniqueCoverArt,
+  withOtherReleases,
+  withPropertyFromAlbum,
+} from '#composite/things/track';
+
+import CacheableObject from './cacheable-object.js';
+import Thing from './thing.js';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
@@ -84,39 +89,9 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
-    // Controls how find.track works - it'll never be matched by a reference
-    // just to the track's name, which means you don't have to always reference
-    // some *other* (much more commonly referenced) track by directory instead
-    // of more naturally by name.
     alwaysReferenceByDirectory: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isBoolean),
-      }),
-
-      excludeFromList({
-        list: 'trackData',
-        item: input.myself(),
-      }),
-
-      withOriginalRelease({
-        data: '#trackData',
-      }),
-
-      exitWithoutDependency({
-        dependency: '#originalRelease',
-        value: input.value(false),
-      }),
-
-      withPropertyFromObject({
-        object: '#originalRelease',
-        property: input.value('name'),
-      }),
-
-      {
-        dependencies: ['name', '#originalRelease.name'],
-        compute: ({name, '#originalRelease.name': originalName}) =>
-          name === originalName,
-      },
+      withAlwaysReferenceByDirectory(),
+      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
     ],
 
     // Disables presenting the track as though it has its own unique artwork.
@@ -298,61 +273,20 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.date'}),
     ],
 
-    // Whether or not the track has "unique" cover artwork - a cover which is
-    // specifically associated with this track in particular, rather than with
-    // the track's album as a whole. This is typically used to select between
-    // displaying the track artwork and a fallback, such as the album artwork
-    // or a placeholder. (This property is named hasUniqueCoverArt instead of
-    // the usual hasCoverArt to emphasize that it does not inherit from the
-    // album.)
     hasUniqueCoverArt: [
       withHasUniqueCoverArt(),
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ],
 
     otherReleases: [
-      exitWithoutDependency({
-        dependency: 'trackData',
-        mode: input.value('empty'),
-      }),
-
-      withOriginalRelease({
-        selfIfOriginal: input.value(true),
-      }),
-
-      {
-        flags: {expose: true},
-        expose: {
-          dependencies: [input.myself(), '#originalRelease', 'trackData'],
-          compute: ({
-            [input.myself()]: thisTrack,
-            ['#originalRelease']: originalRelease,
-            trackData,
-          }) =>
-            (originalRelease === thisTrack
-              ? []
-              : [originalRelease])
-              .concat(trackData.filter(track =>
-                track !== originalRelease &&
-                track !== thisTrack &&
-                track.originalReleaseTrack === originalRelease)),
-        },
-      },
+      withOtherReleases(),
+      exposeDependency({dependency: '#otherReleases'}),
     ],
 
-    // Specifically exclude re-releases from this list - while it's useful to
-    // get from a re-release to the tracks it references, re-releases aren't
-    // generally relevant from the perspective of the tracks being referenced.
-    // Filtering them from data here hides them from the corresponding field
-    // on the site (obviously), and has the bonus of not counting them when
-    // counting the number of times a track has been referenced, for use in
-    // the "Tracks - by Times Referenced" listing page (or other data
-    // processing).
     referencedByTracks: trackReverseReferenceList({
       list: input.value('referencedTracks'),
     }),
 
-    // For the same reasoning, exclude re-releases from sampled tracks too.
     sampledByTracks: trackReverseReferenceList({
       list: input.value('sampledTracks'),
     }),
@@ -386,344 +320,3 @@ export class Track extends Thing {
     return parts.join('');
   }
 }
-
-// Early exits with a value inherited from the original release, if
-// this track is a rerelease, and otherwise continues with no further
-// dependencies provided. If allowOverride is true, then the continuation
-// will also be called if the original release exposed the requested
-// property as null.
-export const inheritFromOriginalRelease = templateCompositeFrom({
-  annotation: `Track.inheritFromOriginalRelease`,
-
-  inputs: {
-    property: input({type: 'string'}),
-    allowOverride: input({type: 'boolean', defaultValue: false}),
-  },
-
-  steps: () => [
-    withOriginalRelease(),
-
-    {
-      dependencies: [
-        '#originalRelease',
-        input('property'),
-        input('allowOverride'),
-      ],
-
-      compute: (continuation, {
-        ['#originalRelease']: originalRelease,
-        [input('property')]: originalProperty,
-        [input('allowOverride')]: allowOverride,
-      }) => {
-        if (!originalRelease) return continuation();
-
-        const value = originalRelease[originalProperty];
-        if (allowOverride && value === null) return continuation();
-
-        return continuation.exit(value);
-      },
-    },
-  ],
-});
-
-// Gets the track's album. This will early exit if albumData is missing.
-// By default, if there's no album whose list of tracks includes this track,
-// the output dependency will be null; set {notFoundMode: 'exit'} to early
-// exit instead.
-export const withAlbum = templateCompositeFrom({
-  annotation: `Track.withAlbum`,
-
-  inputs: {
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
-  outputs: ['#album'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: 'albumData',
-      mode: input.value('empty'),
-      output: input.value({
-        ['#album']: null,
-      }),
-    }),
-
-    {
-      dependencies: [input.myself(), 'albumData'],
-      compute: (continuation, {
-        [input.myself()]: track,
-        ['albumData']: albumData,
-      }) =>
-        continuation({
-          ['#album']:
-            albumData.find(album => album.tracks.includes(track)),
-        }),
-    },
-
-    raiseOutputWithoutDependency({
-      dependency: '#album',
-      output: input.value({
-        ['#album']: null,
-      }),
-    }),
-
-    {
-      dependencies: ['#album'],
-      compute: (continuation, {'#album': album}) =>
-        continuation.raiseOutput({'#album': album}),
-    },
-  ],
-});
-
-// Gets a single property from this track's album, providing it as the same
-// property name prefixed with '#album.' (by default). If the track's album
-// isn't available, then by default, the property will be provided as null;
-// set {notFoundMode: 'exit'} to early exit instead.
-export const withPropertyFromAlbum = templateCompositeFrom({
-  annotation: `withPropertyFromAlbum`,
-
-  inputs: {
-    property: input.staticValue({type: 'string'}),
-
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
-  outputs: ({
-    [input.staticValue('property')]: property,
-  }) => ['#album.' + property],
-
-  steps: () => [
-    withAlbum({
-      notFoundMode: input('notFoundMode'),
-    }),
-
-    withPropertyFromObject({
-      object: '#album',
-      property: input('property'),
-    }),
-
-    {
-      dependencies: ['#value', input.staticValue('property')],
-      compute: (continuation, {
-        ['#value']: value,
-        [input.staticValue('property')]: property,
-      }) => continuation({
-        ['#album.' + property]: value,
-      }),
-    },
-  ],
-});
-
-// Gets the track section containing this track from its album's track list.
-// If notFoundMode is set to 'exit', this will early exit if the album can't be
-// found or if none of its trackSections includes the track for some reason.
-export const withContainingTrackSection = templateCompositeFrom({
-  annotation: `withContainingTrackSection`,
-
-  inputs: {
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
-  outputs: ['#trackSection'],
-
-  steps: () => [
-    withPropertyFromAlbum({
-      property: input.value('trackSections'),
-      notFoundMode: input('notFoundMode'),
-    }),
-
-    {
-      dependencies: [
-        input.myself(),
-        input('notFoundMode'),
-        '#album.trackSections',
-      ],
-
-      compute(continuation, {
-        [input.myself()]: track,
-        [input('notFoundMode')]: notFoundMode,
-        ['#album.trackSections']: trackSections,
-      }) {
-        if (!trackSections) {
-          return continuation.raiseOutput({
-            ['#trackSection']: null,
-          });
-        }
-
-        const trackSection =
-          trackSections.find(({tracks}) => tracks.includes(track));
-
-        if (trackSection) {
-          return continuation.raiseOutput({
-            ['#trackSection']: trackSection,
-          });
-        } else if (notFoundMode === 'exit') {
-          return continuation.exit(null);
-        } else {
-          return continuation.raiseOutput({
-            ['#trackSection']: null,
-          });
-        }
-      },
-    },
-  ],
-});
-
-// Just includes the original release of this track as a dependency.
-// If this track isn't a rerelease, then it'll provide null, unless the
-// {selfIfOriginal} option is set, in which case it'll provide this track
-// itself. Note that this will early exit if the original release is
-// specified by reference and that reference doesn't resolve to anything.
-// Outputs to '#originalRelease' by default.
-export const withOriginalRelease = templateCompositeFrom({
-  annotation: `withOriginalRelease`,
-
-  inputs: {
-    selfIfOriginal: input({type: 'boolean', defaultValue: false}),
-
-    data: input({
-      validate: validateWikiData({referenceType: 'track'}),
-      defaultDependency: 'trackData',
-    }),
-  },
-
-  outputs: ['#originalRelease'],
-
-  steps: () => [
-    withResolvedReference({
-      ref: 'originalReleaseTrack',
-      data: input('data'),
-      find: input.value(find.track),
-      notFoundMode: input.value('exit'),
-    }).outputs({
-      ['#resolvedReference']: '#originalRelease',
-    }),
-
-    {
-      dependencies: [
-        input.myself(),
-        input('selfIfOriginal'),
-        '#originalRelease',
-      ],
-
-      compute: (continuation, {
-        [input.myself()]: track,
-        [input('selfIfOriginal')]: selfIfOriginal,
-        ['#originalRelease']: originalRelease,
-      }) =>
-        continuation({
-          ['#originalRelease']:
-            (originalRelease ??
-              (selfIfOriginal
-                ? track
-                : null)),
-        }),
-    },
-  ],
-});
-
-// The algorithm for checking if a track has unique cover art is used in a
-// couple places, so it's defined in full as a compositional step.
-export const withHasUniqueCoverArt = templateCompositeFrom({
-  annotation: 'withHasUniqueCoverArt',
-
-  outputs: ['#hasUniqueCoverArt'],
-
-  steps: () => [
-    {
-      dependencies: ['disableUniqueCoverArt'],
-      compute: (continuation, {disableUniqueCoverArt}) =>
-        (disableUniqueCoverArt
-          ? continuation.raiseOutput({
-              ['#hasUniqueCoverArt']: false,
-            })
-          : continuation()),
-    },
-
-    withResolvedContribs({from: 'coverArtistContribs'}),
-
-    {
-      dependencies: ['#resolvedContribs'],
-      compute: (continuation, {
-        ['#resolvedContribs']: contribsFromTrack,
-      }) =>
-        (empty(contribsFromTrack)
-          ? continuation()
-          : continuation.raiseOutput({
-              ['#hasUniqueCoverArt']: true,
-            })),
-    },
-
-    withPropertyFromAlbum({
-      property: input.value('trackCoverArtistContribs'),
-    }),
-
-    {
-      dependencies: ['#album.trackCoverArtistContribs'],
-      compute: (continuation, {
-        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
-      }) =>
-        continuation.raiseOutput({
-          ['#hasUniqueCoverArt']:
-            !empty(contribsFromAlbum),
-        }),
-    },
-  ],
-});
-
-// Shorthand for checking if the track has unique cover art and exposing a
-// fallback value if it isn't.
-export const exitWithoutUniqueCoverArt = templateCompositeFrom({
-  annotation: `exitWithoutUniqueCoverArt`,
-
-  inputs: {
-    value: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    withHasUniqueCoverArt(),
-
-    exitWithoutDependency({
-      dependency: '#hasUniqueCoverArt',
-      mode: input.value('falsy'),
-      value: input('value'),
-    }),
-  ],
-});
-
-export const trackReverseReferenceList = templateCompositeFrom({
-  annotation: `trackReverseReferenceList`,
-
-  compose: false,
-
-  inputs: {
-    list: input({type: 'string'}),
-  },
-
-  steps: () => [
-    withReverseReferenceList({
-      data: 'trackData',
-      list: input('list'),
-    }),
-
-    {
-      flags: {expose: true},
-      expose: {
-        dependencies: ['#reverseReferenceList'],
-        compute: ({
-          ['#reverseReferenceList']: reverseReferenceList,
-        }) =>
-          reverseReferenceList.filter(track => !track.originalReleaseTrack),
-      },
-    },
-  ],
-});
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index c764b528..0460f272 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -2,14 +2,16 @@ import {input} from '#composite';
 import find from '#find';
 import {isLanguageCode, isName, isURL} from '#validators';
 
-import Thing, {
+import {
   color,
   flag,
   name,
   referenceList,
   simpleString,
   wikiData,
-} from './thing.js';
+} from '#composite/wiki-properties';
+
+import Thing from './thing.js';
 
 export class WikiInfo extends Thing {
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
diff --git a/test/unit/data/composite/common-utilities/exposeConstant.js b/test/unit/data/composite/control-flow/exposeConstant.js
index bfed0951..0c75894b 100644
--- a/test/unit/data/composite/common-utilities/exposeConstant.js
+++ b/test/unit/data/composite/control-flow/exposeConstant.js
@@ -1,11 +1,7 @@
 import t from 'tap';
 
-import {
-  compositeFrom,
-  continuationSymbol,
-  exposeConstant,
-  input,
-} from '#composite';
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {exposeConstant} from '#composite/control-flow';
 
 t.test(`exposeConstant: basic behavior`, t => {
   t.plan(2);
diff --git a/test/unit/data/composite/common-utilities/exposeDependency.js b/test/unit/data/composite/control-flow/exposeDependency.js
index 4f07cc16..8f6bfd01 100644
--- a/test/unit/data/composite/common-utilities/exposeDependency.js
+++ b/test/unit/data/composite/control-flow/exposeDependency.js
@@ -1,11 +1,7 @@
 import t from 'tap';
 
-import {
-  compositeFrom,
-  continuationSymbol,
-  exposeDependency,
-  input,
-} from '#composite';
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
 
 t.test(`exposeDependency: basic behavior`, t => {
   t.plan(4);
diff --git a/test/unit/data/composite/common-utilities/withResultOfAvailabilityCheck.js b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
index 50c127d3..4c4be04a 100644
--- a/test/unit/data/composite/common-utilities/withResultOfAvailabilityCheck.js
+++ b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -1,11 +1,7 @@
 import t from 'tap';
 
-import {
-  compositeFrom,
-  continuationSymbol,
-  input,
-  withResultOfAvailabilityCheck,
-} from '#composite';
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
 
 const composite = compositeFrom({
   compose: false,
diff --git a/test/unit/data/composite/common-utilities/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js
index 3431382e..ead1b9b2 100644
--- a/test/unit/data/composite/common-utilities/withPropertiesFromObject.js
+++ b/test/unit/data/composite/data/withPropertiesFromObject.js
@@ -1,11 +1,8 @@
 import t from 'tap';
 
-import {
-  compositeFrom,
-  exposeDependency,
-  input,
-  withPropertiesFromObject,
-} from '#composite';
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withPropertiesFromObject} from '#composite/data';
 
 const composite = compositeFrom({
   compose: false,
diff --git a/test/unit/data/composite/common-utilities/withPropertyFromObject.js b/test/unit/data/composite/data/withPropertyFromObject.js
index 11487226..6a772c36 100644
--- a/test/unit/data/composite/common-utilities/withPropertyFromObject.js
+++ b/test/unit/data/composite/data/withPropertyFromObject.js
@@ -1,11 +1,8 @@
 import t from 'tap';
 
-import {
-  compositeFrom,
-  exposeDependency,
-  input,
-  withPropertyFromObject,
-} from '#composite';
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
 
 t.test(`withPropertyFromObject: basic behavior`, t => {
   t.plan(4);