« 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/wiki-properties
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/composite/wiki-properties')
-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
35 files changed, 1079 insertions, 0 deletions
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: () => [],
+});