« 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/yaml.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/yaml.js')
-rw-r--r--src/data/yaml.js1071
1 files changed, 1071 insertions, 0 deletions
diff --git a/src/data/yaml.js b/src/data/yaml.js
new file mode 100644
index 0000000..86f3014
--- /dev/null
+++ b/src/data/yaml.js
@@ -0,0 +1,1071 @@
+// yaml.js - specification for HSMusic YAML data file format and utilities for
+// loading, processing, and validating YAML files and documents
+
+import {readFile, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {inspect as nodeInspect} from 'node:util';
+
+import yaml from 'js-yaml';
+
+import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {sortByName} from '#sort';
+import {atOffset, empty, filterProperties, typeAppearance, withEntries}
+  from '#sugar';
+import Thing from '#thing';
+import thingConstructors from '#things';
+
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDuplicateDirectories,
+} from '#data-checks';
+
+import {
+  annotateErrorWithFile,
+  decorateErrorWithIndex,
+  decorateErrorWithAnnotation,
+  openAggregate,
+  showAggregate,
+  withAggregate,
+} from '#aggregate';
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
+}
+
+// General function for inputting a single document (usually loaded from YAML)
+// and outputting an instance of a provided Thing subclass.
+//
+// makeProcessDocument is a factory function: the returned function will take a
+// document and apply the configuration passed to makeProcessDocument in order
+// to construct a Thing subclass.
+//
+function makeProcessDocument(thingConstructor, {
+  // The bulk of configuration happens here in the spec's `fields` property.
+  // Each key is a field that's expected on the source document; fields that
+  // don't match one of these keys will cause an error. Values are object
+  // entries describing what to do with the field.
+  //
+  // A field entry's `property` tells what property the value for this field
+  // will be put into, on the respective Thing (subclass) instance.
+  //
+  // A field entry's `transform` optionally allows converting the raw value in
+  // YAML into some other format before providing setting it on the Thing
+  // instance.
+  //
+  // If a field entry has `ignore: true`, it will be completely skipped by the
+  // YAML parser - it won't be validated, read, or loaded into data objects.
+  // This is mainly useful for fields that are purely annotational or are
+  // currently placeholders.
+  //
+  fields: fieldSpecs = {},
+
+  // List of fields which are invalid when coexisting in a document.
+  // Data objects are generally allowing with regards to what properties go
+  // together, allowing for properties to be set separately from each other
+  // instead of complaining about invalid or unused-data cases. But it's
+  // useful to see these kinds of errors when actually validating YAML files!
+  //
+  // Each item of this array should itself be an object with a descriptive
+  // message and a list of fields. Of those fields, none should ever coexist
+  // with any other. For example:
+  //
+  //   [
+  //     {message: '...', fields: ['A', 'B', 'C']},
+  //     {message: '...', fields: ['C', 'D']},
+  //   ]
+  //
+  // ...means A can't coexist with B or C, B can't coexist with A or C, and
+  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // A or B.
+  //
+  invalidFieldCombinations = [],
+}) {
+  if (!thingConstructor) {
+    throw new Error(`Missing Thing class`);
+  }
+
+  if (!fieldSpecs) {
+    throw new Error(`Expected fields to be provided`);
+  }
+
+  const knownFields = Object.keys(fieldSpecs);
+
+  const ignoredFields =
+    Object.entries(fieldSpecs)
+      .filter(([, {ignore}]) => ignore)
+      .map(([field]) => field);
+
+  const propertyToField =
+    withEntries(fieldSpecs, entries => entries
+      .map(([field, {property}]) => [property, field]));
+
+  // TODO: Is this function even necessary??
+  // Aren't we doing basically the same work in the function it's decorating???
+  const decorateErrorWithName = (fn) => {
+    const nameField = propertyToField.name;
+    if (!nameField) return fn;
+
+    return (document) => {
+      try {
+        return fn(document);
+      } catch (error) {
+        const name = document[nameField];
+        error.message = name
+          ? `(name: ${inspect(name)}) ${error.message}`
+          : `(${colors.dim(`no name found`)}) ${error.message}`;
+        throw error;
+      }
+    };
+  };
+
+  return decorateErrorWithName((document) => {
+    const nameField = propertyToField.name;
+    const namePart =
+      (nameField
+        ? (document[nameField]
+          ? ` named ${colors.green(`"${document[nameField]}"`)}`
+          : ` (name field, "${nameField}", not specified)`)
+        : ``);
+
+    const constructorPart =
+      (thingConstructor[Thing.friendlyName]
+        ? thingConstructor[Thing.friendlyName]
+     : thingConstructor.name
+        ? thingConstructor.name
+        : `document`);
+
+    const aggregate = openAggregate({
+      message: `Errors processing ${constructorPart}` + namePart,
+    });
+
+    const documentEntries = Object.entries(document)
+      .filter(([field]) => !ignoredFields.includes(field));
+
+    const skippedFields = new Set();
+
+    const unknownFields = documentEntries
+      .map(([field]) => field)
+      .filter((field) => !knownFields.includes(field));
+
+    if (!empty(unknownFields)) {
+      aggregate.push(new UnknownFieldsError(unknownFields));
+
+      for (const field of unknownFields) {
+        skippedFields.add(field);
+      }
+    }
+
+    const presentFields = Object.keys(document);
+
+    const fieldCombinationErrors = [];
+
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent =
+        presentFields.filter(field => fields.includes(field));
+
+      if (fieldsPresent.length >= 2) {
+        const filteredDocument =
+          filterProperties(
+            document,
+            fieldsPresent,
+            {preserveOriginalOrder: true});
+
+        fieldCombinationErrors.push(
+          new FieldCombinationError(filteredDocument, message));
+
+        for (const field of Object.keys(filteredDocument)) {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(fieldCombinationErrors)) {
+      aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors));
+    }
+
+    const fieldValues = {};
+
+    for (const [field, documentValue] of documentEntries) {
+      if (skippedFields.has(field)) continue;
+
+      // This variable would like to certify itself as "not into capitalism".
+      let propertyValue =
+        (fieldSpecs[field].transform
+          ? fieldSpecs[field].transform(documentValue)
+          : documentValue);
+
+      // Completely blank items in a YAML list are read as null.
+      // They're handy to have around when filling out a document and shouldn't
+      // be considered an error (or data at all).
+      if (Array.isArray(propertyValue)) {
+        const wasEmpty = empty(propertyValue);
+
+        propertyValue =
+          propertyValue.filter(item => item !== null);
+
+        const isEmpty = empty(propertyValue);
+
+        // Don't set arrays which are empty as a result of the above filter.
+        // Arrays which were originally empty, i.e. `Field: []`, are still
+        // valid data, but if it's just an array not containing any filled out
+        // items, it should be treated as a placeholder and skipped over.
+        if (isEmpty && !wasEmpty) {
+          propertyValue = null;
+        }
+      }
+
+      fieldValues[field] = propertyValue;
+    }
+
+    const thing = Reflect.construct(thingConstructor, []);
+
+    const fieldValueErrors = [];
+
+    for (const [field, value] of Object.entries(fieldValues)) {
+      const {property} = fieldSpecs[field];
+
+      try {
+        thing[property] = value;
+      } catch (caughtError) {
+        skippedFields.add(field);
+        fieldValueErrors.push(new FieldValueError(
+          field, value, {cause: caughtError}));
+      }
+    }
+
+    if (!empty(fieldValueErrors)) {
+      aggregate.push(new FieldValueAggregateError(
+        fieldValueErrors, thingConstructor));
+    }
+
+    if (skippedFields.size >= 1) {
+      aggregate.push(
+        new SkippedFieldsSummaryError(
+          filterProperties(
+            document,
+            Array.from(skippedFields),
+            {preserveOriginalOrder: true})));
+    }
+
+    return {thing, aggregate};
+  });
+}
+
+export class UnknownFieldsError extends Error {
+  constructor(fields) {
+    super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
+    this.fields = fields;
+  }
+}
+
+export class FieldCombinationAggregateError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Invalid field combinations - all involved fields ignored`);
+  }
+}
+
+export class FieldCombinationError extends Error {
+  constructor(fields, message) {
+    const fieldNames = Object.keys(fields);
+
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
+
+    const causeMessage =
+      (typeof message === 'function'
+        ? message(fields)
+     : typeof message === 'string'
+        ? message
+        : null);
+
+    super(mainMessage, {
+      cause:
+        (causeMessage
+          ? new Error(causeMessage)
+          : null),
+    });
+
+    this.fields = fields;
+  }
+}
+
+export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
+  }
+}
+
+export class FieldValueError extends Error {
+  constructor(field, value, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
+    super(
+      `Failed to set ${fieldText} field to ${valueText}`,
+      options);
+  }
+}
+
+export class SkippedFieldsSummaryError extends Error {
+  constructor(filteredDocument) {
+    const entries = Object.entries(filteredDocument);
+
+    const lines =
+      entries.map(([field, value]) =>
+        ` - ${field}: ` +
+        inspect(value, {maxStringLength: 70})
+          .split('\n')
+          .map((line, index) => index === 0 ? line : `   ${line}`)
+          .join('\n'));
+
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
+    super(
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      lines.join('\n') + '\n' +
+      colors.bright(colors.yellow(`See above errors for details.`)));
+  }
+}
+
+export function parseDate(date) {
+  return new Date(date);
+}
+
+export function parseDuration(string) {
+  if (typeof string !== 'string') {
+    return string;
+  }
+
+  const parts = string.split(':').map((n) => parseInt(n));
+  if (parts.length === 3) {
+    return parts[0] * 3600 + parts[1] * 60 + parts[2];
+  } else if (parts.length === 2) {
+    return parts[0] * 60 + parts[1];
+  } else {
+    return 0;
+  }
+}
+
+export function parseAdditionalFiles(array) {
+  if (!Array.isArray(array)) {
+    // Error will be caught when validating against whatever this value is
+    return array;
+  }
+
+  return array.map((item) => ({
+    title: item['Title'],
+    description: item['Description'] ?? null,
+    files: item['Files'],
+  }));
+}
+
+export const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
+
+export function parseContributors(contributionStrings) {
+  // If this isn't something we can parse, just return it as-is.
+  // The Thing object's validators will handle the data error better
+  // than we're able to here.
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
+  }
+
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
+  });
+}
+
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
+
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
+}
+
+export function parseDimensions(string) {
+  // It's technically possible to pass an array like [30, 40] through here.
+  // That's not really an issue because if it isn't of the appropriate shape,
+  // the Thing object's validators will handle the error.
+  if (typeof string !== 'string') {
+    return string;
+  }
+
+  const parts = string.split(/[x,* ]+/g);
+
+  if (parts.length !== 2) {
+    throw new Error(`Invalid dimensions: ${string} (expected "width & height")`);
+  }
+
+  const nums = parts.map((part) => Number(part.trim()));
+
+  if (nums.includes(NaN)) {
+    throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
+  }
+
+  return nums;
+}
+
+// documentModes: Symbols indicating sets of behavior for loading and processing
+// data files.
+export const documentModes = {
+  // onePerFile: One document per file. Expects files array (or function) and
+  // processDocument function. Obviously, each specified data file should only
+  // contain one YAML document (an error will be thrown otherwise). Calls save
+  // with an array of processed documents (wiki objects).
+  onePerFile: Symbol('Document mode: onePerFile'),
+
+  // headerAndEntries: One or more documents per file; the first document is
+  // treated as a "header" and represents data which pertains to all following
+  // "entry" documents. Expects files array (or function) and
+  // processHeaderDocument and processEntryDocument functions. Calls save with
+  // an array of {header, entries} objects.
+  //
+  // Please note that the final results loaded from each file may be "missing"
+  // data objects corresponding to entry documents if the processEntryDocument
+  // function throws on any entries, resulting in partial data provided to
+  // save() - errors will be caught and thrown in the final buildSteps
+  // aggregate. However, if the processHeaderDocument function fails, all
+  // following documents in the same file will be ignored as well (i.e. an
+  // entire file will be excempt from the save() function's input).
+  headerAndEntries: Symbol('Document mode: headerAndEntries'),
+
+  // allInOne: One or more documents, all contained in one file. Expects file
+  // string (or function) and processDocument function. Calls save with an
+  // array of processed documents (wiki objects).
+  allInOne: Symbol('Document mode: allInOne'),
+
+  // oneDocumentTotal: Just a single document, represented in one file.
+  // Expects file string (or function) and processDocument function. Calls
+  // save with the single processed wiki document (data object).
+  //
+  // Please note that if the single document fails to process, the save()
+  // function won't be called at all, generally resulting in an altogether
+  // missing property from the global wikiData object. This should be caught
+  // and handled externally.
+  oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'),
+};
+
+// dataSteps: Top-level array of "steps" for loading YAML document files.
+//
+// title:
+//   Name of the step (displayed in build output)
+//
+// documentMode:
+//   Symbol which indicates by which "mode" documents from data files are
+//   loaded and processed. See documentModes export.
+//
+// file, files:
+//   String or array of strings which are paths to YAML data files, or a
+//   function which returns the above (may be async). All paths are appended to
+//   the global dataPath provided externally (e.g. HSMUSIC_DATA env variable).
+//   Which to provide (file or files) depends on documentMode. If this is a
+//   function, it will be provided with dataPath (e.g. so that a sub-path may be
+//   readdir'd), but don't path.join(dataPath) the returned value(s) yourself -
+//   this will be done automatically.
+//
+// processDocument, processHeaderDocument, processEntryDocument:
+//   Functions which take a YAML document and return an actual wiki data object;
+//   all actual conversion between YAML and wiki data happens here. Which to
+//   provide (one or a combination) depend on documentMode.
+//
+// save:
+//   Function which takes all documents processed (now as wiki data objects) and
+//   actually applies them to a global wiki data object, for use in page
+//   generation and other behavior. Returns an object to be assigned over the
+//   global wiki data object (so specify any new properties here). This is also
+//   the place to perform any final post-processing on data objects (linking
+//   them to each other, setting additional properties, etc). Input argument
+//   format depends on documentMode.
+//
+export const getDataSteps = () => {
+  const steps = [];
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
+    if (!getSpecFn) continue;
+
+    steps.push(getSpecFn({
+      documentModes,
+      thingConstructors,
+    }));
+  }
+
+  sortByName(steps, {getName: step => step.title});
+
+  return steps;
+};
+
+export async function loadAndProcessDataDocuments({dataPath}) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
+
+  function decorateErrorWithFile(fn) {
+    return decorateErrorWithAnnotation(fn,
+      (caughtError, firstArg) =>
+        annotateErrorWithFile(
+          caughtError,
+          path.relative(
+            dataPath,
+            (typeof firstArg === 'object'
+              ? firstArg.file
+              : firstArg))));
+  }
+
+  function asyncDecorateErrorWithFile(fn) {
+    return decorateErrorWithFile(fn).async;
+  }
+
+  for (const dataStep of getDataSteps()) {
+    await processDataAggregate.nestAsync(
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
+      async ({call, callAsync, map, mapAsync, push}) => {
+        const {documentMode} = dataStep;
+
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
+        }
+
+        // Hear me out, it's been like 1200 years since I wrote the rest of
+        // this beautifully error-containing code and I don't know how to
+        // integrate this nicely. So I'm just returning the result and the
+        // error that should be thrown. Yes, we're back in callback hell,
+        // just without the callbacks. Thank you.
+        const filterBlankDocuments = documents => {
+          const aggregate = openAggregate({
+            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+          });
+
+          const filteredDocuments =
+            documents
+              .filter(doc => doc !== null);
+
+          if (filteredDocuments.length !== documents.length) {
+            const blankIndexRangeInfo =
+              documents
+                .map((doc, index) => [doc, index])
+                .filter(([doc]) => doc === null)
+                .map(([doc, index]) => index)
+                .reduce((accumulator, index) => {
+                  if (accumulator.length === 0) {
+                    return [[index, index]];
+                  }
+                  const current = accumulator.at(-1);
+                  const rest = accumulator.slice(0, -1);
+                  if (current[1] === index - 1) {
+                    return rest.concat([[current[0], index]]);
+                  } else {
+                    return accumulator.concat([[index, index]]);
+                  }
+                }, [])
+                .map(([start, end]) => ({
+                  start,
+                  end,
+                  count: end - start + 1,
+                  previous: atOffset(documents, start, -1),
+                  next: atOffset(documents, end, +1),
+                }));
+
+            for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
+              const parts = [];
+
+              if (count === 1) {
+                const range = `#${start + 1}`;
+                parts.push(`${count} document (${colors.yellow(range)}), `);
+              } else {
+                const range = `#${start + 1}-${end + 1}`;
+                parts.push(`${count} documents (${colors.yellow(range)}), `);
+              }
+
+              if (previous === null) {
+                parts.push(`at start of file`);
+              } else if (next === null) {
+                parts.push(`at end of file`);
+              } else {
+                const previousDescription = Object.entries(previous).at(0).join(': ');
+                const nextDescription = Object.entries(next).at(0).join(': ');
+                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
+              }
+
+              aggregate.push(new Error(parts.join('')));
+            }
+          }
+
+          return {documents: filteredDocuments, aggregate};
+        };
+
+        const processDocument = (document, thingClassOrFn) => {
+          const thingClass =
+            (thingClassOrFn.prototype instanceof Thing
+              ? thingClassOrFn
+              : thingClassOrFn(document));
+
+          if (typeof thingClass !== 'function') {
+            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+          }
+
+          if (!(thingClass.prototype instanceof Thing)) {
+            throw new Error(`Expected a thing class, got ${thingClass.name}`);
+          }
+
+          const spec = thingClass[Thing.yamlDocumentSpec];
+
+          if (!spec) {
+            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+          }
+
+          // TODO: Making a function to only call it just like that is
+          // obviously pretty jank! It should be created once per data step.
+          const fn = makeProcessDocument(thingClass, spec);
+          return fn(document);
+        };
+
+        if (
+          documentMode === documentModes.allInOne ||
+          documentMode === documentModes.oneDocumentTotal
+        ) {
+          if (!dataStep.file) {
+            throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+          }
+
+          const file = path.join(
+            dataPath,
+            typeof dataStep.file === 'function'
+              ? await callAsync(dataStep.file, dataPath)
+              : dataStep.file);
+
+          const statResult = await callAsync(() =>
+            stat(file).then(
+              () => true,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return false;
+                } else {
+                  throw error;
+                }
+              }));
+
+          if (statResult === false) {
+            const saveResult = call(dataStep.save, {
+              [documentModes.allInOne]: [],
+              [documentModes.oneDocumentTotal]: {},
+            }[documentMode]);
+
+            if (!saveResult) return;
+
+            Object.assign(wikiDataResult, saveResult);
+
+            return;
+          }
+
+          const readResult = await callAsync(readFile, file, 'utf-8');
+
+          if (!readResult) {
+            return;
+          }
+
+          let processResults;
+
+          switch (documentMode) {
+            case documentModes.oneDocumentTotal: {
+              const yamlResult = call(yaml.load, readResult);
+
+              if (!yamlResult) {
+                processResults = null;
+                break;
+              }
+
+              const {thing, aggregate} =
+                processDocument(yamlResult, dataStep.documentThing);
+
+              processResults = thing;
+
+              call(() => aggregate.close());
+
+              break;
+            }
+
+            case documentModes.allInOne: {
+              const yamlResults = call(yaml.loadAll, readResult);
+
+              if (!yamlResults) {
+                processResults = [];
+                return;
+              }
+
+              const {documents, aggregate: filterAggregate} =
+                filterBlankDocuments(yamlResults);
+
+              call(filterAggregate.close);
+
+              processResults = [];
+
+              map(documents, decorateErrorWithIndex(document => {
+                const {thing, aggregate} =
+                  processDocument(document, dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }), {message: `Errors processing documents`});
+
+              break;
+            }
+          }
+
+          if (!processResults) return;
+
+          const saveResult = call(dataStep.save, processResults);
+
+          if (!saveResult) return;
+
+          Object.assign(wikiDataResult, saveResult);
+
+          return;
+        }
+
+        if (!dataStep.files) {
+          throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+        }
+
+        const filesFromDataStep =
+          (typeof dataStep.files === 'function'
+            ? await callAsync(() =>
+                dataStep.files(dataPath).then(
+                  files => files,
+                  error => {
+                    if (error.code === 'ENOENT') {
+                      return [];
+                    } else {
+                      throw error;
+                    }
+                  }))
+            : dataStep.files);
+
+        const filesUnderDataPath =
+          filesFromDataStep
+            .map(file => path.join(dataPath, file));
+
+        const yamlResults = [];
+
+        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
+          asyncDecorateErrorWithFile(async file => {
+            let contents;
+            try {
+              contents = await readFile(file, 'utf-8');
+            } catch (caughtError) {
+              throw new Error(`Failed to read data file`, {cause: caughtError});
+            }
+
+            let documents;
+            try {
+              documents = yaml.loadAll(contents);
+            } catch (caughtError) {
+              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+            }
+
+            const {documents: filteredDocuments, aggregate: filterAggregate} =
+              filterBlankDocuments(documents);
+
+            try {
+              filterAggregate.close();
+            } catch (caughtError) {
+              // Blank documents aren't a critical error, they're just something
+              // that should be noted - the (filtered) documents still get pushed.
+              const pathToFile = path.relative(dataPath, file);
+              annotateErrorWithFile(caughtError, pathToFile);
+              push(caughtError);
+            }
+
+            yamlResults.push({file, documents: filteredDocuments});
+          }));
+
+        const processResults = [];
+
+        switch (documentMode) {
+          case documentModes.headerAndEntries:
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
+              decorateErrorWithFile(({documents}) => {
+                const headerDocument = documents[0];
+                const entryDocuments = documents.slice(1).filter(Boolean);
+
+                if (!headerDocument)
+                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
+
+                withAggregate({message: `Errors processing documents`}, ({push}) => {
+                  const {thing: headerObject, aggregate: headerAggregate} =
+                    processDocument(headerDocument, dataStep.headerDocumentThing);
+
+                  try {
+                    headerAggregate.close();
+                  } catch (caughtError) {
+                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+                    push(caughtError);
+                  }
+
+                  const entryObjects = [];
+
+                  for (let index = 0; index < entryDocuments.length; index++) {
+                    const entryDocument = entryDocuments[index];
+
+                    const {thing: entryObject, aggregate: entryAggregate} =
+                      processDocument(entryDocument, dataStep.entryDocumentThing);
+
+                    entryObjects.push(entryObject);
+
+                    try {
+                      entryAggregate.close();
+                    } catch (caughtError) {
+                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+                      push(caughtError);
+                    }
+                  }
+
+                  processResults.push({
+                    header: headerObject,
+                    entries: entryObjects,
+                  });
+                });
+              }));
+            break;
+
+          case documentModes.onePerFile:
+            map(yamlResults, {message: `Errors processing data files as valid documents`},
+              decorateErrorWithFile(({documents}) => {
+                if (documents.length > 1)
+                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
+
+                if (empty(documents) || !documents[0])
+                  throw new Error(`Expected a document, this file is empty`);
+
+                const {thing, aggregate} =
+                  processDocument(documents[0], dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }));
+            break;
+        }
+
+        const saveResult = call(dataStep.save, processResults);
+
+        if (!saveResult) return;
+
+        Object.assign(wikiDataResult, saveResult);
+      }
+    );
+  }
+
+  return {
+    aggregate: processDataAggregate,
+    result: wikiDataResult,
+  };
+}
+
+// Data linking! Basically, provide (portions of) wikiData to the Things which
+// require it - they'll expose dynamically computed properties as a result (many
+// of which are required for page HTML generation and other expected behavior).
+export function linkWikiDataArrays(wikiData) {
+  const linkWikiDataSpec = new Map([
+    [wikiData.albumData, [
+      'artTagData',
+      'artistData',
+      'groupData',
+    ]],
+
+    [wikiData.artTagData, [
+      'albumData',
+      'trackData',
+    ]],
+
+    [wikiData.artistData, [
+      'albumData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [wikiData.flashData, [
+      'artistData',
+      'flashActData',
+      'trackData',
+    ]],
+
+    [wikiData.flashActData, [
+      'flashData',
+      'flashSideData',
+    ]],
+
+    [wikiData.flashSideData, [
+      'flashActData',
+    ]],
+
+    [wikiData.groupData, [
+      'albumData',
+      'groupCategoryData',
+    ]],
+
+    [wikiData.groupCategoryData, [
+      'groupData',
+    ]],
+
+    [wikiData.homepageLayout?.rows, [
+      'albumData',
+      'groupData',
+    ]],
+
+    [wikiData.trackData, [
+      'albumData',
+      'artTagData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [[wikiData.wikiInfo], [
+      'groupData',
+    ]],
+  ]);
+
+  for (const [things, keys] of linkWikiDataSpec.entries()) {
+    if (things === undefined) continue;
+    for (const thing of things) {
+      if (thing === undefined) continue;
+      for (const key of keys) {
+        if (!(key in wikiData)) continue;
+        thing[key] = wikiData[key];
+      }
+    }
+  }
+}
+
+export function sortWikiDataArrays(wikiData) {
+  for (const [key, value] of Object.entries(wikiData)) {
+    if (!Array.isArray(value)) continue;
+    wikiData[key] = value.slice();
+  }
+
+  const steps = getDataSteps();
+
+  for (const step of steps) {
+    if (!step.sort) continue;
+    step.sort(wikiData);
+  }
+
+  // Re-link data arrays, so that every object has the new, sorted versions.
+  // Note that the sorting step deliberately creates new arrays (mutating
+  // slices instead of the original arrays) - this is so that the object
+  // caching system understands that it's working with a new ordering.
+  // We still need to actually provide those updated arrays over again!
+  linkWikiDataArrays(wikiData);
+}
+
+// Utility function for loading all wiki data from the provided YAML data
+// directory (e.g. the root of the hsmusic-data repository). This doesn't
+// provide much in the way of customization; it's meant to be used more as
+// a boilerplate for more specialized output, or as a quick start in utilities
+// where reporting info about data loading isn't as relevant as during the
+// main wiki build process.
+export async function quickLoadAllFromYAML(dataPath, {
+  bindFind,
+  getAllFindSpecs,
+
+  showAggregate: customShowAggregate = showAggregate,
+}) {
+  const showAggregate = customShowAggregate;
+
+  let wikiData;
+
+  {
+    const {aggregate, result} = await loadAndProcessDataDocuments({dataPath});
+
+    wikiData = result;
+
+    try {
+      aggregate.close();
+      logInfo`Loaded data without errors. (complete data)`;
+    } catch (error) {
+      showAggregate(error);
+      logWarn`Loaded data with errors. (partial data)`;
+    }
+  }
+
+  linkWikiDataArrays(wikiData);
+
+  try {
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    logInfo`No duplicate directories found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
+
+  try {
+    filterReferenceErrors(wikiData, {bindFind}).close();
+    logInfo`No reference errors found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Reference errors found. (partial data)`;
+  }
+
+  try {
+    reportContentTextErrors(wikiData, {bindFind});
+    logInfo`No content text errors found.`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Content text errors found.`;
+  }
+
+  sortWikiDataArrays(wikiData);
+
+  return wikiData;
+}