« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/composite
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/composite')
-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/exposeWhetherDependencyAvailable.js42
-rw-r--r--src/data/composite/control-flow/helpers/performAvailabilityCheck.js19
-rw-r--r--src/data/composite/control-flow/index.js16
-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/withAvailabilityFilter.js40
-rw-r--r--src/data/composite/control-flow/withResultOfAvailabilityCheck.js54
-rw-r--r--src/data/composite/data/excludeFromList.js50
-rw-r--r--src/data/composite/data/fillMissingListItems.js45
-rw-r--r--src/data/composite/data/index.js35
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js41
-rw-r--r--src/data/composite/data/withIndexInList.js38
-rw-r--r--src/data/composite/data/withMappedList.js49
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js73
-rw-r--r--src/data/composite/data/withPropertiesFromList.js86
-rw-r--r--src/data/composite/data/withPropertiesFromObject.js87
-rw-r--r--src/data/composite/data/withPropertyFromList.js94
-rw-r--r--src/data/composite/data/withPropertyFromObject.js89
-rw-r--r--src/data/composite/data/withSortedList.js115
-rw-r--r--src/data/composite/data/withStretchedList.js36
-rw-r--r--src/data/composite/data/withSum.js33
-rw-r--r--src/data/composite/data/withUnflattenedList.js66
-rw-r--r--src/data/composite/data/withUniqueItemsOnly.js40
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/album/withTracks.js29
-rw-r--r--src/data/composite/things/art-tag/index.js2
-rw-r--r--src/data/composite/things/art-tag/withAllDescendantArtTags.js44
-rw-r--r--src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js46
-rw-r--r--src/data/composite/things/artist/artistTotalDuration.js69
-rw-r--r--src/data/composite/things/artist/index.js1
-rw-r--r--src/data/composite/things/artwork/index.js1
-rw-r--r--src/data/composite/things/artwork/withDate.js41
-rw-r--r--src/data/composite/things/contribution/index.js7
-rw-r--r--src/data/composite/things/contribution/inheritFromContributionPresets.js61
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js46
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js66
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js80
-rw-r--r--src/data/composite/things/contribution/withContributionArtist.js26
-rw-r--r--src/data/composite/things/contribution/withContributionContext.js45
-rw-r--r--src/data/composite/things/contribution/withMatchingContributionPresets.js70
-rw-r--r--src/data/composite/things/flash-act/index.js1
-rw-r--r--src/data/composite/things/flash-act/withFlashSide.js22
-rw-r--r--src/data/composite/things/flash/index.js1
-rw-r--r--src/data/composite/things/flash/withFlashAct.js22
-rw-r--r--src/data/composite/things/track-section/index.js3
-rw-r--r--src/data/composite/things/track-section/withAlbum.js20
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js25
-rw-r--r--src/data/composite/things/track-section/withStartCountingFrom.js64
-rw-r--r--src/data/composite/things/track/exitWithoutUniqueCoverArt.js26
-rw-r--r--src/data/composite/things/track/index.js17
-rw-r--r--src/data/composite/things/track/inheritContributionListFromMainRelease.js44
-rw-r--r--src/data/composite/things/track/inheritFromMainRelease.js41
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAllReleases.js47
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js97
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js20
-rw-r--r--src/data/composite/things/track/withCoverArtistContribs.js73
-rw-r--r--src/data/composite/things/track/withDate.js34
-rw-r--r--src/data/composite/things/track/withDirectorySuffix.js36
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js108
-rw-r--r--src/data/composite/things/track/withMainRelease.js70
-rw-r--r--src/data/composite/things/track/withOtherReleases.js30
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js48
-rw-r--r--src/data/composite/things/track/withPropertyFromMainRelease.js86
-rw-r--r--src/data/composite/things/track/withSuffixDirectoryFromAlbum.js53
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js60
-rw-r--r--src/data/composite/things/track/withTrackNumber.js50
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js48
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyFind.js39
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyReverse.js39
-rw-r--r--src/data/composite/wiki-data/helpers/withDirectoryFromName.js41
-rw-r--r--src/data/composite/wiki-data/helpers/withResolvedReverse.js40
-rw-r--r--src/data/composite/wiki-data/helpers/withSimpleDirectory.js52
-rw-r--r--src/data/composite/wiki-data/index.js32
-rw-r--r--src/data/composite/wiki-data/inputNotFoundMode.js9
-rw-r--r--src/data/composite/wiki-data/inputSoupyFind.js28
-rw-r--r--src/data/composite/wiki-data/inputSoupyReverse.js32
-rw-r--r--src/data/composite/wiki-data/inputWikiData.js17
-rw-r--r--src/data/composite/wiki-data/processContentEntryDates.js181
-rw-r--r--src/data/composite/wiki-data/raiseResolvedReferenceList.js96
-rw-r--r--src/data/composite/wiki-data/withClonedThings.js68
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js57
-rw-r--r--src/data/composite/wiki-data/withContributionListSums.js95
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js51
-rw-r--r--src/data/composite/wiki-data/withDirectory.js62
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js129
-rw-r--r--src/data/composite/wiki-data/withParsedContentEntries.js111
-rw-r--r--src/data/composite/wiki-data/withParsedLyricsEntries.js157
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js100
-rw-r--r--src/data/composite/wiki-data/withRedatedContributionList.js127
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js100
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js156
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js57
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js80
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js36
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js36
-rw-r--r--src/data/composite/wiki-properties/additionalFiles.js30
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/annotatedReferenceList.js64
-rw-r--r--src/data/composite/wiki-properties/color.js12
-rw-r--r--src/data/composite/wiki-properties/commentary.js34
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js49
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js68
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js70
-rw-r--r--src/data/composite/wiki-properties/contentString.js15
-rw-r--r--src/data/composite/wiki-properties/contribsPresent.js30
-rw-r--r--src/data/composite/wiki-properties/contributionList.js58
-rw-r--r--src/data/composite/wiki-properties/dimensions.js13
-rw-r--r--src/data/composite/wiki-properties/directory.js41
-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/helpers/reference-list-helpers.js44
-rw-r--r--src/data/composite/wiki-properties/index.js38
-rw-r--r--src/data/composite/wiki-properties/lyrics.js36
-rw-r--r--src/data/composite/wiki-properties/name.js11
-rw-r--r--src/data/composite/wiki-properties/referenceList.js46
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js32
-rw-r--r--src/data/composite/wiki-properties/reverseReferenceList.js30
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/composite/wiki-properties/simpleDate.js14
-rw-r--r--src/data/composite/wiki-properties/simpleString.js12
-rw-r--r--src/data/composite/wiki-properties/singleReference.js46
-rw-r--r--src/data/composite/wiki-properties/soupyFind.js14
-rw-r--r--src/data/composite/wiki-properties/soupyReverse.js37
-rw-r--r--src/data/composite/wiki-properties/thing.js40
-rw-r--r--src/data/composite/wiki-properties/thingList.js44
-rw-r--r--src/data/composite/wiki-properties/urls.js14
-rw-r--r--src/data/composite/wiki-properties/wallpaperParts.js9
-rw-r--r--src/data/composite/wiki-properties/wikiData.js27
142 files changed, 6723 insertions, 0 deletions
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..e76699c5
--- /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({acceptsNull: true}),
+  },
+
+  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/exposeWhetherDependencyAvailable.js b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js
new file mode 100644
index 00000000..a2fdd6b0
--- /dev/null
+++ b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js
@@ -0,0 +1,42 @@
+// Exposes true if a dependency is available, and false otherwise,
+// or the reverse if the `negate` input is set true.
+//
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeWhetherDependencyAvailable`,
+
+  compose: false,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+
+    mode: inputAvailabilityCheckMode(),
+
+    negate: input({type: 'boolean', defaultValue: false}),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('negate')],
+
+      compute: ({
+        ['#availability']: availability,
+        [input('negate')]: negate,
+      }) =>
+        (negate
+          ? !availability
+          : availability),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/helpers/performAvailabilityCheck.js b/src/data/composite/control-flow/helpers/performAvailabilityCheck.js
new file mode 100644
index 00000000..0e44ab59
--- /dev/null
+++ b/src/data/composite/control-flow/helpers/performAvailabilityCheck.js
@@ -0,0 +1,19 @@
+import {empty} from '#sugar';
+
+export default function performAvailabilityCheck(value, mode) {
+  switch (mode) {
+    case 'null':
+      return value !== undefined && value !== null;
+
+    case 'empty':
+      return value !== undefined && !empty(value);
+
+    case 'falsy':
+      return !!value && (!Array.isArray(value) || !empty(value));
+
+    case 'index':
+      return typeof value === 'number' && value >= 0;
+  }
+
+  return undefined;
+}
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
new file mode 100644
index 00000000..7e137a14
--- /dev/null
+++ b/src/data/composite/control-flow/index.js
@@ -0,0 +1,16 @@
+// #composite/control-flow
+//
+// No entries depend on any other entries, except siblings in this directory.
+//
+
+export {default as exitWithoutDependency} from './exitWithoutDependency.js';
+export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js';
+export {default as exposeConstant} from './exposeConstant.js';
+export {default as exposeDependency} from './exposeDependency.js';
+export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
+export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
+export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js';
+export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
+export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
+export {default as withAvailabilityFilter} from './withAvailabilityFilter.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..8008fdeb
--- /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', 'index'),
+    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/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js
new file mode 100644
index 00000000..cfea998e
--- /dev/null
+++ b/src/data/composite/control-flow/withAvailabilityFilter.js
@@ -0,0 +1,40 @@
+// Performs the same availability check across all items of a list, providing
+// a list that's suitable anywhere a filter is expected.
+//
+// Accepts the same mode options as withResultOfAvailabilityCheck.
+//
+// See also:
+//  - withFilteredList
+//  - withResultOfAvailabilityCheck
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+import performAvailabilityCheck from './helpers/performAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `withAvailabilityFilter`,
+
+  inputs: {
+    from: input({type: 'array'}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  outputs: ['#availabilityFilter'],
+
+  steps: () => [
+    {
+      dependencies: [input('from'), input('mode')],
+      compute: (continuation, {
+        [input('from')]: list,
+        [input('mode')]: mode,
+      }) => continuation({
+        ['#availabilityFilter']:
+          list.map(value =>
+            performAvailabilityCheck(value, mode)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
new file mode 100644
index 00000000..c5221a62
--- /dev/null
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -0,0 +1,54 @@
+// 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!
+// * 'index': Check that the value is a number, and is at least zero.
+//
+// See also:
+//  - exitWithoutDependency
+//  - exitWithoutUpdateValue
+//  - exposeDependencyOrContinue
+//  - exposeUpdateValueOrContinue
+//  - exposeWhetherDependencyAvailable
+//  - raiseOutputWithoutDependency
+//  - raiseOutputWithoutUpdateValue
+//  - withAvailabilityFilter
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+import performAvailabilityCheck from './helpers/performAvailabilityCheck.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,
+      }) => continuation({
+        ['#availability']:
+          performAvailabilityCheck(value, mode),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
new file mode 100644
index 00000000..2a3e818e
--- /dev/null
+++ b/src/data/composite/data/excludeFromList.js
@@ -0,0 +1,50 @@
+// 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
+//
+
+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..356b1119
--- /dev/null
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -0,0 +1,45 @@
+// Replaces items of a list, which are null or undefined, with some fallback
+// value. By default, this replaces the passed dependency.
+//
+// See also:
+//  - excludeFromList
+//
+
+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..46a3dc81
--- /dev/null
+++ b/src/data/composite/data/index.js
@@ -0,0 +1,35 @@
+// #composite/data
+//
+// Entries here may depend on entries in #composite/control-flow.
+//
+
+// Utilities which act on generic objects
+
+export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
+export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+
+// Utilities which act on generic lists
+
+export {default as excludeFromList} from './excludeFromList.js';
+
+export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
+
+export {default as withFilteredList} from './withFilteredList.js';
+export {default as withMappedList} from './withMappedList.js';
+export {default as withSortedList} from './withSortedList.js';
+export {default as withStretchedList} from './withStretchedList.js';
+
+export {default as withPropertyFromList} from './withPropertyFromList.js';
+export {default as withPropertiesFromList} from './withPropertiesFromList.js';
+
+export {default as withFlattenedList} from './withFlattenedList.js';
+export {default as withUnflattenedList} from './withUnflattenedList.js';
+
+export {default as withIndexInList} from './withIndexInList.js';
+export {default as withNearbyItemFromList} from './withNearbyItemFromList.js';
+
+// Utilities which act on slightly more particular data forms
+// (probably, containers of particular kinds of values)
+
+export {default as withSum} from './withSum.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
new file mode 100644
index 00000000..44c1661d
--- /dev/null
+++ b/src/data/composite/data/withFilteredList.js
@@ -0,0 +1,50 @@
+// Applies a filter - an array of truthy and falsy values - to the index-
+// corresponding items in a list. Items which correspond to a truthy value
+// are kept, and the rest are excluded from the output list.
+//
+// If the flip option is set, only items corresponding with a *falsy* value in
+// the filter are kept.
+//
+// TODO: There should be two outputs - one for the items included according to
+// the filter, and one for the items excluded.
+//
+// See also:
+//  - withAvailabilityFilter
+//  - withMappedList
+//  - withSortedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+
+    flip: input({
+      type: 'boolean',
+      defaultValue: false,
+    }),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter'), input('flip')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+        [input('flip')]: flip,
+      }) => continuation({
+        '#filteredList':
+          list.filter((_item, index) =>
+            (flip
+              ? !filter[index]
+              :  filter[index])),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
new file mode 100644
index 00000000..31b1a742
--- /dev/null
+++ b/src/data/composite/data/withFlattenedList.js
@@ -0,0 +1,41 @@
+// 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:
+//  - withUnflattenedList
+//
+
+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/withIndexInList.js b/src/data/composite/data/withIndexInList.js
new file mode 100644
index 00000000..b1af2033
--- /dev/null
+++ b/src/data/composite/data/withIndexInList.js
@@ -0,0 +1,38 @@
+// Gets the index of the provided item in the provided list. Note that this
+// will output -1 if the item is not found, and this may be detected using
+// any availability check with type: 'index'. If the list includes the item
+// twice, the output index will be of the first match.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withNearbyItemFromList
+//  - exitWithoutDependency
+//  - raiseOutputWithoutDependency
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withIndexInList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+  },
+
+  outputs: ['#index'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('item')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('item')]: item,
+      }) => continuation({
+        ['#index']:
+          list.indexOf(item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
new file mode 100644
index 00000000..cd32058e
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,49 @@
+// Applies a map function to each item in a list, just like a normal JavaScript
+// map.
+//
+// Pass a filter (e.g. from withAvailabilityFilter) to process only items
+// kept by the filter. Other items will be left as-is.
+//
+// See also:
+//  - withFilteredList
+//  - withSortedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+
+    filter: input({
+      type: 'array',
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+        [input('filter')]: filter,
+      }) => continuation({
+        ['#mappedList']:
+          stitchArrays({
+            item: list,
+            keep: filter ?? Array.from(list, () => true),
+          }).map(({item, keep}, index) =>
+              (keep
+                ? mapFn(item, index, list)
+                : item)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
new file mode 100644
index 00000000..83a8cc21
--- /dev/null
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -0,0 +1,73 @@
+// Gets a nearby (typically adjacent) item in a list, meaning the item which is
+// placed at a particular offset compared to the provided item. This is null if
+// the provided list doesn't include the provided item at all, and also if the
+// offset would read past either end of the list - except if configured:
+//
+//  - If the 'wrap' input is provided (as true), the offset will loop around
+//    and continue from the opposing end.
+//
+//  - If the 'valuePastEdge' input is provided, that value will be output
+//    instead of null.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withIndexInList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {atOffset} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withIndexInList from './withIndexInList.js';
+
+export default templateCompositeFrom({
+  annotation: `withNearbyItemFromList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+
+    offset: input({type: 'number'}),
+    wrap: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ['#nearbyItem'],
+
+  steps: () => [
+    withIndexInList({
+      list: input('list'),
+      item: input('item'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      mode: input.value('index'),
+
+      output: input.value({
+        ['#nearbyItem']:
+          null,
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('list'),
+        input('offset'),
+        input('wrap'),
+        '#index',
+      ],
+
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('offset')]: offset,
+        [input('wrap')]: wrap,
+        ['#index']: index,
+      }) => continuation({
+        ['#nearbyItem']:
+          atOffset(list, index, offset, {wrap}),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
new file mode 100644
index 00000000..fb4134bc
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -0,0 +1,86 @@
+// 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
+//
+
+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..760095c2
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -0,0 +1,94 @@
+// 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.
+//
+// If the `internal` input is true, this reads the CacheableObject update value
+// of each object rather than its exposed value.
+//
+// See also:
+//  - withPropertiesFromList
+//  - withPropertyFromObject
+//
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+
+function getOutputName({list, property, prefix}) {
+  if (!property) return `#values`;
+  if (prefix) return `${prefix}.${property}`;
+  if (list) return `${list}.${property}`;
+  return `#list.${property}`;
+}
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    property: input({type: 'string'}),
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+    [input.staticValue('property')]: property,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    [getOutputName({list, property, prefix})],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('list'),
+        input('property'),
+        input('internal'),
+      ],
+
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('property')]: property,
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#values']:
+          list.map(item =>
+            (item === null
+              ? null
+           : internal
+              ? CacheableObject.getUpdateValue(item, property)
+                  ?? null
+              : item[property]
+                  ?? null)),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input.staticValue('property'),
+        input.staticValue('prefix'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        [input.staticValue('property')]: property,
+        [input.staticValue('prefix')]: prefix,
+      }) => continuation({
+        ['#outputName']:
+          getOutputName({list, property, prefix}),
+      }),
+    },
+
+    {
+      dependencies: ['#values', '#outputName'],
+      compute: (continuation, {
+        ['#values']: values,
+        ['#outputName']: outputName,
+      }) =>
+        continuation.raiseOutput({[outputName]: values}),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
new file mode 100644
index 00000000..4f240506
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -0,0 +1,89 @@
+// 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.
+//
+// If the `internal` input is true, this reads the CacheableObject update value
+// of the object rather than its exposed value.
+//
+// See also:
+//  - withPropertiesFromObject
+//  - withPropertyFromList
+//
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  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: [
+        input('object'),
+        input('property'),
+        input('internal'),
+      ],
+
+      compute: (continuation, {
+        [input('object')]: object,
+        [input('property')]: property,
+        [input('internal')]: internal,
+      }) => continuation({
+        '#value':
+          (object === null
+            ? null
+         : internal
+            ? CacheableObject.getUpdateValue(object, property)
+                ?? null
+            : object[property]
+                ?? null),
+      }),
+    },
+
+    {
+      dependencies: ['#output', '#value'],
+
+      compute: (continuation, {
+        ['#output']: output,
+        ['#value']: value,
+      }) => continuation({
+        [output]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
new file mode 100644
index 00000000..a7d21768
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,115 @@
+// Applies a sort function across pairs of items in a list, just like a normal
+// JavaScript sort. Alongside the sorted results, so are outputted the indices
+// which each item in the unsorted list corresponds to in the sorted one,
+// allowing for the results of this sort to be composed in some more involved
+// operation. For example, using an alphabetical sort, the list ['banana',
+// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well
+// as the indices list [1, 0, 2].
+//
+// If two items are equal (in the eyes of the sort operation), their placement
+// in the sorted list is arbitrary, though every input index will be present in
+// '#sortIndices' exactly once (and equal items will be bunched together).
+//
+// The '#sortIndices' output refers to the "true" index which each source item
+// occupies in the sorted list. This sacrifices information about equal items,
+// which can be obtained through '#unstableSortIndices' instead: each mapped
+// index may appear more than once, and rather than represent exact positions
+// in the sorted list, they represent relational values: if items A and B are
+// mapped to indices 3 and 5, then A certainly is positioned before B (and vice
+// versa); but there may be more than one item in-between. If items C and D are
+// both mapped to index 4, then their position relative to each other is
+// arbitrary - they are equal - but they both certainly appear after item A and
+// before item B.
+//
+// This implementation is based on the one used for sortMultipleArrays.
+//
+// See also:
+//  - withFilteredList
+//  - withMappedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withSortedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    sort: input({type: 'function'}),
+  },
+
+  outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('sort')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('sort')]: sortFn,
+      }) {
+        const symbols = [];
+        const symbolToIndex = new Map();
+
+        for (const index of list.keys()) {
+          const symbol = Symbol();
+          symbols.push(symbol);
+          symbolToIndex.set(symbol, index);
+        }
+
+        const equalSymbols = new Map();
+
+        const assertEqual = (symbol1, symbol2) => {
+          if (equalSymbols.has(symbol1)) {
+            equalSymbols.get(symbol1).add(symbol2);
+          } else {
+            equalSymbols.set(symbol1, new Set([symbol2]));
+          }
+        };
+
+        const isEqual = (symbol1, symbol2) =>
+          !!equalSymbols.get(symbol1)?.has(symbol2);
+
+        symbols.sort((symbol1, symbol2) => {
+          const comparison =
+            sortFn(
+              list[symbolToIndex.get(symbol1)],
+              list[symbolToIndex.get(symbol2)]);
+
+          if (comparison === 0) {
+            assertEqual(symbol1, symbol2);
+            assertEqual(symbol2, symbol1);
+          }
+
+          return comparison;
+        });
+
+        const stableSortIndices = [];
+        const unstableSortIndices = [];
+        const sortedList = [];
+
+        let unstableIndex = 0;
+
+        for (const [stableIndex, symbol] of symbols.entries()) {
+          const sourceIndex = symbolToIndex.get(symbol);
+          sortedList.push(list[sourceIndex]);
+
+          if (stableIndex > 0) {
+            const previous = symbols[stableIndex - 1];
+            if (!isEqual(symbol, previous)) {
+              unstableIndex++;
+            }
+          }
+
+          stableSortIndices[sourceIndex] = stableIndex;
+          unstableSortIndices[sourceIndex] = unstableIndex;
+        }
+
+        return continuation({
+          ['#sortedList']: sortedList,
+          ['#sortIndices']: stableSortIndices,
+          ['#unstableSortIndices']: unstableSortIndices,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withStretchedList.js b/src/data/composite/data/withStretchedList.js
new file mode 100644
index 00000000..46733064
--- /dev/null
+++ b/src/data/composite/data/withStretchedList.js
@@ -0,0 +1,36 @@
+// Repeats each item in a list in-place by a corresponding length.
+
+import {input, templateCompositeFrom} from '#composite';
+import {repeat, stitchArrays} from '#sugar';
+import {isNumber, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withStretchedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+
+    lengths: input({
+      validate: validateArrayItems(isNumber),
+    }),
+  },
+
+  outputs: ['#stretchedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('lengths')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('lengths')]: lengths,
+      }) => continuation({
+        ['#stretchedList']:
+          stitchArrays({
+            item: list,
+            length: lengths,
+          }).map(({item, length}) => repeat(length, [item]))
+            .flat(),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withSum.js b/src/data/composite/data/withSum.js
new file mode 100644
index 00000000..484e9906
--- /dev/null
+++ b/src/data/composite/data/withSum.js
@@ -0,0 +1,33 @@
+// Gets the numeric total of adding all the values in a list together.
+// Values that are false, null, or undefined are skipped over.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isNumber, sparseArrayOf} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withSum`,
+
+  inputs: {
+    values: input({
+      validate: sparseArrayOf(isNumber),
+    }),
+  },
+
+  outputs: ['#sum'],
+
+  steps: () => [
+    {
+      dependencies: [input('values')],
+      compute: (continuation, {
+        [input('values')]: values,
+      }) => continuation({
+        ['#sum']:
+          values
+            .filter(item => typeof item === 'number')
+            .reduce(
+              (accumulator, value) => accumulator + value,
+              0),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
new file mode 100644
index 00000000..820d628a
--- /dev/null
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -0,0 +1,66 @@
+// 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).
+//
+// See also:
+//  - withFlattenedList
+//
+
+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/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..7ee08b08
--- /dev/null
+++ b/src/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,40 @@
+// Excludes duplicate items from a list and provides the results, overwriting
+// the list in-place, if possible.
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueItemsOnly`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#uniqueItems'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#values']:
+          unique(list),
+      }),
+    },
+
+    {
+      dependencies: ['#values', input.staticDependency('list')],
+      compute: (continuation, {
+        '#values': values,
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        [list ?? '#uniqueItems']:
+          values,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
new file mode 100644
index 00000000..dfc6864f
--- /dev/null
+++ b/src/data/composite/things/album/index.js
@@ -0,0 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
+export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
new file mode 100644
index 00000000..fd3f2894
--- /dev/null
+++ b/src/data/composite/things/album/withHasCoverArt.js
@@ -0,0 +1,64 @@
+// TODO: This shouldn't be coded as an Album-specific thing,
+// or even really to do with cover artworks in particular, either.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: 'withHasCoverArt',
+
+  outputs: ['#hasCoverArt'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'coverArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'coverArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#coverArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#coverArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
new file mode 100644
index 00000000..835ee570
--- /dev/null
+++ b/src/data/composite/things/album/withTracks.js
@@ -0,0 +1,29 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withTracks`,
+
+  outputs: ['#tracks'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'trackSections',
+      output: input.value({'#tracks': []}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackSections',
+      property: input.value('tracks'),
+    }),
+
+    withFlattenedList({
+      list: '#trackSections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#tracks',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js
new file mode 100644
index 00000000..bbd38293
--- /dev/null
+++ b/src/data/composite/things/art-tag/index.js
@@ -0,0 +1,2 @@
+export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js';
+export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js';
diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
new file mode 100644
index 00000000..795f96cd
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
@@ -0,0 +1,44 @@
+// Gets all the art tags which descend from this one - that means its own direct
+// descendants, but also all the direct and indirect desceands of each of those!
+// The results aren't specially sorted, but they won't contain any duplicates
+// (for example if two descendant tags both route deeper to end up including
+// some of the same tags).
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAllDescendantArtTags`,
+
+  outputs: ['#allDescendantArtTags'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'directDescendantArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#allDescendantArtTags': []})
+    }),
+
+    withResolvedReferenceList({
+      list: 'directDescendantArtTags',
+      find: soupyFind.input('artTag'),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: directDescendantArtTags,
+      }) => continuation({
+        ['#allDescendantArtTags']:
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      }),
+    },
+  ],
+})
diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
new file mode 100644
index 00000000..e084a42b
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
@@ -0,0 +1,46 @@
+// Gets all the art tags which are ancestors of this one as a "baobab tree" -
+// what you'd typically think of as roots are all up in the air! Since this
+// really is backwards from the way that the art tag tree is written in data,
+// chances are pretty good that there will be many of the exact same "leaf"
+// nodes - art tags which don't themselves have any ancestors. In the actual
+// data structure, each node is a Map, with keys for each ancestor and values
+// for each ancestor's own baobab (thus a branching structure, just like normal
+// trees in this regard).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAncestorArtTagBaobabTree`,
+
+  outputs: ['#ancestorArtTagBaobabTree'],
+
+  steps: () => [
+    withReverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }).outputs({
+      ['#reverseReferenceList']: '#directAncestorArtTags',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directAncestorArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#ancestorArtTagBaobabTree': new Map()}),
+    }),
+
+    {
+      dependencies: ['#directAncestorArtTags'],
+      compute: (continuation, {
+        ['#directAncestorArtTags']: directAncestorArtTags,
+      }) => continuation({
+        ['#ancestorArtTagBaobabTree']:
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js
new file mode 100644
index 00000000..b8a205fe
--- /dev/null
+++ b/src/data/composite/things/artist/artistTotalDuration.js
@@ -0,0 +1,69 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withContributionListSums, withReverseReferenceList}
+  from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `artistTotalDuration`,
+
+  compose: false,
+
+  steps: () => [
+    withReverseReferenceList({
+      reverse: soupyReverse.input('trackArtistContributionsBy'),
+    }).outputs({
+      '#reverseReferenceList': '#contributionsAsArtist',
+    }),
+
+    withReverseReferenceList({
+      reverse: soupyReverse.input('trackContributorContributionsBy'),
+    }).outputs({
+      '#reverseReferenceList': '#contributionsAsContributor',
+    }),
+
+    {
+      dependencies: [
+        '#contributionsAsArtist',
+        '#contributionsAsContributor',
+      ],
+
+      compute: (continuation, {
+        ['#contributionsAsArtist']: artistContribs,
+        ['#contributionsAsContributor']: contributorContribs,
+      }) => continuation({
+        ['#allContributions']: [
+          ...artistContribs,
+          ...contributorContribs,
+        ],
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#allContributions',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromList({
+      list: '#allContributions.thing',
+      property: input.value('isMainRelease'),
+    }),
+
+    withFilteredList({
+      list: '#allContributions',
+      filter: '#allContributions.thing.isMainRelease',
+    }).outputs({
+      '#filteredList': '#mainReleaseContributions',
+    }),
+
+    withContributionListSums({
+      list: '#mainReleaseContributions',
+    }),
+
+    exposeDependency({
+      dependency: '#contributionListDuration',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artist/index.js b/src/data/composite/things/artist/index.js
new file mode 100644
index 00000000..55514c71
--- /dev/null
+++ b/src/data/composite/things/artist/index.js
@@ -0,0 +1 @@
+export {default as artistTotalDuration} from './artistTotalDuration.js';
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
new file mode 100644
index 00000000..b92bff72
--- /dev/null
+++ b/src/data/composite/things/artwork/index.js
@@ -0,0 +1 @@
+export {default as withDate} from './withDate.js';
diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js
new file mode 100644
index 00000000..5e05b814
--- /dev/null
+++ b/src/data/composite/things/artwork/withDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'date',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: date,
+      }) =>
+        (date
+          ? continuation.raiseOutput({'#date': date})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'dateFromThingProperty',
+      output: input.value({'#date': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'dateFromThingProperty',
+    }).outputs({
+      ['#value']: '#date',
+    }),
+  ],
+})
diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js
new file mode 100644
index 00000000..9b22be2e
--- /dev/null
+++ b/src/data/composite/things/contribution/index.js
@@ -0,0 +1,7 @@
+export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
+export {default as thingPropertyMatches} from './thingPropertyMatches.js';
+export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
+export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
+export {default as withContributionArtist} from './withContributionArtist.js';
+export {default as withContributionContext} from './withContributionContext.js';
+export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js';
diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js
new file mode 100644
index 00000000..a74e6db3
--- /dev/null
+++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js
@@ -0,0 +1,61 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromList} from '#composite/data';
+
+import withMatchingContributionPresets
+  from './withMatchingContributionPresets.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromContributionPresets`,
+
+  inputs: {
+    property: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withMatchingContributionPresets().outputs({
+      '#matchingContributionPresets': '#presets',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#presets',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromList({
+      list: '#presets',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#values'],
+
+      compute: (continuation, {
+        ['#values']: values,
+      }) => continuation({
+        ['#index']:
+          values.findIndex(value =>
+            value !== undefined &&
+            value !== null),
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      mode: input.value('index'),
+    }),
+
+    {
+      dependencies: ['#values', '#index'],
+
+      compute: (continuation, {
+        ['#values']: values,
+        ['#index']: index,
+      }) => continuation({
+        ['#value']:
+          values[index],
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
new file mode 100644
index 00000000..1e9019b8
--- /dev/null
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -0,0 +1,46 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `thingPropertyMatches`,
+
+  compose: false,
+
+  inputs: {
+    value: input({type: 'string'}),
+  },
+
+  steps: () => [
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {thing, thingProperty}) =>
+        continuation({
+          ['#thingProperty']:
+            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+              ? thing.artistContribsFromThingProperty
+              : thingProperty),
+        }),
+    },
+
+    exitWithoutDependency({
+      dependency: '#thingProperty',
+      value: input.value(false),
+    }),
+
+    {
+      dependencies: [
+        '#thingProperty',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thingProperty']: thingProperty,
+        [input('value')]: value,
+      }) =>
+        thingProperty === value,
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
new file mode 100644
index 00000000..4042e78f
--- /dev/null
+++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
@@ -0,0 +1,66 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `thingReferenceTypeMatches`,
+
+  compose: false,
+
+  inputs: {
+    value: input({type: 'string'}),
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'thing',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.constructor',
+        input('value'),
+      ],
+
+      compute: (continuation, {
+        ['#thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
+        (constructor[Symbol.for('Thing.referenceType')] === value
+          ? continuation.exit(true)
+       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+          ? continuation()
+          : continuation.exit(false)),
+    },
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.thing.constructor',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thing.thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
+        constructor[Symbol.for('Thing.referenceType')] === value,
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js
new file mode 100644
index 00000000..175d6cbb
--- /dev/null
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -0,0 +1,80 @@
+// Get the artist's contribution list containing this property. Although that
+// list literally includes both dated and dateless contributions, here, if the
+// current contribution is dateless, the list is filtered to only include
+// dateless contributions from the same immediately nearby context.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withContributionArtist from './withContributionArtist.js';
+
+export default templateCompositeFrom({
+  annotation: `withContainingReverseContributionList`,
+
+  inputs: {
+    artistProperty: input({
+      defaultDependency: 'artistProperty',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#containingReverseContributionList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('artistProperty'),
+      output: input.value({
+        ['#containingReverseContributionList']:
+          null,
+      }),
+    }),
+
+    withContributionArtist(),
+
+    withPropertyFromObject({
+      object: '#artist',
+      property: input('artistProperty'),
+    }).outputs({
+      ['#value']: '#list',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: 'date',
+    }).outputs({
+      ['#availability']: '#hasDate',
+    }),
+
+    {
+      dependencies: ['#hasDate', '#list'],
+      compute: (continuation, {
+        ['#hasDate']: hasDate,
+        ['#list']: list,
+      }) =>
+        (hasDate
+          ? continuation.raiseOutput({
+              ['#containingReverseContributionList']:
+                list.filter(contrib => contrib.date),
+            })
+          : continuation({
+              ['#list']:
+                list.filter(contrib => !contrib.date),
+            })),
+    },
+
+    {
+      dependencies: ['#list', 'thing'],
+      compute: (continuation, {
+        ['#list']: list,
+        ['thing']: thing,
+      }) => continuation({
+        ['#containingReverseContributionList']:
+          (thing.album
+            ? list.filter(contrib => contrib.thing.album === thing.album)
+            : list),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js
new file mode 100644
index 00000000..5f81c716
--- /dev/null
+++ b/src/data/composite/things/contribution/withContributionArtist.js
@@ -0,0 +1,26 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withContributionArtist`,
+
+  inputs: {
+    ref: input({
+      type: 'string',
+      defaultDependency: 'artist',
+    }),
+  },
+
+  outputs: ['#artist'],
+
+  steps: () => [
+    withResolvedReference({
+      ref: input('ref'),
+      find: soupyFind.input('artist'),
+    }).outputs({
+      '#resolvedReference': '#artist',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionContext.js b/src/data/composite/things/contribution/withContributionContext.js
new file mode 100644
index 00000000..3c1c31c0
--- /dev/null
+++ b/src/data/composite/things/contribution/withContributionContext.js
@@ -0,0 +1,45 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withContributionContext`,
+
+  outputs: [
+    '#contributionTarget',
+    '#contributionProperty',
+  ],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'thing',
+      output: input.value({
+        '#contributionTarget': null,
+        '#contributionProperty': null,
+      }),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'thingProperty',
+      output: input.value({
+        '#contributionTarget': null,
+        '#contributionProperty': null,
+      }),
+    }),
+
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {
+        ['thing']: thing,
+        ['thingProperty']: thingProperty,
+      }) => continuation({
+        ['#contributionTarget']:
+          thing.constructor[Symbol.for('Thing.referenceType')],
+
+        ['#contributionProperty']:
+          thingProperty,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withMatchingContributionPresets.js b/src/data/composite/things/contribution/withMatchingContributionPresets.js
new file mode 100644
index 00000000..09454164
--- /dev/null
+++ b/src/data/composite/things/contribution/withMatchingContributionPresets.js
@@ -0,0 +1,70 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withContributionContext from './withContributionContext.js';
+
+export default templateCompositeFrom({
+  annotation: `withMatchingContributionPresets`,
+
+  outputs: ['#matchingContributionPresets'],
+
+  steps: () => [
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('wikiInfo'),
+      internal: input.value(true),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#thing.wikiInfo',
+      output: input.value({
+        '#matchingContributionPresets': null,
+      }),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.wikiInfo',
+      property: input.value('contributionPresets'),
+    }).outputs({
+      '#thing.wikiInfo.contributionPresets': '#contributionPresets',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#contributionPresets',
+      mode: input.value('empty'),
+      output: input.value({
+        '#matchingContributionPresets': [],
+      }),
+    }),
+
+    withContributionContext(),
+
+    {
+      dependencies: [
+        '#contributionPresets',
+        '#contributionTarget',
+        '#contributionProperty',
+        'annotation',
+      ],
+
+      compute: (continuation, {
+        ['#contributionPresets']: presets,
+        ['#contributionTarget']: target,
+        ['#contributionProperty']: property,
+        ['annotation']: annotation,
+      }) => continuation({
+        ['#matchingContributionPresets']:
+          presets
+            .filter(preset =>
+              preset.context[0] === target &&
+              preset.context.slice(1).includes(property) &&
+              // For now, only match if the annotation is a complete match.
+              // Partial matches (e.g. because the contribution includes "two"
+              // annotations, separated by commas) don't count.
+              preset.annotation === annotation),
+      })
+    },
+  ],
+});
diff --git a/src/data/composite/things/flash-act/index.js b/src/data/composite/things/flash-act/index.js
new file mode 100644
index 00000000..40fecd2f
--- /dev/null
+++ b/src/data/composite/things/flash-act/index.js
@@ -0,0 +1 @@
+export {default as withFlashSide} from './withFlashSide.js';
diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js
new file mode 100644
index 00000000..e09f06e6
--- /dev/null
+++ b/src/data/composite/things/flash-act/withFlashSide.js
@@ -0,0 +1,22 @@
+// Gets the flash act's side. This will early exit if flashSideData is missing.
+// If there's no side whose list of flash acts includes this act, the output
+// dependency will be null.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withFlashSide`,
+
+  outputs: ['#flashSide'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('flashSidesWhoseActsInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#flashSide',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/flash/index.js b/src/data/composite/things/flash/index.js
new file mode 100644
index 00000000..63ac13da
--- /dev/null
+++ b/src/data/composite/things/flash/index.js
@@ -0,0 +1 @@
+export {default as withFlashAct} from './withFlashAct.js';
diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js
new file mode 100644
index 00000000..87922aff
--- /dev/null
+++ b/src/data/composite/things/flash/withFlashAct.js
@@ -0,0 +1,22 @@
+// Gets the flash's act. This will early exit if flashActData is missing.
+// If there's no flash whose list of flashes includes this flash, the output
+// dependency will be null.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withFlashAct`,
+
+  outputs: ['#flashAct'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('flashActsWhoseFlashesInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#flashAct',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
new file mode 100644
index 00000000..f11a2ab5
--- /dev/null
+++ b/src/data/composite/things/track-section/index.js
@@ -0,0 +1,3 @@
+export {default as withAlbum} from './withAlbum.js';
+export {default as withContinueCountingFrom} from './withContinueCountingFrom.js';
+export {default as withStartCountingFrom} from './withStartCountingFrom.js';
diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js
new file mode 100644
index 00000000..e257062e
--- /dev/null
+++ b/src/data/composite/things/track-section/withAlbum.js
@@ -0,0 +1,20 @@
+// Gets the track section's album.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  outputs: ['#album'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('albumsWhoseTrackSectionsInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#album',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
new file mode 100644
index 00000000..e034b7a5
--- /dev/null
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import withStartCountingFrom from './withStartCountingFrom.js';
+
+export default templateCompositeFrom({
+  annotation: `withContinueCountingFrom`,
+
+  outputs: ['#continueCountingFrom'],
+
+  steps: () => [
+    withStartCountingFrom(),
+
+    {
+      dependencies: ['#startCountingFrom', 'tracks'],
+      compute: (continuation, {
+        ['#startCountingFrom']: startCountingFrom,
+        ['tracks']: tracks,
+      }) => continuation({
+        ['#continueCountingFrom']:
+          startCountingFrom +
+          tracks.length,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js
new file mode 100644
index 00000000..ef345327
--- /dev/null
+++ b/src/data/composite/things/track-section/withStartCountingFrom.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withStartCountingFrom`,
+
+  inputs: {
+    from: input({
+      type: 'number',
+      defaultDependency: 'startCountingFrom',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#startCountingFrom'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from === null
+          ? continuation()
+          : continuation.raiseOutput({'#startCountingFrom': from})),
+    },
+
+    withAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('trackSections'),
+    }),
+
+    withNearbyItemFromList({
+      list: '#album.trackSections',
+      item: input.myself(),
+      offset: input.value(-1),
+    }).outputs({
+      '#nearbyItem': '#previousTrackSection',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#previousTrackSection',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#previousTrackSection',
+      property: input.value('continueCountingFrom'),
+    }).outputs({
+      '#previousTrackSection.continueCountingFrom': '#startCountingFrom',
+    }),
+  ],
+});
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..e789e736
--- /dev/null
+++ b/src/data/composite/things/track/index.js
@@ -0,0 +1,17 @@
+export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js';
+export {default as inheritFromMainRelease} from './inheritFromMainRelease.js';
+export {default as withAllReleases} from './withAllReleases.js';
+export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
+export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withCoverArtistContribs} from './withCoverArtistContribs.js';
+export {default as withDate} from './withDate.js';
+export {default as withDirectorySuffix} from './withDirectorySuffix.js';
+export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
+export {default as withMainRelease} from './withMainRelease.js';
+export {default as withOtherReleases} from './withOtherReleases.js';
+export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
+export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js';
+export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js';
+export {default as withTrackArtDate} from './withTrackArtDate.js';
+export {default as withTrackNumber} from './withTrackNumber.js';
diff --git a/src/data/composite/things/track/inheritContributionListFromMainRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
new file mode 100644
index 00000000..89252feb
--- /dev/null
+++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
@@ -0,0 +1,44 @@
+// Like inheritFromMainRelease, but tuned for contributions.
+// Recontextualizes contributions for this track.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withRecontextualizedContributionList, withRedatedContributionList}
+  from '#composite/wiki-data';
+
+import withDate from './withDate.js';
+import withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritContributionListFromMainRelease`,
+
+  steps: () => [
+    withPropertyFromMainRelease({
+      property: input.thisProperty(),
+      notFoundValue: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#isSecondaryRelease',
+      mode: input.value('falsy'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#mainReleaseValue',
+    }),
+
+    withDate(),
+
+    withRedatedContributionList({
+      list: '#mainReleaseValue',
+      date: '#date',
+    }),
+
+    exposeDependency({
+      dependency: '#mainReleaseValue',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/inheritFromMainRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js
new file mode 100644
index 00000000..b1cbb65e
--- /dev/null
+++ b/src/data/composite/things/track/inheritFromMainRelease.js
@@ -0,0 +1,41 @@
+// Early exits with the value for the same property as specified on the
+// main release, if this track is a secondary release, and otherwise continues
+// without providing any further dependencies.
+//
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromMainRelease`,
+
+  inputs: {
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withPropertyFromMainRelease({
+      property: input.thisProperty(),
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#isSecondaryRelease',
+      mode: input.value('falsy'),
+    }),
+
+    exposeDependency({
+      dependency: '#mainReleaseValue',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 00000000..65a2263d
--- /dev/null
+++ b/src/data/composite/things/track/trackAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names from various sources.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isAdditionalNameList} from '#validators';
+
+import withInferredAdditionalNames from './withInferredAdditionalNames.js';
+import withSharedAdditionalNames from './withSharedAdditionalNames.js';
+
+export default templateCompositeFrom({
+  annotation: `trackAdditionalNameList`,
+
+  compose: false,
+
+  update: {validate: isAdditionalNameList},
+
+  steps: () => [
+    withInferredAdditionalNames(),
+    withSharedAdditionalNames(),
+
+    {
+      dependencies: [
+        '#inferredAdditionalNames',
+        '#sharedAdditionalNames',
+        input.updateValue(),
+      ],
+
+      compute: ({
+        ['#inferredAdditionalNames']: inferredAdditionalNames,
+        ['#sharedAdditionalNames']: sharedAdditionalNames,
+        [input.updateValue()]: providedAdditionalNames,
+      }) => [
+        ...providedAdditionalNames ?? [],
+        ...sharedAdditionalNames,
+        ...inferredAdditionalNames,
+      ],
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js
new file mode 100644
index 00000000..b93bf753
--- /dev/null
+++ b/src/data/composite/things/track/withAllReleases.js
@@ -0,0 +1,47 @@
+// Gets all releases of the current track. All items of the outputs are
+// distinct Track objects; one track is the main release; all else are
+// secondary releases of that main release; and one item, which may be
+// the main release or one of the secondary releases, is the current
+// track. The results are sorted by date, and it is possible that the
+// main release is not actually the earliest/first.
+
+import {input, templateCompositeFrom} from '#composite';
+import {sortByDate} from '#sort';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withMainRelease from './withMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withAllReleases`,
+
+  outputs: ['#allReleases'],
+
+  steps: () => [
+    withMainRelease({
+      selfIfMain: input.value(true),
+      notFoundValue: input.value([]),
+    }),
+
+    // We don't talk about bruno no no
+    // Yes, this can perform a normal access equivalent to
+    // `this.secondaryReleases` from within a data composition.
+    // Oooooooooooooooooooooooooooooooooooooooooooooooo
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input.value('secondaryReleases'),
+    }),
+
+    {
+      dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'],
+      compute: (continuation, {
+        ['#mainRelease']: mainRelease,
+        ['#mainRelease.secondaryReleases']: secondaryReleases,
+      }) => continuation({
+        ['#allReleases']:
+          sortByDate([mainRelease, ...secondaryReleases]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
new file mode 100644
index 00000000..60faeaf4
--- /dev/null
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -0,0 +1,97 @@
+// 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 find from '#find';
+import {isBoolean} from '#validators';
+
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withAlwaysReferenceByDirectory`,
+
+  outputs: ['#alwaysReferenceByDirectory'],
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(isBoolean),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('alwaysReferenceTracksByDirectory'),
+    }),
+
+    // Falsy mode means this exposes true if the album's property is true,
+    // but continues if the property is false (which is also the default).
+    exposeDependencyOrContinue({
+      dependency: '#album.alwaysReferenceTracksByDirectory',
+      mode: input.value('falsy'),
+    }),
+
+    // Remaining code is for defaulting to true if this track is a rerelease of
+    // another with the same name, so everything further depends on access to
+    // trackData as well as mainReleaseTrack.
+
+    exitWithoutDependency({
+      dependency: 'trackData',
+      mode: input.value('empty'),
+      value: input.value(false),
+    }),
+
+    exitWithoutDependency({
+      dependency: 'mainReleaseTrack',
+      value: input.value(false),
+    }),
+
+    // It's necessary to use the custom trackMainReleasesOnly find function
+    // here, so as to avoid recursion issues - the find.track() function depends
+    // on accessing each track's alwaysReferenceByDirectory, which means it'll
+    // hit *this track* - and thus this step - and end up recursing infinitely.
+    // By definition, find.trackMainReleasesOnly excludes tracks which have
+    // an mainReleaseTrack update value set, which means even though it does
+    // still access each of tracks' `alwaysReferenceByDirectory` property, it
+    // won't access that of *this* track - it will never proceed past the
+    // `exitWithoutDependency` step directly above, so there's no opportunity
+    // for recursion.
+    withResolvedReference({
+      ref: 'mainReleaseTrack',
+      data: 'trackData',
+      find: input.value(find.trackMainReleasesOnly),
+    }).outputs({
+      '#resolvedReference': '#mainRelease',
+    }),
+
+    exitWithoutDependency({
+      dependency: '#mainRelease',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['name', '#mainRelease.name'],
+      compute: (continuation, {
+        name,
+        ['#mainRelease.name']: mainReleaseName,
+      }) => continuation({
+        ['#alwaysReferenceByDirectory']:
+          name === mainReleaseName,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
new file mode 100644
index 00000000..3d4d081e
--- /dev/null
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -0,0 +1,20 @@
+// Gets the track section containing this track from its album's track list.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withContainingTrackSection`,
+
+  outputs: ['#trackSection'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('trackSectionsWhichInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#trackSection',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js
new file mode 100644
index 00000000..9057cfeb
--- /dev/null
+++ b/src/data/composite/things/track/withCoverArtistContribs.js
@@ -0,0 +1,73 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependencyOrContinue} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withTrackArtDate from './withTrackArtDate.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtistContribs`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'coverArtistContribs',
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtistContribs'],
+
+  steps: () => [
+    exitWithoutUniqueCoverArt({
+      value: input.value([]),
+    }),
+
+    withTrackArtDate(),
+
+    withResolvedContribs({
+      from: input('from'),
+      thingProperty: input.value('coverArtistContribs'),
+      artistProperty: input.value('trackCoverArtistContributions'),
+      date: '#trackArtDate',
+    }).outputs({
+      '#resolvedContribs': '#coverArtistContribs',
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    withRedatedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      date: '#trackArtDate',
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: coverArtistContribs,
+      }) => continuation({
+        ['#coverArtistContribs']: coverArtistContribs,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withDate.js b/src/data/composite/things/track/withDate.js
new file mode 100644
index 00000000..b5a770e9
--- /dev/null
+++ b/src/data/composite/things/track/withDate.js
@@ -0,0 +1,34 @@
+// Gets the track's own date. This is either its dateFirstReleased property
+// or, if unset, the album's date.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: ['dateFirstReleased'],
+      compute: (continuation, {dateFirstReleased}) =>
+        (dateFirstReleased
+          ? continuation.raiseOutput({'#date': dateFirstReleased})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('date'),
+    }),
+
+    {
+      dependencies: ['#album.date'],
+      compute: (continuation, {['#album.date']: albumDate}) =>
+        (albumDate
+          ? continuation.raiseOutput({'#date': albumDate})
+          : continuation.raiseOutput({'#date': null})),
+    },
+  ],
+})
diff --git a/src/data/composite/things/track/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js
new file mode 100644
index 00000000..c063e158
--- /dev/null
+++ b/src/data/composite/things/track/withDirectorySuffix.js
@@ -0,0 +1,36 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectorySuffix`,
+
+  outputs: ['#directorySuffix'],
+
+  steps: () => [
+    withSuffixDirectoryFromAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#suffixDirectoryFromAlbum',
+      mode: input.value('falsy'),
+      output: input.value({['#directorySuffix']: null}),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('directorySuffix'),
+    }),
+
+    {
+      dependencies: ['#album.directorySuffix'],
+      compute: (continuation, {
+        ['#album.directorySuffix']: directorySuffix,
+      }) => continuation({
+        ['#directorySuffix']:
+          directorySuffix,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
new file mode 100644
index 00000000..85d3b92a
--- /dev/null
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -0,0 +1,108 @@
+// 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.)
+//
+// withHasUniqueCoverArt is based only around the presence of *specified*
+// cover artist contributions, not whether the references to artists on those
+// contributions actually resolve to anything. It completely evades interacting
+// with find/replace.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/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()),
+    },
+
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+      internal: input.value(true),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#album.trackCoverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasUniqueCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#trackArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#trackArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasUniqueCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js
new file mode 100644
index 00000000..3a91edae
--- /dev/null
+++ b/src/data/composite/things/track/withMainRelease.js
@@ -0,0 +1,70 @@
+// Just includes the main release of this track as a dependency.
+// If this track isn't a secondary release, then it'll provide null, unless
+// the {selfIfMain} option is set, in which case it'll provide this track
+// itself. This will early exit (with notFoundValue) if the main release
+// is specified by reference and that reference doesn't resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withMainRelease`,
+
+  inputs: {
+    selfIfMain: input({type: 'boolean', defaultValue: false}),
+    notFoundValue: input({defaultValue: null}),
+  },
+
+  outputs: ['#mainRelease'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'mainReleaseTrack',
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        input('selfIfMain'),
+        '#availability',
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: track,
+        [input('selfIfMain')]: selfIfMain,
+        '#availability': availability,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#mainRelease']:
+                (selfIfMain ? track : null),
+            })),
+    },
+
+    withResolvedReference({
+      ref: 'mainReleaseTrack',
+      find: soupyFind.input('track'),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#resolvedReference',
+      value: input('notFoundValue'),
+    }),
+
+    {
+      dependencies: ['#resolvedReference'],
+
+      compute: (continuation, {
+        ['#resolvedReference']: resolvedReference,
+      }) =>
+        continuation({
+          ['#mainRelease']: resolvedReference,
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
new file mode 100644
index 00000000..0639742f
--- /dev/null
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -0,0 +1,30 @@
+// Gets all releases of the current track *except* this track itself;
+// in other words, all other releases of the current track.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withAllReleases from './withAllReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `withOtherReleases`,
+
+  outputs: ['#otherReleases'],
+
+  steps: () => [
+    withAllReleases(),
+
+    {
+      dependencies: [input.myself(), '#allReleases'],
+      compute: (continuation, {
+        [input.myself()]: thisTrack,
+        ['#allReleases']: allReleases,
+      }) => continuation({
+        ['#otherReleases']:
+          allReleases.filter(track => track !== thisTrack),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
new file mode 100644
index 00000000..a203c2e7
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -0,0 +1,48 @@
+// Gets a single property from this track's album, providing it as the same
+// property name prefixed with '#album.' (by default).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAlbum`,
+
+  inputs: {
+    property: input.staticValue({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) => ['#album.' + property],
+
+  steps: () => [
+    // XXX: This is a ridiculous hack considering `defaultValue` above.
+    // If we were certain what was up, we'd just get around to fixing it LOL
+    {
+      dependencies: [input('internal')],
+      compute: (continuation, {
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#internal']: internal ?? false,
+      }),
+    },
+
+    withPropertyFromObject({
+      object: 'album',
+      property: input('property'),
+      internal: '#internal',
+    }),
+
+    {
+      dependencies: ['#value', input.staticValue('property')],
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        ['#album.' + property]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withPropertyFromMainRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js
new file mode 100644
index 00000000..393a4c63
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromMainRelease.js
@@ -0,0 +1,86 @@
+// Provides a value inherited from the main release, if applicable, and a
+// flag indicating if this track is a secondary release or not.
+//
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withMainRelease from './withMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromMainRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) =>
+    ['#isSecondaryRelease'].concat(
+      (property
+        ? ['#mainRelease.' + property]
+        : ['#mainReleaseValue'])),
+
+  steps: () => [
+    withMainRelease({
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#mainRelease',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input.staticValue('property')]: property,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput(
+              Object.assign(
+                {'#isSecondaryRelease': false},
+                (property
+                  ? {['#mainRelease.' + property]: null}
+                  : {'#mainReleaseValue': null})))),
+    },
+
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: [
+        '#value',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) =>
+        continuation.raiseOutput(
+          Object.assign(
+            {'#isSecondaryRelease': true},
+            (property
+              ? {['#mainRelease.' + property]: value}
+              : {'#mainReleaseValue': value}))),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
new file mode 100644
index 00000000..7159a3f4
--- /dev/null
+++ b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
@@ -0,0 +1,53 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withSuffixDirectoryFromAlbum`,
+
+  inputs: {
+    flagValue: input({
+      defaultDependency: 'suffixDirectoryFromAlbum',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#suffixDirectoryFromAlbum'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'suffixDirectoryFromAlbum',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        'suffixDirectoryFromAlbum'
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['suffixDirectoryFromAlbum']: flagValue,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({['#suffixDirectoryFromAlbum']: flagValue})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('suffixTrackDirectories'),
+    }),
+
+    {
+      dependencies: ['#album.suffixTrackDirectories'],
+      compute: (continuation, {
+        ['#album.suffixTrackDirectories']: suffixTrackDirectories,
+      }) => continuation({
+        ['#suffixDirectoryFromAlbum']:
+          suffixTrackDirectories,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
new file mode 100644
index 00000000..9b7b61c7
--- /dev/null
+++ b/src/data/composite/things/track/withTrackArtDate.js
@@ -0,0 +1,60 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withDate from './withDate.js';
+import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackArtDate`,
+
+  inputs: {
+    from: input({
+      validate: isDate,
+      defaultDependency: 'coverArtDate',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#trackArtDate'],
+
+  steps: () => [
+    withHasUniqueCoverArt(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#hasUniqueCoverArt',
+      mode: input.value('falsy'),
+      output: input.value({'#trackArtDate': null}),
+    }),
+
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from
+          ? continuation.raiseOutput({'#trackArtDate': from})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackArtDate'),
+    }),
+
+    {
+      dependencies: ['#album.trackArtDate'],
+      compute: (continuation, {
+        ['#album.trackArtDate']: albumTrackArtDate,
+      }) =>
+        (albumTrackArtDate
+          ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
+          : continuation()),
+    },
+
+    withDate().outputs({
+      '#date': '#trackArtDate',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js
new file mode 100644
index 00000000..61428e8c
--- /dev/null
+++ b/src/data/composite/things/track/withTrackNumber.js
@@ -0,0 +1,50 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withIndexInList, withPropertiesFromObject} from '#composite/data';
+
+import withContainingTrackSection from './withContainingTrackSection.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackNumber`,
+
+  outputs: ['#trackNumber'],
+
+  steps: () => [
+    withContainingTrackSection(),
+
+    // Zero is the fallback, not one, but in most albums the first track
+    // (and its intended output by this composition) will be one.
+    raiseOutputWithoutDependency({
+      dependency: '#trackSection',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    withPropertiesFromObject({
+      object: '#trackSection',
+      properties: input.value(['tracks', 'startCountingFrom']),
+    }),
+
+    withIndexInList({
+      list: '#trackSection.tracks',
+      item: input.myself(),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    {
+      dependencies: ['#trackSection.startCountingFrom', '#index'],
+      compute: (continuation, {
+        ['#trackSection.startCountingFrom']: startCountingFrom,
+        ['#index']: index,
+      }) => continuation({
+        ['#trackNumber']:
+          startCountingFrom +
+          index,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js
new file mode 100644
index 00000000..cf52950d
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -0,0 +1,48 @@
+// 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'),
+      date: input.value(null),
+    }),
+
+    // 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/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js
new file mode 100644
index 00000000..aec3f5b1
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyFind.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyFind, {getSoupyFindInputKey} from './inputSoupyFind.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyFind`,
+
+  inputs: {
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#find'],
+
+  steps: () => [
+    {
+      dependencies: [input('find')],
+      compute: (continuation, {
+        [input('find')]: find,
+      }) =>
+        (typeof find === 'function'
+          ? continuation.raiseOutput({
+              ['#find']: find,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyFindInputKey(find),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'find',
+      property: '#key',
+    }).outputs({
+      '#value': '#find',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js
new file mode 100644
index 00000000..86a1061c
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyReverse, {getSoupyReverseInputKey} from './inputSoupyReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyReverse`,
+
+  inputs: {
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverse'],
+
+  steps: () => [
+    {
+      dependencies: [input('reverse')],
+      compute: (continuation, {
+        [input('reverse')]: reverse,
+      }) =>
+        (typeof reverse === 'function'
+          ? continuation.raiseOutput({
+              ['#reverse']: reverse,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyReverseInputKey(reverse),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'reverse',
+      property: '#key',
+    }).outputs({
+      '#value': '#reverse',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withDirectoryFromName.js b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
new file mode 100644
index 00000000..f85dae16
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
@@ -0,0 +1,41 @@
+// Compute a directory from a name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isName} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withDirectoryFromName`,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('name'),
+      mode: input.value('falsy'),
+      output: input.value({
+        ['#directory']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input('name')],
+      compute: (continuation, {
+        [input('name')]: name,
+      }) => continuation({
+        ['#directory']:
+          getKebabCase(name),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
new file mode 100644
index 00000000..818f60b7
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
@@ -0,0 +1,40 @@
+// Actually execute a reverse function.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputWikiData from '../inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: input({type: 'function'}),
+    options: input({type: 'object', defaultValue: null}),
+  },
+
+  outputs: ['#resolvedReverse'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('data'),
+        input('reverse'),
+        input('options'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('data')]: data,
+        [input('reverse')]: reverseFunction,
+        [input('options')]: opts,
+      }) => continuation({
+        ['#resolvedReverse']:
+          (data
+            ? reverseFunction(myself, data, opts)
+            : reverseFunction(myself, opts)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
new file mode 100644
index 00000000..08ca3bfc
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
@@ -0,0 +1,52 @@
+// A "simple" directory, based only on the already-provided directory, if
+// available, or the provided name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withDirectoryFromName from './withDirectoryFromName.js';
+
+export default templateCompositeFrom({
+  annotation: `withSimpleDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('directory'),
+    }),
+
+    {
+      dependencies: ['#availability', input('directory')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('directory')]: directory,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#directory']: directory
+            })
+          : continuation()),
+    },
+
+    withDirectoryFromName({
+      name: input('name'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
new file mode 100644
index 00000000..1d94f74b
--- /dev/null
+++ b/src/data/composite/wiki-data/index.js
@@ -0,0 +1,32 @@
+// #composite/wiki-data
+//
+// Entries here may depend on entries in #composite/control-flow and in
+// #composite/data.
+//
+
+export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
+export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
+export {default as inputNotFoundMode} from './inputNotFoundMode.js';
+export {default as inputSoupyFind} from './inputSoupyFind.js';
+export {default as inputSoupyReverse} from './inputSoupyReverse.js';
+export {default as inputWikiData} from './inputWikiData.js';
+export {default as processContentEntryDates} from './processContentEntryDates.js';
+export {default as withClonedThings} from './withClonedThings.js';
+export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
+export {default as withContributionListSums} from './withContributionListSums.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
+export {default as withDirectory} from './withDirectory.js';
+export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
+export {default as withParsedContentEntries} from './withParsedContentEntries.js';
+export {default as withParsedLyricsEntries} from './withParsedLyricsEntries.js';
+export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
+export {default as withRedatedContributionList} from './withRedatedContributionList.js';
+export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
+export {default as withResolvedContribs} from './withResolvedContribs.js';
+export {default as withResolvedReference} from './withResolvedReference.js';
+export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
+export {default as withResolvedSeriesList} from './withResolvedSeriesList.js';
+export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
+export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/inputNotFoundMode.js b/src/data/composite/wiki-data/inputNotFoundMode.js
new file mode 100644
index 00000000..d16b2472
--- /dev/null
+++ b/src/data/composite/wiki-data/inputNotFoundMode.js
@@ -0,0 +1,9 @@
+import {input} from '#composite';
+import {is} from '#validators';
+
+export default function inputNotFoundMode() {
+  return input({
+    validate: is('exit', 'filter', 'null'),
+    defaultValue: 'filter',
+  });
+}
diff --git a/src/data/composite/wiki-data/inputSoupyFind.js b/src/data/composite/wiki-data/inputSoupyFind.js
new file mode 100644
index 00000000..020f4990
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyFind.js
@@ -0,0 +1,28 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyFind() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyFind:')) {
+            throw new Error(`Expected soupyFind.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyFind.input = key =>
+  input.value('_soupyFind:' + key);
+
+export default inputSoupyFind;
+
+export function getSoupyFindInputKey(value) {
+  return value.slice('_soupyFind:'.length);
+}
diff --git a/src/data/composite/wiki-data/inputSoupyReverse.js b/src/data/composite/wiki-data/inputSoupyReverse.js
new file mode 100644
index 00000000..0b0a23fe
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyReverse.js
@@ -0,0 +1,32 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyReverse() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyReverse:')) {
+            throw new Error(`Expected soupyReverse.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyReverse.input = key =>
+  input.value('_soupyReverse:' + key);
+
+export default inputSoupyReverse;
+
+export function getSoupyReverseInputKey(value) {
+  return value.slice('_soupyReverse:'.length).replace(/\.unique$/, '');
+}
+
+export function doesSoupyReverseInputWantUnique(value) {
+  return value.endsWith('.unique');
+}
diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js
new file mode 100644
index 00000000..b9021986
--- /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}),
+    defaultValue: null,
+  });
+}
diff --git a/src/data/composite/wiki-data/processContentEntryDates.js b/src/data/composite/wiki-data/processContentEntryDates.js
new file mode 100644
index 00000000..e418a121
--- /dev/null
+++ b/src/data/composite/wiki-data/processContentEntryDates.js
@@ -0,0 +1,181 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, isString, looseArrayOf} from '#validators';
+
+import {fillMissingListItems} from '#composite/data';
+
+// Important note: These two kinds of inputs have the exact same shape!!
+// This isn't on purpose (besides that they *are* both supposed to be strings).
+// They just don't have any more particular validation, yet.
+
+const inputDateList = defaultDependency =>
+  input({
+    validate: looseArrayOf(isString),
+    defaultDependency,
+  });
+
+const inputKindList = defaultDependency =>
+  input.staticDependency({
+    validate: looseArrayOf(isString),
+    defaultDependency: defaultDependency,
+  });
+
+export default templateCompositeFrom({
+  annotation: `processContentEntryDates`,
+
+  inputs: {
+    annotations: input({
+      validate: looseArrayOf(isContentString),
+      defaultDependency: '#entries.annotation',
+    }),
+
+    dates: inputDateList('#entries.date'),
+    secondDates: inputDateList('#entries.secondDate'),
+    accessDates: inputDateList('#entries.accessDate'),
+
+    dateKinds: inputKindList('#entries.dateKind'),
+    accessKinds: inputKindList('#entries.accessKind'),
+  },
+
+  outputs: ({
+    [input.staticDependency('dates')]: dates,
+    [input.staticDependency('secondDates')]: secondDates,
+    [input.staticDependency('accessDates')]: accessDates,
+    [input.staticDependency('dateKinds')]: dateKinds,
+    [input.staticDependency('accessKinds')]: accessKinds,
+  }) => [
+    dates ?? '#processedContentEntryDates',
+    secondDates ?? '#processedContentEntrySecondDates',
+    accessDates ?? '#processedContentEntryAccessDates',
+    dateKinds ?? '#processedContentEntryDateKinds',
+    accessKinds ?? '#processedContentEntryAccessKinds',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [input('annotations')],
+      compute: (continuation, {
+        [input('annotations')]: annotations,
+      }) => continuation({
+        ['#webArchiveDates']:
+          annotations
+            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
+            .map(match => match?.[1])
+            .map(dateText =>
+              (dateText
+                ? dateText.slice(0, 4) + '/' +
+                  dateText.slice(4, 6) + '/' +
+                  dateText.slice(6, 8)
+                : null)),
+      }),
+    },
+
+    {
+      dependencies: [input('dates')],
+      compute: (continuation, {
+        [input('dates')]: dates,
+      }) => continuation({
+        ['#processedContentEntryDates']:
+          dates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [input('secondDates')],
+      compute: (continuation, {
+        [input('secondDates')]: secondDates,
+      }) => continuation({
+        ['#processedContentEntrySecondDates']:
+          secondDates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    fillMissingListItems({
+      list: input('dateKinds'),
+      fill: input.value(null),
+    }).outputs({
+      '#list': '#processedContentEntryDateKinds',
+    }),
+
+    {
+      dependencies: [input('accessDates'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessDates')]: accessDates,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessDates']:
+          stitchArrays({
+            accessDate: accessDates,
+            webArchiveDate: webArchiveDates
+          }).map(({accessDate, webArchiveDate}) =>
+              accessDate ??
+              webArchiveDate ??
+              null)
+            .map(date => date ? new Date(date) : date),
+      }),
+    },
+
+    {
+      dependencies: [input('accessKinds'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessKinds')]: accessKinds,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessKinds']:
+          stitchArrays({
+            accessKind: accessKinds,
+            webArchiveDate: webArchiveDates,
+          }).map(({accessKind, webArchiveDate}) =>
+              accessKind ??
+              (webArchiveDate && 'captured') ??
+              null),
+      }),
+    },
+
+    // TODO: Annoying conversion step for outputs, would be nice to avoid.
+    {
+      dependencies: [
+        '#processedContentEntryDates',
+        '#processedContentEntrySecondDates',
+        '#processedContentEntryAccessDates',
+        '#processedContentEntryDateKinds',
+        '#processedContentEntryAccessKinds',
+        input.staticDependency('dates'),
+        input.staticDependency('secondDates'),
+        input.staticDependency('accessDates'),
+        input.staticDependency('dateKinds'),
+        input.staticDependency('accessKinds'),
+      ],
+
+      compute: (continuation, {
+        ['#processedContentEntryDates']: processedContentEntryDates,
+        ['#processedContentEntrySecondDates']: processedContentEntrySecondDates,
+        ['#processedContentEntryAccessDates']: processedContentEntryAccessDates,
+        ['#processedContentEntryDateKinds']: processedContentEntryDateKinds,
+        ['#processedContentEntryAccessKinds']: processedContentEntryAccessKinds,
+        [input.staticDependency('dates')]: dates,
+        [input.staticDependency('secondDates')]: secondDates,
+        [input.staticDependency('accessDates')]: accessDates,
+        [input.staticDependency('dateKinds')]: dateKinds,
+        [input.staticDependency('accessKinds')]: accessKinds,
+      }) => continuation({
+        [dates ?? '#processedContentEntryDates']:
+          processedContentEntryDates,
+
+        [secondDates ?? '#processedContentEntrySecondDates']:
+          processedContentEntrySecondDates,
+
+        [accessDates ?? '#processedContentEntryAccessDates']:
+          processedContentEntryAccessDates,
+
+        [dateKinds ?? '#processedContentEntryDateKinds']:
+          processedContentEntryDateKinds,
+
+        [accessKinds ?? '#processedContentEntryAccessKinds']:
+          processedContentEntryAccessKinds,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/raiseResolvedReferenceList.js b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
new file mode 100644
index 00000000..613b002b
--- /dev/null
+++ b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
@@ -0,0 +1,96 @@
+// Concludes compositions like withResolvedReferenceList, which share behavior
+// in processing the resolved results before continuing further.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFilteredList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+
+export default templateCompositeFrom({
+  inputs: {
+    notFoundMode: inputNotFoundMode(),
+
+    results: input({type: 'array'}),
+    filter: input({type: 'array'}),
+
+    exitValue: input({defaultValue: []}),
+
+    outputs: input.staticValue({type: 'string'}),
+  },
+
+  outputs: ({
+    [input.staticValue('outputs')]: outputs,
+  }) => [outputs],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('results'),
+        input('filter'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('filter')]: filter,
+        [input('outputs')]: outputs,
+      }) =>
+        (filter.every(keep => keep)
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('notFoundMode'),
+        input('exitValue'),
+      ],
+
+      compute: (continuation, {
+        [input('notFoundMode')]: notFoundMode,
+        [input('exitValue')]: exitValue,
+      }) =>
+        (notFoundMode === 'exit'
+          ? continuation.exit(exitValue)
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('results'),
+        input('notFoundMode'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('notFoundMode')]: notFoundMode,
+        [input('outputs')]: outputs,
+      }) =>
+        (notFoundMode === 'null'
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    withFilteredList({
+      list: input('results'),
+      filter: input('filter'),
+    }),
+
+    {
+      dependencies: [
+        '#filteredList',
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        ['#filteredList']: filteredList,
+        [input('outputs')]: outputs,
+      }) => continuation({
+        [outputs]:
+          filteredList,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withClonedThings.js b/src/data/composite/wiki-data/withClonedThings.js
new file mode 100644
index 00000000..9af6aa84
--- /dev/null
+++ b/src/data/composite/wiki-data/withClonedThings.js
@@ -0,0 +1,68 @@
+// Clones all the things in a list. If the 'assign' input is provided,
+// all new things are assigned the same specified properties. If the
+// 'assignEach' input is provided, each new thing is assigned the
+// corresponding properties.
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+import {isObject, sparseArrayOf} from '#validators';
+
+import {withMappedList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withClonedThings`,
+
+  inputs: {
+    things: input({type: 'array'}),
+
+    assign: input({
+      type: 'object',
+      defaultValue: null,
+    }),
+
+    assignEach: input({
+      validate: sparseArrayOf(isObject),
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#clonedThings'],
+
+  steps: () => [
+    {
+      dependencies: [input('assign'), input('assignEach')],
+      compute: (continuation, {
+        [input('assign')]: assign,
+        [input('assignEach')]: assignEach,
+      }) => continuation({
+        ['#assignmentMap']:
+          (index) =>
+            (assign && assignEach
+              ? {...assignEach[index] ?? {}, ...assign}
+           : assignEach
+              ? assignEach[index] ?? {}
+              : assign ?? {}),
+      }),
+    },
+
+    {
+      dependencies: ['#assignmentMap'],
+      compute: (continuation, {
+        ['#assignmentMap']: assignmentMap,
+      }) => continuation({
+        ['#cloningMap']:
+          (thing, index) =>
+            Object.assign(
+              CacheableObject.clone(thing),
+              assignmentMap(index)),
+      }),
+    },
+
+    withMappedList({
+      list: input('things'),
+      map: '#cloningMap',
+    }).outputs({
+      '#mappedList': '#clonedThings',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
new file mode 100644
index 00000000..9e260abf
--- /dev/null
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -0,0 +1,57 @@
+import {input, templateCompositeFrom} from '#composite';
+import thingConstructors from '#things';
+import {isContributionList} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withConstitutedArtwork`,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  outputs: ['#constitutedArtwork'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('dimensionsFromThingProperty'),
+        input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
+        input('artistContribsFromThingProperty'),
+        input('artistContribsArtistProperty'),
+        input('artTagsFromThingProperty'),
+        input('referencedArtworksFromThingProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
+        [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
+        [input('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty,
+        [input('artistContribsArtistProperty')]: artistContribsArtistProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
+      }) => continuation({
+        ['#constitutedArtwork']:
+          Object.assign(new thingConstructors.Artwork, {
+            thing: myself,
+            dimensionsFromThingProperty,
+            fileExtensionFromThingProperty,
+            artistContribsFromThingProperty,
+            artistContribsArtistProperty,
+            artTagsFromThingProperty,
+            dateFromThingProperty,
+            referencedArtworksFromThingProperty,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withContributionListSums.js b/src/data/composite/wiki-data/withContributionListSums.js
new file mode 100644
index 00000000..b4f36361
--- /dev/null
+++ b/src/data/composite/wiki-data/withContributionListSums.js
@@ -0,0 +1,95 @@
+// Gets the total duration and contribution count from a list of contributions,
+// respecting their `countInContributionTotals` and `countInDurationTotals`
+// flags.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  withFilteredList,
+  withPropertiesFromList,
+  withPropertyFromList,
+  withSum,
+  withUniqueItemsOnly,
+} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withContributionListSums`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: [
+    '#contributionListCount',
+    '#contributionListDuration',
+  ],
+
+  steps: () => [
+    withPropertiesFromList({
+      list: input('list'),
+      properties: input.value([
+        'countInContributionTotals',
+        'countInDurationTotals',
+      ]),
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInContributionTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForCounting',
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInDurationTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForDuration',
+    }),
+
+    {
+      dependencies: ['#contributionsForCounting'],
+      compute: (continuation, {
+        ['#contributionsForCounting']: contributionsForCounting,
+      }) => continuation({
+        ['#count']:
+          contributionsForCounting.length,
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributionsForDuration',
+      property: input.value('thing'),
+    }),
+
+    // Don't double-up the durations for a track where the artist has multiple
+    // contributions.
+    withUniqueItemsOnly({
+      list: '#contributionsForDuration.thing',
+    }),
+
+    withPropertyFromList({
+      list: '#contributionsForDuration.thing',
+      property: input.value('duration'),
+    }).outputs({
+      '#contributionsForDuration.thing.duration': '#durationValues',
+    }),
+
+    withSum({
+      values: '#durationValues',
+    }).outputs({
+      '#sum': '#duration',
+    }),
+
+    {
+      dependencies: ['#count', '#duration'],
+      compute: (continuation, {
+        ['#count']: count,
+        ['#duration']: duration,
+      }) => continuation({
+        ['#contributionListCount']: count,
+        ['#contributionListDuration']: duration,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
new file mode 100644
index 00000000..a114d5ff
--- /dev/null
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -0,0 +1,51 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtDate`,
+
+  inputs: {
+    from: input({
+      validate: isDate,
+      defaultDependency: 'coverArtDate',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtDate'],
+
+  steps: () => [
+    withResolvedContribs({
+      from: 'coverArtistContribs',
+      date: input.value(null),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#resolvedContribs',
+      mode: input.value('empty'),
+      output: input.value({'#coverArtDate': null}),
+    }),
+
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from
+          ? continuation.raiseOutput({'#coverArtDate': from})
+          : continuation()),
+    },
+
+    {
+      dependencies: ['date'],
+      compute: (continuation, {date}) =>
+        (date
+          ? continuation({'#coverArtDate': date})
+          : continuation({'#coverArtDate': null})),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
new file mode 100644
index 00000000..f3bedf2e
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -0,0 +1,62 @@
+// Select a directory, either using a manually specified directory, or
+// computing it from a name. By default these values are the current thing's
+// 'directory' and 'name' properties, so it can be used without any options
+// to get the current thing's effective directory (assuming no custom rules).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withSimpleDirectory from './helpers/withSimpleDirectory.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withSimpleDirectory({
+      directory: input('directory'),
+      name: input('name'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directory',
+      output: input.value({['#directory']: null}),
+    }),
+
+    {
+      dependencies: ['#directory', input('suffix')],
+      compute: (continuation, {
+        ['#directory']: directory,
+        [input('suffix')]: suffix,
+      }) => continuation({
+        ['#directory']:
+          (suffix
+            ? directory + '-' + suffix
+            : directory),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..6794c479
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,129 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegexCaseSensitive} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
+    }),
+
+    withPropertiesFromList({
+      list: '#parsedContentEntryHeadings',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('artist'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    processContentEntryDates(),
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
+      }) => continuation({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedContentEntries.js b/src/data/composite/wiki-data/withParsedContentEntries.js
new file mode 100644
index 00000000..2a9b3f6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedContentEntries.js
@@ -0,0 +1,111 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, validateInstanceOf} from '#validators';
+
+import {withPropertiesFromList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withParsedContentEntries`,
+
+  inputs: {
+    // TODO: Is there any way to validate this input based on the *other*
+    // inputs proivded, i.e. regexes? This kind of just assumes the string
+    // has already been validated according to the form the regex expects,
+    // which *is* always the case (as used), but it seems a bit awkward.
+    from: input({validate: isContentString}),
+
+    caseSensitiveRegex: input({
+      validate: validateInstanceOf(RegExp),
+    }),
+  },
+
+  outputs: [
+    '#parsedContentEntryHeadings',
+    '#parsedContentEntryBodies',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('from'),
+        input('caseSensitiveRegex'),
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        [input('caseSensitiveRegex')]: caseSensitiveRegex,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(caseSensitiveRegex)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#parsedContentEntryHeadings',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#parsedContentEntryBodies']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#parsedContentEntryHeadings',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      }) => continuation({
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      })
+    }
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js
new file mode 100644
index 00000000..d13bfbaa
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedLyricsEntries.js
@@ -0,0 +1,157 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isLyrics} from '#validators';
+import {commentaryRegexCaseSensitive, oldStyleLyricsDetectionRegex}
+  from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+function constituteLyricsEntry(text) {
+  return {
+    artists: [],
+    artistDisplayText: null,
+    annotation: null,
+    date: null,
+    secondDate: null,
+    dateKind: null,
+    accessDate: null,
+    accessKind: null,
+    body: text,
+  };
+}
+
+export default templateCompositeFrom({
+  annotation: `withParsedLyricsEntries`,
+
+  inputs: {
+    from: input({validate: isLyrics}),
+  },
+
+  outputs: ['#parsedLyricsEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: lyrics,
+      }) =>
+        (oldStyleLyricsDetectionRegex.test(lyrics)
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#parsedLyricsEntries']:
+                [constituteLyricsEntry(lyrics)],
+            })),
+    },
+
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
+    }),
+
+    withPropertiesFromList({
+      list: '#parsedContentEntryHeadings',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('artist'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    processContentEntryDates(),
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
+      }) => continuation({
+        ['#parsedLyricsEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
new file mode 100644
index 00000000..bcc6e486
--- /dev/null
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -0,0 +1,100 @@
+// Clones all the contributions in a list, with thing and thingProperty both
+// updated to match the current thing. Overwrites the provided dependency.
+// Optionally updates artistProperty as well. Doesn't do anything if
+// the provided dependency is null.
+//
+// See also:
+//  - withRedatedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isStringNonEmpty} from '#validators';
+
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRecontextualizedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+      }) =>
+        (list
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    {
+      dependencies: [
+        input.myself(),
+        input.thisProperty(),
+        input('artistProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input.thisProperty()]: thisProperty,
+        [input('artistProperty')]: artistProperty,
+      }) => continuation({
+        ['#assignment']:
+          Object.assign(
+            {thing: myself},
+            {thingProperty: thisProperty},
+
+            (artistProperty
+              ? {artistProperty}
+              : {})),
+      }),
+    },
+
+    withClonedThings({
+      things: input('list'),
+      assign: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRedatedContributionList.js b/src/data/composite/wiki-data/withRedatedContributionList.js
new file mode 100644
index 00000000..12f3e16b
--- /dev/null
+++ b/src/data/composite/wiki-data/withRedatedContributionList.js
@@ -0,0 +1,127 @@
+// Clones all the contributions in a list, with date updated to the provided
+// value. Overwrites the provided dependency. Doesn't do anything if the
+// provided dependency is null, or the provided date is null.
+//
+// If 'override' is true (the default), then so long as the provided date has
+// a value at all, it's always written onto the (cloned) contributions.
+//
+// If 'override' is false, and any of the contributions were already dated,
+// those will keep their existing dates.
+//
+// See also:
+//  - withRecontextualizedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {withMappedList, withPropertyFromList} from '#composite/data';
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRedatedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    override: input({
+      type: 'boolean',
+      defaultValue: true,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+        input('date'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+        [input('date')]: date,
+      }) =>
+        (list && date
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input.value('date'),
+    }).outputs({
+      '#list.date': '#existingDates',
+    }),
+
+    {
+      dependencies: [
+        input('date'),
+        input('override'),
+        '#existingDates',
+      ],
+
+      compute: (continuation, {
+        [input('date')]: date,
+        [input('override')]: override,
+        '#existingDates': existingDates,
+      }) => continuation({
+        ['#assignmentMap']:
+          // TODO: Should be mapping over withIndicesFromList
+          (_, index) =>
+            (!override && existingDates[index]
+              ? {date: existingDates[index]}
+           : date
+              ? {date}
+              : {}),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#assignmentMap',
+    }).outputs({
+      '#mappedList': '#assignment',
+    }),
+
+    withClonedThings({
+      things: input('list'),
+      assignEach: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
new file mode 100644
index 00000000..9cc52f29
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -0,0 +1,100 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isObject, validateArrayItems} from '#validators';
+
+import {withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedAnnotatedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isObject),
+      acceptsNull: true,
+    }),
+
+    reference: input({type: 'string', defaultValue: 'reference'}),
+    annotation: input({type: 'string', defaultValue: 'annotation'}),
+    thing: input({type: 'string', defaultValue: 'thing'}),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedAnnotatedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedAnnotatedReferenceList']: [],
+      }),
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('reference'),
+    }).outputs({
+      ['#values']: '#references',
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('annotation'),
+    }).outputs({
+      ['#values']: '#annotations',
+    }),
+
+    withResolvedReferenceList({
+      list: '#references',
+      data: input('data'),
+      find: input('find'),
+      notFoundMode: input.value('null'),
+    }),
+
+    {
+      dependencies: [
+        input('thing'),
+        input('annotation'),
+        '#resolvedReferenceList',
+        '#annotations',
+      ],
+
+      compute: (continuation, {
+        [input('thing')]: thingProperty,
+        [input('annotation')]: annotationProperty,
+        ['#resolvedReferenceList']: things,
+        ['#annotations']: annotations,
+      }) => continuation({
+        ['#matches']:
+          stitchArrays({
+            [thingProperty]: things,
+            [annotationProperty]: annotations,
+          }),
+      }),
+    },
+
+    withAvailabilityFilter({
+      from: '#resolvedReferenceList',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedAnnotatedReferenceList'),
+    }),
+  ],
+})
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
new file mode 100644
index 00000000..838c991f
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -0,0 +1,156 @@
+// Resolves the contribsByRef contained in the provided dependency,
+// providing (named by the second argument) the result. "Resolving"
+// means mapping the artist reference of each contribution to an artist
+// object, and filtering out those whose artist reference doesn't match
+// any artist.
+
+import {input, templateCompositeFrom} from '#composite';
+import {filterMultipleArrays, stitchArrays} from '#sugar';
+import thingConstructors from '#things';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withPropertyFromList, withPropertiesFromList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedContribs`,
+
+  inputs: {
+    from: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: inputNotFoundMode(),
+
+    thingProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#resolvedContribs'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('from'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedContribs']: [],
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('thingProperty'),
+        input.staticDependency('from'),
+      ],
+
+      compute: (continuation, {
+        [input('thingProperty')]: thingProperty,
+        [input.staticDependency('from')]: fromDependency,
+      }) => continuation({
+        ['#thingProperty']:
+          (thingProperty
+            ? thingProperty
+         : !fromDependency?.startsWith('#')
+            ? fromDependency
+            : null),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: input('from'),
+      properties: input.value(['artist', 'annotation']),
+      prefix: input.value('#contribs'),
+    }),
+
+    {
+      dependencies: [
+        '#contribs.artist',
+        '#contribs.annotation',
+        input('date'),
+      ],
+
+      compute(continuation, {
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
+        [input('date')]: date,
+      }) {
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
+
+        return continuation({
+          ['#details']:
+            stitchArrays({artist, annotation})
+              .map(details => ({
+                ...details,
+                date: date ?? null,
+              })),
+        });
+      },
+    },
+
+    {
+      dependencies: [
+        '#details',
+        '#thingProperty',
+        input('artistProperty'),
+        input.myself(),
+        'find',
+      ],
+
+      compute: (continuation, {
+        ['#details']: details,
+        ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
+        [input.myself()]: myself,
+        ['find']: find,
+      }) => continuation({
+        ['#contributions']:
+          details.map(details => {
+            const contrib = new thingConstructors.Contribution();
+
+            Object.assign(contrib, {
+              ...details,
+              thing: myself,
+              thingProperty: thingProperty,
+              artistProperty: artistProperty,
+              find: find,
+            });
+
+            return contrib;
+          }),
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributions',
+      property: input.value('artist'),
+    }),
+
+    withAvailabilityFilter({
+      from: '#contributions.artist',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#contributions',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedContribs'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
new file mode 100644
index 00000000..6f422194
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -0,0 +1,57 @@
+// Resolves a reference by using the provided find function to match it
+// within the provided thingData dependency. 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 {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputSoupyFind from './inputSoupyFind.js';
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReference`,
+
+  inputs: {
+    ref: input({type: 'string', acceptsNull: true}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#resolvedReference'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('ref'),
+      output: input.value({
+        ['#resolvedReference']: null,
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [
+        input('ref'),
+        input('data'),
+        '#find',
+      ],
+
+      compute: (continuation, {
+        [input('ref')]: ref,
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#resolvedReference']:
+          (data
+            ? findFunction(ref, data, {mode: 'quiet'}) ?? null
+            : findFunction(ref, {mode: 'quiet'}) ?? 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..9dc960dd
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -0,0 +1,80 @@
+// Resolves a list of references, with each reference matched with provided
+// data in the same way as withResolvedReference. 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 {isString, validateArrayItems} from '#validators';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withMappedList} from '#composite/data';
+
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputSoupyFind from './inputSoupyFind.js';
+import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isString),
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedReferenceList']: [],
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [input('data'), '#find'],
+      compute: (continuation, {
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#map']:
+          (data
+            ? ref => findFunction(ref, data, {mode: 'quiet'})
+            : ref => findFunction(ref, {mode: 'quiet'})),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#map',
+    }).outputs({
+      '#mappedList': '#matches',
+    }),
+
+    withAvailabilityFilter({
+      from: '#matches',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedReferenceList'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
new file mode 100644
index 00000000..deaab466
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedSeriesList.js
@@ -0,0 +1,130 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isSeriesList, validateThing} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withUnflattenedList,
+  withPropertiesFromList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedSeriesList`,
+
+  inputs: {
+    group: input({
+      validate: validateThing({referenceType: 'group'}),
+    }),
+
+    list: input({
+      validate: isSeriesList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#resolvedSeriesList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedSeriesList']: [],
+      }),
+    }),
+
+    withPropertiesFromList({
+      list: input('list'),
+      prefix: input.value('#serieses'),
+      properties: input.value([
+        'name',
+        'description',
+        'albums',
+
+        'showAlbumArtists',
+      ]),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.albums',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#serieses.albums',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('album'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#serieses.albums',
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.description',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.showAlbumArtists',
+      fill: input.value(null),
+    }),
+
+    {
+      dependencies: [
+        '#serieses.name',
+        '#serieses.description',
+        '#serieses.albums',
+
+        '#serieses.showAlbumArtists',
+      ],
+
+      compute: (continuation, {
+        ['#serieses.name']: name,
+        ['#serieses.description']: description,
+        ['#serieses.albums']: albums,
+
+        ['#serieses.showAlbumArtists']: showAlbumArtists,
+      }) => continuation({
+        ['#seriesProperties']:
+          stitchArrays({
+            name,
+            description,
+            albums,
+
+            showAlbumArtists,
+          }).map(properties => ({
+              ...properties,
+              group: input
+            }))
+      }),
+    },
+
+    {
+      dependencies: ['#seriesProperties', input('group')],
+      compute: (continuation, {
+        ['#seriesProperties']: seriesProperties,
+        [input('group')]: group,
+      }) => continuation({
+        ['#resolvedSeriesList']:
+          seriesProperties
+            .map(properties => ({
+              ...properties,
+              group,
+            })),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
new file mode 100644
index 00000000..906f5bc5
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -0,0 +1,36 @@
+// Check out the info on reverseReferenceList!
+// This is its composable form.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverseReferenceList'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    // TODO: Check that the reverse spec returns a list.
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+    }).outputs({
+      '#resolvedReverse': '#reverseReferenceList',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..5e85fa6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {compareCaseLessSensitive, normalizeName} from '#sort';
+import {validateWikiData} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
new file mode 100644
index 00000000..7c267038
--- /dev/null
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -0,0 +1,36 @@
+// Like withReverseReferenceList, but this is specifically for special "unique"
+// references, meaning this thing is referenced by exactly one or zero things
+// in the data list.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueReferencingThing`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#uniqueReferencingThing'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+      options: input.value({unique: true}),
+    }).outputs({
+      '#resolvedReverse': '#uniqueReferencingThing',
+    }),
+  ],
+});
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/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..c5971d4a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,14 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+    expose: {transform: value => value ?? []},
+  };
+}
diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js
new file mode 100644
index 00000000..8e6c96a1
--- /dev/null
+++ b/src/data/composite/wiki-properties/annotatedReferenceList.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  isContentString,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList}
+  from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
+
+export default templateCompositeFrom({
+  annotation: `annotatedReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    ...referenceListInputDescriptions(),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    reference: input.staticValue({type: 'string', defaultValue: 'reference'}),
+    annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}),
+    thing: input.staticValue({type: 'string', defaultValue: 'thing'}),
+  },
+
+  update(staticInputs) {
+    const {
+      [input.staticValue('reference')]: referenceProperty,
+      [input.staticValue('annotation')]: annotationProperty,
+    } = staticInputs;
+
+    return referenceListUpdateDescription({
+      validateReferenceList: type =>
+        validateArrayItems(
+          validateProperties({
+            [referenceProperty]: validateReference(type),
+            [annotationProperty]: optional(isContentString),
+          })),
+    })(staticInputs);
+  },
+
+  steps: () => [
+    withResolvedAnnotatedReferenceList({
+      list: input.updateValue(),
+
+      reference: input('reference'),
+      annotation: input('annotation'),
+      thing: input('thing'),
+
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}),
+  ],
+});
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..928bbd1b
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -0,0 +1,34 @@
+// Artist commentary! Generally present on tracks and albums.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isCommentary} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `commentary`,
+
+  compose: false,
+
+  update: {
+    validate: isCommentary,
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedCommentaryEntries({
+      from: input.updateValue(),
+    }),
+
+    exposeDependency({
+      dependency: '#parsedCommentaryEntries',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
new file mode 100644
index 00000000..c5c14769
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -0,0 +1,49 @@
+// List of artists referenced in commentary entries.
+// This is mostly useful for credits and listings on artist pages.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
+  from '#composite/data';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `commentatorArtists`,
+
+  compose: false,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'commentary',
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedCommentaryEntries({
+      from: 'commentary',
+    }),
+
+    withPropertyFromList({
+      list: '#parsedCommentaryEntries',
+      property: input.value('artists'),
+    }).outputs({
+      '#parsedCommentaryEntries.artists': '#artistLists',
+    }),
+
+    withFlattenedList({
+      list: '#artistLists',
+    }).outputs({
+      '#flattenedList': '#artists',
+    }),
+
+    withUniqueItemsOnly({
+      list: '#artists',
+    }),
+
+    exposeDependency({
+      dependency: '#artists',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
new file mode 100644
index 00000000..0ee3bfcd
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -0,0 +1,68 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateThing} from '#validators';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtwork`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateThing({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    exposeDependency({
+      dependency: '#constitutedArtwork',
+    }),
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
new file mode 100644
index 00000000..246c08b5
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -0,0 +1,70 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateWikiData} from '#validators';
+
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtworkList`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateWikiData({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    {
+      dependencies: ['#constitutedArtwork'],
+      compute: ({
+        ['#constitutedArtwork']: constitutedArtwork,
+      }) => [constitutedArtwork],
+    },
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/contentString.js b/src/data/composite/wiki-properties/contentString.js
new file mode 100644
index 00000000..b0e82444
--- /dev/null
+++ b/src/data/composite/wiki-properties/contentString.js
@@ -0,0 +1,15 @@
+// String type that's slightly more specific than simpleString. If the
+// property is a generic piece of human-reading content, this adds some
+// useful valiation on top of simpleString - but still check if more
+// particular properties like `name` are more appropriate.
+//
+// This type adapts validation for single- and multiline content.
+
+import {isContentString} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isContentString},
+  };
+}
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..d9a6b417
--- /dev/null
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -0,0 +1,58 @@
+// 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:
+//
+//   [
+//     {artist: 'Artist Name', annotation: 'Viola'},
+//     {artist: 'artist:john-cena', annotation: null},
+//     ...
+//   ]
+//
+// ...typically as processed from YAML, spreadsheet, or elsewhere.
+// Exposes as the same, but with the artist property 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, isDate, isStringNonEmpty} from '#validators';
+
+import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `contributionList`,
+
+  compose: false,
+
+  inputs: {
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  update: {validate: isContributionList},
+
+  steps: () => [
+    withResolvedContribs({
+      from: input.updateValue(),
+      thingProperty: input.thisProperty(),
+      artistProperty: input('artistProperty'),
+      date: input('date'),
+    }),
+
+    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..1756a8e5
--- /dev/null
+++ b/src/data/composite/wiki-properties/directory.js
@@ -0,0 +1,41 @@
+// 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 {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withDirectory} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `directory`,
+
+  compose: false,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withDirectory({
+      directory: input.updateValue({validate: isDirectory}),
+      name: input('name'),
+      suffix: input('suffix'),
+    }),
+
+    exposeDependency({
+      dependency: '#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/helpers/reference-list-helpers.js b/src/data/composite/wiki-properties/helpers/reference-list-helpers.js
new file mode 100644
index 00000000..dfdc6b41
--- /dev/null
+++ b/src/data/composite/wiki-properties/helpers/reference-list-helpers.js
@@ -0,0 +1,44 @@
+import {input} from '#composite';
+import {anyOf, isString, isThingClass, validateArrayItems} from '#validators';
+
+export function referenceListInputDescriptions() {
+  return {
+    class: input.staticValue({
+      validate:
+        anyOf(
+          isThingClass,
+          validateArrayItems(isThingClass)),
+
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+
+    referenceType: input.staticValue({
+      validate:
+        anyOf(
+          isString,
+          validateArrayItems(isString)),
+
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+  };
+}
+
+export function referenceListUpdateDescription({
+  validateReferenceList,
+}) {
+  return ({
+    [input.staticValue('class')]: thingClass,
+    [input.staticValue('referenceType')]: referenceType,
+  }) => ({
+    validate:
+      validateReferenceList(
+        (Array.isArray(thingClass)
+          ? thingClass.map(thingClass =>
+              thingClass[Symbol.for('Thing.referenceType')])
+       : thingClass
+          ? thingClass[Symbol.for('Thing.referenceType')]
+          : referenceType)),
+  });
+}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
new file mode 100644
index 00000000..892fc44a
--- /dev/null
+++ b/src/data/composite/wiki-properties/index.js
@@ -0,0 +1,38 @@
+// #composite/wiki-properties
+//
+// Entries here may depend on entries in #composite/control-flow,
+// #composite/data, and #composite/wiki-data.
+
+export {default as additionalFiles} from './additionalFiles.js';
+export {default as additionalNameList} from './additionalNameList.js';
+export {default as annotatedReferenceList} from './annotatedReferenceList.js';
+export {default as color} from './color.js';
+export {default as commentary} from './commentary.js';
+export {default as commentatorArtists} from './commentatorArtists.js';
+export {default as constitutibleArtwork} from './constitutibleArtwork.js';
+export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
+export {default as contentString} from './contentString.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 lyrics} from './lyrics.js';
+export {default as name} from './name.js';
+export {default as referenceList} from './referenceList.js';
+export {default as referencedArtworkList} from './referencedArtworkList.js';
+export {default as reverseReferenceList} from './reverseReferenceList.js';
+export {default as seriesList} from './seriesList.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 soupyFind} from './soupyFind.js';
+export {default as soupyReverse} from './soupyReverse.js';
+export {default as thing} from './thing.js';
+export {default as thingList} from './thingList.js';
+export {default as urls} from './urls.js';
+export {default as wallpaperParts} from './wallpaperParts.js';
+export {default as wikiData} from './wikiData.js';
diff --git a/src/data/composite/wiki-properties/lyrics.js b/src/data/composite/wiki-properties/lyrics.js
new file mode 100644
index 00000000..eb5e524a
--- /dev/null
+++ b/src/data/composite/wiki-properties/lyrics.js
@@ -0,0 +1,36 @@
+// Lyrics! This comes in two styles - "old", where there's just one set of
+// lyrics, or the newer/standard one, with multiple sets that are each
+// annotated, credited, etc.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isLyrics} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedLyricsEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `lyrics`,
+
+  compose: false,
+
+  update: {
+    validate: isLyrics,
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedLyricsEntries({
+      from: input.updateValue(),
+    }),
+
+    exposeDependency({
+      dependency: '#parsedLyricsEntries',
+    }),
+  ],
+});
diff --git a/src/data/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..4f8207b5
--- /dev/null
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -0,0 +1,46 @@
+// Stores and exposes a list of references to other data objects; all items
+// must be references to the same type, which is either implied from the class
+// input, or explicitly set on the referenceType input.
+//
+// See also:
+//  - singleReference
+//  - withResolvedReferenceList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateReferenceList} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedReferenceList}
+  from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
+
+export default templateCompositeFrom({
+  annotation: `referenceList`,
+
+  compose: false,
+
+  inputs: {
+    ...referenceListInputDescriptions(),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+  },
+
+  update:
+    referenceListUpdateDescription({
+      validateReferenceList: validateReferenceList,
+    }),
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
new file mode 100644
index 00000000..9ba2e393
--- /dev/null
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -0,0 +1,32 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {isDate} from '#validators';
+
+import annotatedReferenceList from './annotatedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `referencedArtworkList`,
+
+  compose: false,
+
+  steps: () => [
+    {
+      compute: (continuation) => continuation({
+        ['#find']:
+          find.mixed({
+            track: find.trackPrimaryArtwork,
+            album: find.albumPrimaryArtwork,
+          }),
+      }),
+    },
+
+    annotatedReferenceList({
+      referenceType: input.value(['album', 'track']),
+
+      data: 'artworkData',
+      find: '#find',
+
+      thing: input.value('artwork'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js
new file mode 100644
index 00000000..6d590a67
--- /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.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyReverse, inputWikiData, withReverseReferenceList}
+  from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `reverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: input('data'),
+      reverse: input('reverse'),
+    }),
+
+    exposeDependency({dependency: '#reverseReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js
new file mode 100644
index 00000000..2a101b45
--- /dev/null
+++ b/src/data/composite/wiki-properties/seriesList.js
@@ -0,0 +1,31 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isSeriesList, validateThing} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedSeriesList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `seriesList`,
+
+  compose: false,
+
+  inputs: {
+    group: input({
+      validate: validateThing({referenceType: 'group'}),
+    }),
+  },
+
+  steps: () => [
+    withResolvedSeriesList({
+      group: input('group'),
+
+      list: input.updateValue({
+        validate: isSeriesList,
+      }),
+    }),
+
+    exposeDependency({
+      dependency: '#resolvedSeriesList',
+    }),
+  ],
+});
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..7bf317ac
--- /dev/null
+++ b/src/data/composite/wiki-properties/simpleString.js
@@ -0,0 +1,12 @@
+// 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';
+
+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..f532ebbe
--- /dev/null
+++ b/src/data/composite/wiki-properties/singleReference.js
@@ -0,0 +1,46 @@
+// 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 {isThingClass, validateReference} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedReference}
+  from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `singleReference`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+
+    find: inputSoupyFind(),
+    data: inputWikiData({allowMixedTypes: false}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateReference(
+        thingClass[Symbol.for('Thing.referenceType')]),
+  }),
+
+  steps: () => [
+    withResolvedReference({
+      ref: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReference'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/soupyFind.js b/src/data/composite/wiki-properties/soupyFind.js
new file mode 100644
index 00000000..0f9a17e3
--- /dev/null
+++ b/src/data/composite/wiki-properties/soupyFind.js
@@ -0,0 +1,14 @@
+import {isObject} from '#validators';
+
+import {inputSoupyFind} from '#composite/wiki-data';
+
+function soupyFind() {
+  return {
+    flags: {update: true},
+    update: {validate: isObject},
+  };
+}
+
+soupyFind.input = inputSoupyFind.input;
+
+export default soupyFind;
diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js
new file mode 100644
index 00000000..784a66b4
--- /dev/null
+++ b/src/data/composite/wiki-properties/soupyReverse.js
@@ -0,0 +1,37 @@
+import {isObject} from '#validators';
+
+import {inputSoupyReverse} from '#composite/wiki-data';
+
+function soupyReverse() {
+  return {
+    flags: {update: true},
+    update: {validate: isObject},
+  };
+}
+
+soupyReverse.input = inputSoupyReverse.input;
+
+soupyReverse.contributionsBy =
+  (bindTo, contributionsProperty) => ({
+    bindTo,
+
+    referencing: thing => thing[contributionsProperty],
+    referenced: contrib => [contrib.artist],
+  });
+
+soupyReverse.artworkContributionsBy =
+  (bindTo, artworkProperty, {single = false} = {}) => ({
+    bindTo,
+
+    referencing: thing =>
+      (single
+        ? (thing[artworkProperty]
+            ? thing[artworkProperty].artistContribs
+            : [])
+        : thing[artworkProperty]
+            .flatMap(artwork => artwork.artistContribs)),
+
+    referenced: contrib => [contrib.artist],
+  });
+
+export default soupyReverse;
diff --git a/src/data/composite/wiki-properties/thing.js b/src/data/composite/wiki-properties/thing.js
new file mode 100644
index 00000000..1f97a362
--- /dev/null
+++ b/src/data/composite/wiki-properties/thing.js
@@ -0,0 +1,40 @@
+// An individual Thing, provided directly rather than by reference.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateThing} from '#validators';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({
+      validate: isThingClass,
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateThing({
+        referenceType:
+          (thingClass
+            ? thingClass[Symbol.for('Thing.referenceType')]
+            : ''),
+      }),
+  }),
+
+  steps: () => [
+    exposeUpdateValueOrContinue(),
+
+    exposeConstant({
+      value: input.value(null),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/thingList.js b/src/data/composite/wiki-properties/thingList.js
new file mode 100644
index 00000000..f4c00e06
--- /dev/null
+++ b/src/data/composite/wiki-properties/thingList.js
@@ -0,0 +1,44 @@
+// A list of Things, provided directly rather than by reference.
+//
+// Essentially the same as wikiData, but exposes the list of things,
+// instead of keeping it private.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateWikiData} from '#validators';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({
+      validate: isThingClass,
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateWikiData({
+        referenceType:
+          (thingClass
+            ? thingClass[Symbol.for('Thing.referenceType')]
+            : ''),
+      }),
+  }),
+
+  steps: () => [
+    exposeUpdateValueOrContinue(),
+
+    exposeConstant({
+      value: input.value([]),
+    }),
+  ],
+});
+
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/wallpaperParts.js b/src/data/composite/wiki-properties/wallpaperParts.js
new file mode 100644
index 00000000..23049397
--- /dev/null
+++ b/src/data/composite/wiki-properties/wallpaperParts.js
@@ -0,0 +1,9 @@
+import {isWallpaperPartList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isWallpaperPartList},
+    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..3bebed33
--- /dev/null
+++ b/src/data/composite/wiki-properties/wikiData.js
@@ -0,0 +1,27 @@
+// General purpose wiki data constructor, for properties like artistData,
+// trackData, etc.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateWikiData} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateWikiData({
+        referenceType:
+          thingClass[Symbol.for('Thing.referenceType')],
+      }),
+  }),
+
+  steps: () => [],
+});