« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/colors.js44
-rw-r--r--src/util/search-spec.js259
-rw-r--r--src/util/serialize.js77
-rw-r--r--src/util/sort.js438
-rw-r--r--src/util/sugar.js845
-rw-r--r--src/util/wiki-data.js475
6 files changed, 0 insertions, 2138 deletions
diff --git a/src/util/colors.js b/src/util/colors.js
deleted file mode 100644
index 7298c46a..00000000
--- a/src/util/colors.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Color and theming utility functions! Handy.
-
-export function getColors(themeColor, {
-  // chroma.js external dependency (https://gka.github.io/chroma.js/)
-  chroma,
-} = {}) {
-  if (!chroma) {
-    throw new Error('chroma.js library must be passed or bound for color manipulation');
-  }
-
-  const primary = chroma(themeColor);
-
-  const dark = primary.luminance(0.02);
-  const dim = primary.desaturate(2).darken(1.5);
-  const deep = primary.saturate(1.2).luminance(0.035);
-  const deepGhost = deep.alpha(0.8);
-  const light = chroma.average(['#ffffff', primary], 'rgb', [4, 1]);
-  const lightGhost = primary.luminance(0.8).saturate(4).alpha(0.08);
-
-  const bg = primary.luminance(0.008).desaturate(3.5).alpha(0.8);
-  const bgBlack = primary.saturate(1).luminance(0.0025).alpha(0.8);
-  const shadow = primary.desaturate(4).set('hsl.l', 0.05).alpha(0.8);
-
-  const hsl = primary.hsl();
-  if (isNaN(hsl[0])) hsl[0] = 0;
-
-  return {
-    primary: primary.hex(),
-
-    dark: dark.hex(),
-    dim: dim.hex(),
-    deep: deep.hex(),
-    deepGhost: deepGhost.hex(),
-    light: light.hex(),
-    lightGhost: lightGhost.hex(),
-
-    bg: bg.hex(),
-    bgBlack: bgBlack.hex(),
-    shadow: shadow.hex(),
-
-    rgb: primary.rgb(),
-    hsl,
-  };
-}
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
deleted file mode 100644
index 3d05c021..00000000
--- a/src/util/search-spec.js
+++ /dev/null
@@ -1,259 +0,0 @@
-// Index structures shared by client and server, and relevant interfaces.
-
-function getArtworkPath(thing) {
-  switch (thing.constructor[Symbol.for('Thing.referenceType')]) {
-    case 'album': {
-      return [
-        'media.albumCover',
-        thing.directory,
-        thing.coverArtFileExtension,
-      ];
-    }
-
-    case 'flash': {
-      return [
-        'media.flashArt',
-        thing.directory,
-        thing.coverArtFileExtension,
-      ];
-    }
-
-    case 'track': {
-      if (thing.hasUniqueCoverArt) {
-        return [
-          'media.trackCover',
-          thing.album.directory,
-          thing.directory,
-          thing.coverArtFileExtension,
-        ];
-      } else if (thing.album.hasCoverArt) {
-        return [
-          'media.albumCover',
-          thing.album.directory,
-          thing.album.coverArtFileExtension,
-        ];
-      } else {
-        return null;
-      }
-    }
-
-    default:
-      return null;
-  }
-}
-
-function prepareArtwork(thing, {
-  checkIfImagePathHasCachedThumbnails,
-  getThumbnailEqualOrSmaller,
-  urls,
-}) {
-  const hasWarnings =
-    thing.artTags?.some(artTag => artTag.isContentWarning);
-
-  const artworkPath =
-    getArtworkPath(thing);
-
-  if (!artworkPath) {
-    return undefined;
-  }
-
-  const mediaSrc =
-    urls
-      .from('media.root')
-      .to(...artworkPath);
-
-  if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) {
-    return undefined;
-  }
-
-  const selectedSize =
-    getThumbnailEqualOrSmaller(
-      (hasWarnings ? 'mini' : 'adorb'),
-      mediaSrc);
-
-  const mediaSrcJpeg =
-    mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
-
-  const displaySrc =
-    urls
-      .from('thumb.root')
-      .to('thumb.path', mediaSrcJpeg);
-
-  const serializeSrc =
-    displaySrc.replace(thing.directory, '<>');
-
-  return serializeSrc;
-}
-
-export const searchSpec = {
-  generic: {
-    query: ({
-      albumData,
-      artTagData,
-      artistData,
-      flashData,
-      groupData,
-      trackData,
-    }) => [
-      albumData,
-
-      artTagData,
-
-      artistData
-        .filter(artist => !artist.isAlias),
-
-      flashData,
-
-      groupData,
-
-      trackData
-        // Exclude rereleases - there's no reasonable way to differentiate
-        // them from the main release as part of this query.
-        .filter(track => !track.originalReleaseTrack),
-    ].flat(),
-
-    process(thing, opts) {
-      const fields = {};
-
-      fields.primaryName =
-        thing.name;
-
-      const kind =
-        thing.constructor[Symbol.for('Thing.referenceType')];
-
-      fields.parentName =
-        (kind === 'track'
-          ? thing.album.name
-       : kind === 'group'
-          ? thing.category.name
-       : kind === 'flash'
-          ? thing.act.name
-          : null);
-
-      fields.color =
-        thing.color;
-
-      fields.artTags =
-        (thing.constructor.hasPropertyDescriptor('artTags')
-          ? thing.artTags.map(artTag => artTag.nameShort)
-          : []);
-
-      fields.additionalNames =
-        (thing.constructor.hasPropertyDescriptor('additionalNames')
-          ? thing.additionalNames.map(entry => entry.name)
-       : thing.constructor.hasPropertyDescriptor('aliasNames')
-          ? thing.aliasNames
-          : []);
-
-      const contribKeys = [
-        'artistContribs',
-        'bannerArtistContribs',
-        'contributorContribs',
-        'coverArtistContribs',
-        'wallpaperArtistContribs',
-      ];
-
-      const contributions =
-        contribKeys
-          .filter(key => Object.hasOwn(thing, key))
-          .flatMap(key => thing[key]);
-
-      fields.contributors =
-        contributions
-          .flatMap(({artist}) => [
-            artist.name,
-            ...artist.aliasNames,
-          ]);
-
-      const groups =
-         (Object.hasOwn(thing, 'groups')
-           ? thing.groups
-        : Object.hasOwn(thing, 'album')
-           ? thing.album.groups
-           : []);
-
-      const mainContributorNames =
-        contributions
-          .map(({artist}) => artist.name);
-
-      fields.groups =
-        groups
-          .filter(group => !mainContributorNames.includes(group.name))
-          .map(group => group.name);
-
-      fields.artwork =
-        prepareArtwork(thing, opts);
-
-      return fields;
-    },
-
-    index: [
-      'primaryName',
-      'parentName',
-      'artTags',
-      'additionalNames',
-      'contributors',
-      'groups',
-    ],
-
-    store: [
-      'primaryName',
-      'artwork',
-      'color',
-    ],
-  },
-};
-
-export function makeSearchIndex(descriptor, {FlexSearch}) {
-  return new FlexSearch.Document({
-    id: 'reference',
-    index: descriptor.index,
-    store: descriptor.store,
-  });
-}
-
-// TODO: This function basically mirrors bind-utilities.js, which isn't
-// exactly robust, but... binding might need some more thought across the
-// codebase in *general.*
-function bindSearchUtilities({
-  checkIfImagePathHasCachedThumbnails,
-  getThumbnailEqualOrSmaller,
-  thumbsCache,
-  urls,
-}) {
-  const bound = {
-    urls,
-  };
-
-  bound.checkIfImagePathHasCachedThumbnails =
-    (imagePath) =>
-      checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache);
-
-  bound.getThumbnailEqualOrSmaller =
-    (preferred, imagePath) =>
-      getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache);
-
-  return bound;
-}
-
-export function populateSearchIndex(index, descriptor, opts) {
-  const {wikiData} = opts;
-  const bound = bindSearchUtilities(opts);
-
-  const collection = descriptor.query(wikiData);
-
-  for (const thing of collection) {
-    const reference = thing.constructor.getReference(thing);
-
-    let processed;
-    try {
-      processed = descriptor.process(thing, bound);
-    } catch (caughtError) {
-      throw new Error(
-        `Failed to process searchable thing ${reference}`,
-        {cause: caughtError});
-    }
-
-    index.add({reference, ...processed});
-  }
-}
diff --git a/src/util/serialize.js b/src/util/serialize.js
deleted file mode 100644
index eb18a759..00000000
--- a/src/util/serialize.js
+++ /dev/null
@@ -1,77 +0,0 @@
-// Utils used when per-wiki-object data files.
-// Retained for reference and/or later reorganization.
-//
-// Not to be confused with data/serialize.js, which provides a generic
-// interface for serializing any Thing object.
-
-/*
-export function serializeLink(thing) {
-  const ret = {};
-  ret.name = thing.name;
-  ret.directory = thing.directory;
-  if (thing.color) ret.color = thing.color;
-  return ret;
-}
-
-export function serializeContribs(contribs) {
-  return contribs.map(({artist, annotation}) => {
-    const ret = {};
-    ret.artist = serializeLink(artist);
-    if (annotation) ret.contribution = annotation;
-    return ret;
-  });
-}
-
-export function serializeImagePaths(original, {thumb}) {
-  return {
-    original,
-    medium: thumb.medium(original),
-    small: thumb.small(original),
-  };
-}
-
-export function serializeCover(thing, pathFunction, {
-  serializeImagePaths,
-  urls,
-}) {
-  const coverPath = pathFunction(thing, {
-    to: urls.from('media.root').to,
-  });
-
-  const {artTags} = thing;
-
-  const cwTags = artTags.filter((tag) => tag.isContentWarning);
-  const linkTags = artTags.filter((tag) => !tag.isContentWarning);
-
-  return {
-    paths: serializeImagePaths(coverPath),
-    tags: linkTags.map(serializeLink),
-    warnings: cwTags.map((tag) => tag.name),
-  };
-}
-
-export function serializeGroupsForAlbum(album, {serializeLink}) {
-  return album.groups
-    .map((group) => {
-      const index = group.albums.indexOf(album);
-      const next = group.albums[index + 1] || null;
-      const previous = group.albums[index - 1] || null;
-      return {group, index, next, previous};
-    })
-    .map(({group, index, next, previous}) => ({
-      link: serializeLink(group),
-      descriptionShort: group.descriptionShort,
-      albumIndex: index,
-      nextAlbum: next && serializeLink(next),
-      previousAlbum: previous && serializeLink(previous),
-      urls: group.urls,
-    }));
-}
-
-export function serializeGroupsForTrack(track, {serializeLink}) {
-  return track.album.groups.map((group) => ({
-    link: serializeLink(group),
-    urls: group.urls,
-  }));
-}
-*/
diff --git a/src/util/sort.js b/src/util/sort.js
deleted file mode 100644
index ea1e024a..00000000
--- a/src/util/sort.js
+++ /dev/null
@@ -1,438 +0,0 @@
-// Sorting functions - all utils here are mutating, so make sure to initially
-// slice/filter/somehow generate a new array from input data if retaining the
-// initial sort matters! (Spoilers: If what you're doing involves any kind of
-// parallelization, it definitely matters.)
-
-import {empty, sortMultipleArrays, unique}
-  from './sugar.js';
-
-// General sorting utilities! These don't do any sorting on their own but are
-// handy in the sorting functions below (or if you're making your own sort).
-
-export function compareCaseLessSensitive(a, b) {
-  // Compare two strings without considering capitalization... unless they
-  // happen to be the same that way.
-
-  const al = a.toLowerCase();
-  const bl = b.toLowerCase();
-
-  return al === bl
-    ? a.localeCompare(b, undefined, {numeric: true})
-    : al.localeCompare(bl, undefined, {numeric: true});
-}
-
-// Subtract common prefixes and other characters which some people don't like
-// to have considered while sorting. The words part of this is English-only for
-// now, which is totally evil.
-export function normalizeName(s) {
-  // Turn (some) ligatures into expanded variant for cleaner sorting, e.g.
-  // "ff" into "ff", in decompose mode, so that "ü" is represented as two
-  // bytes ("u" + \u0308 combining diaeresis).
-  s = s.normalize('NFKD');
-
-  // Replace one or more whitespace of any kind in a row, as well as certain
-  // punctuation, with a single typical space, then trim the ends.
-  s = s
-    .replace(
-      /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu,
-      ' '
-    )
-    .trim();
-
-  // Discard anything that isn't a letter, number, or space.
-  s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '').trim();
-
-  // Remove common English (only, for now) prefixes.
-  s = s.replace(/^(?:an?|the) /i, '');
-
-  return s;
-}
-
-// Component sort functions - these sort by one particular property, applying
-// unique particulars where appropriate. Usually you don't want to use these
-// directly, but if you're making a custom sort they can come in handy.
-
-// Universal method for sorting things into a predictable order, as directory
-// is taken to be unique. There are two exceptions where this function (and
-// thus any of the composite functions that start with it) *can't* be taken as
-// deterministic:
-//
-//  1) Mixed data of two different Things, as directories are only taken as
-//     unique within one given class of Things. For example, this function
-//     won't be deterministic if its array contains both <album:ithaca> and
-//     <track:ithaca>.
-//
-//  2) Duplicate directories, or multiple instances of the "same" Thing.
-//     This function doesn't differentiate between two objects of the same
-//     directory, regardless of any other properties or the overall "identity"
-//     of the object.
-//
-// These exceptions are unavoidable except for not providing that kind of data
-// in the first place, but you can still ensure the overall program output is
-// deterministic by ensuring the input is arbitrarily sorted according to some
-// other criteria - ex, although sortByDirectory itself isn't determinstic when
-// given mixed track and album data, the final output (what goes on the site)
-// will always be the same if you're doing sortByDirectory([...albumData,
-// ...trackData]), because the initial sort places albums before tracks - and
-// sortByDirectory will handle the rest, given all directories are unique
-// except when album and track directories overlap with each other.
-export function sortByDirectory(data, {
-  getDirectory = object => object.directory,
-} = {}) {
-  const directories = data.map(getDirectory);
-
-  sortMultipleArrays(data, directories,
-    (a, b, directoryA, directoryB) =>
-      compareCaseLessSensitive(directoryA, directoryB));
-
-  return data;
-}
-
-export function sortByName(data, {
-  getName = object => object.name,
-} = {}) {
-  const names = data.map(getName);
-  const normalizedNames = names.map(normalizeName);
-
-  sortMultipleArrays(data, normalizedNames, names,
-    (
-      a, b,
-      normalizedA, normalizedB,
-      nonNormalizedA, nonNormalizedB,
-    ) =>
-      compareNormalizedNames(
-        normalizedA, normalizedB,
-        nonNormalizedA, nonNormalizedB,
-      ));
-
-  return data;
-}
-
-export function compareNormalizedNames(
-  normalizedA, normalizedB,
-  nonNormalizedA, nonNormalizedB,
-) {
-  const comparison = compareCaseLessSensitive(normalizedA, normalizedB);
-  return (
-    (comparison === 0
-      ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB)
-      : comparison));
-}
-
-export function sortByDate(data, {
-  getDate = object => object.date,
-  latestFirst = false,
-} = {}) {
-  const dates = data.map(getDate);
-
-  sortMultipleArrays(data, dates,
-    (a, b, dateA, dateB) =>
-      compareDates(dateA, dateB, {latestFirst}));
-
-  return data;
-}
-
-export function compareDates(a, b, {
-  latestFirst = false,
-} = {}) {
-  if (a && b) {
-    return (latestFirst ? b - a : a - b);
-  }
-
-  // It's possible for objects with and without dates to be mixed
-  // together in the same array. If that's the case, we put all items
-  // without dates at the end.
-  if (a) return -1;
-  if (b) return 1;
-
-  // If neither of the items being compared have a date, don't move
-  // them relative to each other. This is basically the same as
-  // filtering out all non-date items and then pushing them at the
-  // end after sorting the rest.
-  return 0;
-}
-
-export function getLatestDate(dates) {
-  const filtered = dates.filter(Boolean);
-  if (empty(filtered)) return null;
-
-  return filtered
-    .reduce(
-      (accumulator, date) =>
-        date > accumulator ? date : accumulator,
-      -Infinity);
-}
-
-export function getEarliestDate(dates) {
-  const filtered = dates.filter(Boolean);
-  if (empty(filtered)) return null;
-
-  return filtered
-    .reduce(
-      (accumulator, date) =>
-        date < accumulator ? date : accumulator,
-      Infinity);
-}
-
-// Funky sort which takes a data set and a corresponding list of "counts",
-// which are really arbitrary numbers representing some property of each data
-// object defined by the caller. It sorts and mutates *both* of these, so the
-// sorted data will still correspond to the same indexed count.
-export function sortByCount(data, counts, {
-  greatestFirst = false,
-} = {}) {
-  sortMultipleArrays(data, counts, (data1, data2, count1, count2) =>
-    (greatestFirst
-      ? count2 - count1
-      : count1 - count2));
-
-  return data;
-}
-
-export function sortByPositionInParent(data, {
-  getParent,
-  getChildren,
-}) {
-  return data.sort((a, b) => {
-    const parentA = getParent(a);
-    const parentB = getParent(b);
-
-    // Don't change the sort when the two items are from separate parents.
-    // This function doesn't change the order of parents or try to "merge"
-    // two separated chunks of items from the same parent together.
-    if (parentA !== parentB) {
-      return 0;
-    }
-
-    // Don't change the sort when either (or both) of the items doesn't
-    // even have a parent (e.g. it's the passed data is a mixed array of
-    // children and parents).
-    if (!parentA || !parentB) {
-      return 0;
-    }
-
-    const indexA = getChildren(parentA).indexOf(a);
-    const indexB = getChildren(parentB).indexOf(b);
-
-    // If the getParent/getChildren relationship doesn't go both ways for
-    // some reason, don't change the sort.
-    if (indexA === -1 || indexB === -1) {
-      return 0;
-    }
-
-    return indexA - indexB;
-  });
-}
-
-export function sortByPositionInAlbum(data) {
-  return sortByPositionInParent(data, {
-    getParent: track => track.album,
-    getChildren: album => album.tracks,
-  });
-}
-
-export function sortByPositionInFlashAct(data) {
-  return sortByPositionInParent(data, {
-    getParent: flash => flash.act,
-    getChildren: act => act.flashes,
-  });
-}
-
-// Sorts data so that items are grouped together according to whichever of a
-// set of arbitrary given conditions is true first. If no conditions are met
-// for a given item, it's moved over to the end!
-export function sortByConditions(data, conditions) {
-  return data.sort((a, b) => {
-    const ai = conditions.findIndex((f) => f(a));
-    const bi = conditions.findIndex((f) => f(b));
-
-    if (ai >= 0 && bi >= 0) {
-      return ai - bi;
-    } else if (ai >= 0) {
-      return -1;
-    } else if (bi >= 0) {
-      return 1;
-    } else {
-      return 0;
-    }
-  });
-}
-
-// Composite sorting functions - these consider multiple properties, generally
-// always returning the same output regardless of how the input was originally
-// sorted (or left unsorted). If you're working with arbitrarily sorted inputs
-// (typically wiki data, either in full or unsorted filter), these make sure
-// what gets put on the actual website (or wherever) is deterministic. Also
-// they're just handy sorting utilities.
-//
-// Note that because these are each comprised of multiple component sorting
-// functions, they expect more than just one property to be present for full
-// sorting (listed above each function). If you're mapping thing objects to
-// another representation, try to include all of these listed properties.
-
-// Expects thing properties:
-//  * directory (or override getDirectory)
-//  * name (or override getName)
-export function sortAlphabetically(data, {
-  getDirectory,
-  getName,
-} = {}) {
-  sortByDirectory(data, {getDirectory});
-  sortByName(data, {getName});
-  return data;
-}
-
-// Expects thing properties:
-//  * directory (or override getDirectory)
-//  * name (or override getName)
-//  * date (or override getDate)
-export function sortChronologically(data, {
-  latestFirst = false,
-  getDirectory,
-  getName,
-  getDate,
-} = {}) {
-  sortAlphabetically(data, {getDirectory, getName});
-  sortByDate(data, {latestFirst, getDate});
-  return data;
-}
-
-// This one's a little odd! Sorts an array of {entry, thing} pairs using
-// the provided sortFunction, which will operate on each item's `thing`, not
-// its entry (or the item as a whole). If multiple entries are associated
-// with the same thing, they'll end up bunched together in the output,
-// retaining their original relative positioning.
-export function sortEntryThingPairs(data, sortFunction) {
-  const things = unique(data.map(item => item.thing));
-  sortFunction(things);
-
-  const outputArrays = [];
-  const thingToOutputArray = new Map();
-
-  for (const thing of things) {
-    const array = [];
-    thingToOutputArray.set(thing, array);
-    outputArrays.push(array);
-  }
-
-  for (const item of data) {
-    thingToOutputArray.get(item.thing).push(item);
-  }
-
-  data.splice(0, data.length, ...outputArrays.flat());
-
-  return data;
-}
-
-/*
-// Alternate draft version of sortEntryThingPairs.
-// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168
-
-// Maps the provided "preparation" function across a list of arbitrary values,
-// building up a list of sortable values; sorts these with the provided sorting
-// function; and reorders the sources to match their corresponding prepared
-// values. As usual, if multiple source items correspond to the same sorting
-// data, this retains the source relative positioning.
-export function prepareAndSort(sources, prepareForSort, sortFunction) {
-  const prepared = [];
-  const preparedToSource = new Map();
-
-  for (const original of originals) {
-    const prep = prepareForSort(source);
-    prepared.push(prep);
-    preparedToSource.set(prep, source);
-  }
-
-  sortFunction(prepared);
-
-  sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep)));
-
-  return sources;
-}
-*/
-
-// Highly contextual sort functions - these are only for very specific types
-// of Things, and have appropriately hard-coded behavior.
-
-// Sorts so that tracks from the same album are generally grouped together in
-// their original (album track list) order, while prioritizing date (by default
-// release date but can be overridden) above all else.
-//
-// This function also works for data lists which contain only tracks.
-export function sortAlbumsTracksChronologically(data, {
-  latestFirst = false,
-  getDate,
-} = {}) {
-  // Sort albums before tracks...
-  sortByConditions(data, [(t) => t.album === undefined]);
-
-  // Group tracks by album...
-  sortByDirectory(data, {
-    getDirectory: (t) => (t.album ? t.album.directory : t.directory),
-  });
-
-  // Sort tracks by position in album...
-  sortByPositionInAlbum(data);
-
-  // ...and finally sort by date. If tracks from more than one album were
-  // released on the same date, they'll still be grouped together by album,
-  // and tracks within an album will retain their relative positioning (i.e.
-  // stay in the same order as part of the album's track listing).
-  sortByDate(data, {latestFirst, getDate});
-
-  return data;
-}
-
-export function sortFlashesChronologically(data, {
-  latestFirst = false,
-  getDate,
-} = {}) {
-  // Group flashes by act...
-  sortAlphabetically(data, {
-    getName: flash => flash.act.name,
-    getDirectory: flash => flash.act.directory,
-  });
-
-  // Sort flashes by position in act...
-  sortByPositionInFlashAct(data);
-
-  // ...and finally sort by date. If flashes from more than one act were
-  // released on the same date, they'll still be grouped together by act,
-  // and flashes within an act will retain their relative positioning (i.e.
-  // stay in the same order as the act's flash listing).
-  sortByDate(data, {latestFirst, getDate});
-
-  return data;
-}
-
-export function sortContributionsChronologically(data, sortThings, {
-  latestFirst = false,
-} = {}) {
-  // Contributions only have one date property (which is provided when
-  // the contribution is created). They're sorted by this most primarily,
-  // but otherwise use the same sort as is provided.
-
-  const entries =
-    data.map(contrib => ({
-      entry: contrib,
-      thing: contrib.thing,
-    }));
-
-  sortEntryThingPairs(
-    entries,
-    things =>
-      sortThings(things, {latestFirst}));
-
-  const contribs =
-    entries
-      .map(({entry: contrib}) => contrib);
-
-  sortByDate(contribs, {latestFirst});
-
-  // We're not actually operating on the original data array at any point,
-  // so since this is meant to be a mutating function like any other, splice
-  // the sorted contribs into the original array.
-  data.splice(0, data.length, ...contribs);
-
-  return data;
-}
diff --git a/src/util/sugar.js b/src/util/sugar.js
deleted file mode 100644
index 90d47b7c..00000000
--- a/src/util/sugar.js
+++ /dev/null
@@ -1,845 +0,0 @@
-// Syntactic sugar! (Mostly.)
-// Generic functions - these are useful just a8out everywhere.
-//
-// Friendly(!) disclaimer: these utility functions haven't 8een tested all that
-// much. Do not assume it will do exactly what you want it to do in all cases.
-// It will likely only do exactly what I want it to, and only in the cases I
-// decided were relevant enough to 8other handling.
-
-// Apparently JavaScript doesn't come with a function to split an array into
-// chunks! Weird. Anyway, this is an awesome place to use a generator, even
-// though we don't really make use of the 8enefits of generators any time we
-// actually use this. 8ut it's still awesome, 8ecause I say so.
-export function* splitArray(array, fn) {
-  let lastIndex = 0;
-  while (lastIndex < array.length) {
-    let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item));
-    if (nextIndex === -1) {
-      nextIndex = array.length;
-    }
-    yield array.slice(lastIndex, nextIndex);
-    // Plus one because we don't want to include the dividing line in the
-    // next array we yield.
-    lastIndex = nextIndex + 1;
-  }
-}
-
-// Null-accepting function to check if an array or set is empty. Accepts null
-// (which is treated as empty) as a shorthand for "hey, check if this property
-// is an array with/without stuff in it" for objects where properties that are
-// PRESENT but don't currently have a VALUE are null (rather than undefined).
-export function empty(value) {
-  if (value === null) {
-    return true;
-  }
-
-  if (Array.isArray(value)) {
-    return value.length === 0;
-  }
-
-  if (value instanceof Set) {
-    return value.size === 0;
-  }
-
-  throw new Error(`Expected array, set, or null`);
-}
-
-// Repeats all the items of an array a number of times.
-export function repeat(times, array) {
-  if (times === 0) return [];
-  if (array === null || array === undefined) return [];
-  if (Array.isArray(array) && empty(array)) return [];
-
-  const out = [];
-
-  for (let n = 1; n <= times; n++) {
-    const value =
-      (typeof array === 'function'
-        ? array()
-        : array);
-
-    if (Array.isArray(value)) out.push(...value);
-    else out.push(value);
-  }
-
-  return out;
-}
-
-// Gets a random item from an array.
-export function pick(array) {
-  return array[Math.floor(Math.random() * array.length)];
-}
-
-// Gets the item at an index relative to another index.
-export function atOffset(array, index, offset, {
-  wrap = false,
-  valuePastEdge = null,
-} = {}) {
-  if (index === -1) {
-    return valuePastEdge;
-  }
-
-  if (offset === 0) {
-    return array[index];
-  }
-
-  if (wrap) {
-    return array[(index + offset) % array.length];
-  }
-
-  if (offset > 0 && index + offset > array.length - 1) {
-    return valuePastEdge;
-  }
-
-  if (offset < 0 && index + offset < 0) {
-    return valuePastEdge;
-  }
-
-  return array[index + offset];
-}
-
-// Gets the index of the first item that satisfies the provided function,
-// or, if none does, returns the length of the array (the index just past the
-// final item).
-export function findIndexOrEnd(array, fn) {
-  const index = array.findIndex(fn);
-  if (index >= 0) {
-    return index;
-  } else {
-    return array.length;
-  }
-}
-
-// Sums the values in an array, optionally taking a function which maps each
-// item to a number (handy for accessing a certain property on an array of like
-// objects). This also coalesces null values to zero, so if the mapping function
-// returns null (or values in the array are nullish), they'll just be skipped in
-// the sum.
-export function accumulateSum(array, fn = x => x) {
-  return array.reduce(
-    (accumulator, value, index, array) =>
-      accumulator +
-        fn(value, index, array) ?? 0,
-    0);
-}
-
-// Stitches together the items of separate arrays into one array of objects
-// whose keys are the corresponding items from each array at that index.
-// This is mostly useful for iterating over multiple arrays at once!
-export function stitchArrays(keyToArray) {
-  const errors = [];
-
-  for (const [key, value] of Object.entries(keyToArray)) {
-    if (value === null) continue;
-    if (Array.isArray(value)) continue;
-    errors.push(new TypeError(`(${key}) Expected array or null, got ${typeAppearance(value)}`));
-  }
-
-  if (!empty(errors)) {
-    throw new AggregateError(errors, `Expected arrays or null`);
-  }
-
-  const keys = Object.keys(keyToArray);
-  const arrays = Object.values(keyToArray).filter(val => Array.isArray(val));
-  const length = Math.max(...arrays.map(({length}) => length));
-  const results = [];
-
-  for (let i = 0; i < length; i++) {
-    const object = {};
-    for (const key of keys) {
-      object[key] =
-        (Array.isArray(keyToArray[key])
-          ? keyToArray[key][i]
-          : null);
-    }
-    results.push(object);
-  }
-
-  return results;
-}
-
-// Like Map.groupBy! Collects the items of an unsorted array into buckets
-// according to a per-item computed value.
-export function groupArray(items, fn) {
-  const buckets = new Map();
-
-  for (const [index, item] of Array.prototype.entries.call(items)) {
-    const key = fn(item, index);
-    if (buckets.has(key)) {
-      buckets.get(key).push(item);
-    } else {
-      buckets.set(key, [item]);
-    }
-  }
-
-  return buckets;
-}
-
-// Turns this:
-//
-//   [
-//     [123, 'orange', null],
-//     [456, 'apple', true],
-//     [789, 'banana', false],
-//     [1000, 'pear', undefined],
-//   ]
-//
-// Into this:
-//
-//   [
-//     [123, 456, 789, 1000],
-//     ['orange', 'apple', 'banana', 'pear'],
-//     [null, true, false, undefined],
-//   ]
-//
-// And back again, if you call it again on its results.
-export function transposeArrays(arrays) {
-  if (empty(arrays)) {
-    return [];
-  }
-
-  const length = arrays[0].length;
-  const results = new Array(length).fill(null).map(() => []);
-
-  for (const array of arrays) {
-    for (let i = 0; i < length; i++) {
-      results[i].push(array[i]);
-    }
-  }
-
-  return results;
-}
-
-export const mapInPlace = (array, fn) =>
-  array.splice(0, array.length, ...array.map(fn));
-
-export const unique = (arr) => Array.from(new Set(arr));
-
-export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
-  arr1.length === arr2.length &&
-  (checkOrder
-    ? arr1.every((x, i) => arr2[i] === x)
-    : arr1.every((x) => arr2.includes(x)));
-
-export function compareObjects(obj1, obj2, {
-  checkOrder = false,
-  checkSymbols = true,
-} = {}) {
-  const keys1 = Object.keys(obj1);
-  const keys2 = Object.keys(obj2);
-  if (!compareArrays(keys1, keys2, {checkOrder})) return false;
-
-  let syms1, syms2;
-  if (checkSymbols) {
-    syms1 = Object.getOwnPropertySymbols(obj1);
-    syms2 = Object.getOwnPropertySymbols(obj2);
-    if (!compareArrays(syms1, syms2, {checkOrder})) return false;
-  }
-
-  for (const key of keys1) {
-    if (obj2[key] !== obj1[key]) return false;
-  }
-
-  if (checkSymbols) {
-    for (const sym of syms1) {
-      if (obj2[sym] !== obj1[sym]) return false;
-    }
-  }
-
-  return true;
-}
-
-// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) => {
-  const result = fn(Object.entries(obj));
-  if (result instanceof Promise) {
-    return result.then(entries => Object.fromEntries(entries));
-  } else {
-    return Object.fromEntries(result);
-  }
-}
-
-export function setIntersection(set1, set2) {
-  const intersection = new Set();
-  for (const item of set1) {
-    if (set2.has(item)) {
-      intersection.add(item);
-    }
-  }
-  return intersection;
-}
-
-export function filterProperties(object, properties, {
-  preserveOriginalOrder = false,
-} = {}) {
-  if (typeof object !== 'object' || object === null) {
-    throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`);
-  }
-
-  if (!Array.isArray(properties)) {
-    throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`);
-  }
-
-  const filteredObject = {};
-
-  if (preserveOriginalOrder) {
-    for (const property of Object.keys(object)) {
-      if (properties.includes(property)) {
-        filteredObject[property] = object[property];
-      }
-    }
-  } else {
-    for (const property of properties) {
-      if (Object.hasOwn(object, property)) {
-        filteredObject[property] = object[property];
-      }
-    }
-  }
-
-  return filteredObject;
-}
-
-export function queue(array, max = 50) {
-  if (max === 0) {
-    return array.map((fn) => fn());
-  }
-
-  const begin = [];
-  let current = 0;
-  const ret = array.map(
-    (fn) =>
-      new Promise((resolve, reject) => {
-        begin.push(() => {
-          current++;
-          Promise.resolve(fn()).then((value) => {
-            current--;
-            if (current < max && begin.length) {
-              begin.shift()();
-            }
-            resolve(value);
-          }, reject);
-        });
-      })
-  );
-
-  for (let i = 0; i < max && begin.length; i++) {
-    begin.shift()();
-  }
-
-  return ret;
-}
-
-export function delay(ms) {
-  return new Promise((res) => setTimeout(res, ms));
-}
-
-export function promiseWithResolvers() {
-  let obj = {};
-
-  obj.promise =
-    new Promise((...opts) =>
-      ([obj.resolve, obj.reject] = opts));
-
-  return obj;
-}
-
-// Stolen from here: https://stackoverflow.com/a/3561711
-//
-// There's a proposal for a native JS function like this, 8ut it's not even
-// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping
-export function escapeRegex(string) {
-  return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
-}
-
-export function splitKeys(key) {
-  return key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
-}
-
-// Follows a key path like 'foo.bar.baz' to get an item nested deeply inside
-// an object.
-export function getNestedProp(obj, key) {
-  const recursive = (o, k) =>
-    (k.length === 1
-      ? o[k[0]]
-      : recursive(o[k[0]], k.slice(1)));
-
-  return recursive(obj, splitKeys(key));
-}
-
-// Gets the "look" of some arbitrary value. It's like typeof, but smarter.
-// Don't use this for actually validating types - it's only suitable for
-// inclusion in error messages.
-export function typeAppearance(value) {
-  if (value === null) return 'null';
-  if (value === undefined) return 'undefined';
-  if (Array.isArray(value)) return 'array';
-  return typeof value;
-}
-
-// Limits a string to the desired length, filling in an ellipsis at the end
-// if it cuts any text off.
-export function cut(text, length = 40) {
-  if (text.length >= length) {
-    const index = Math.max(1, length - 3);
-    return text.slice(0, index) + '...';
-  } else {
-    return text;
-  }
-}
-
-// Limits a string to the desired length, filling in an ellipsis at the start
-// if it cuts any text off.
-export function cutStart(text, length = 40) {
-  if (text.length >= length) {
-    const index = Math.min(text.length - 1, text.length - length + 3);
-    return '...' + text.slice(index);
-  } else {
-    return text;
-  }
-}
-
-// Wrapper function around wrap(), ha, ha - this requires the Node module
-// 'node-wrap'.
-export function indentWrap(str, {
-  wrap,
-  spaces = 0,
-  width = 60,
-  bullet = false,
-}) {
-  const wrapped =
-    wrap(str, {
-      width: width - spaces,
-      indent: ' '.repeat(spaces),
-    });
-
-  if (bullet) {
-    return wrapped.trimStart();
-  } else {
-    return wrapped;
-  }
-}
-
-// Annotates {index, length} results from another iterator with contextual
-// details, including:
-//
-// * its line and column numbers;
-// * if `formatWhere` is true (the default), a pretty-formatted,
-//   human-readable indication of the match's placement in the string;
-// * if `getContainingLine` is true, the entire line (or multiple lines)
-//   of text containing the match.
-//
-export function* iterateMultiline(content, iterator, {
-  formatWhere = true,
-  getContainingLine = false,
-} = {}) {
-  const lineRegexp = /\n/g;
-  const isMultiline = content.includes('\n');
-
-  let lineNumber = 0;
-  let startOfLine = 0;
-  let previousIndex = 0;
-
-  const countLineBreaks = (index, length) => {
-    const range = content.slice(index, index + length);
-    const lineBreaks = Array.from(range.matchAll(lineRegexp));
-    if (!empty(lineBreaks)) {
-      lineNumber += lineBreaks.length;
-      startOfLine = index + lineBreaks.at(-1).index + 1;
-    }
-  };
-
-  for (const result of iterator) {
-    const {index, length} = result;
-
-    countLineBreaks(previousIndex, index - previousIndex);
-
-    const matchStartOfLine = startOfLine;
-
-    previousIndex = index + length;
-
-    const columnNumber = index - startOfLine;
-
-    const where =
-      (formatWhere && isMultiline
-        ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
-     : formatWhere
-        ? `pos: ${index + 1}`
-        : null);
-
-    countLineBreaks(index, length);
-
-    let containingLine = null;
-    if (getContainingLine) {
-      const nextLineResult =
-        content
-          .slice(previousIndex)
-          .matchAll(lineRegexp)
-          .next();
-
-      const nextStartOfLine =
-        (nextLineResult.done
-          ? content.length
-          : previousIndex + nextLineResult.value.index);
-
-      containingLine =
-        content.slice(matchStartOfLine, nextStartOfLine);
-    }
-
-    yield {
-      ...result,
-      lineNumber,
-      columnNumber,
-      where,
-      containingLine,
-    };
-  }
-}
-
-// Iterates over regular expression matches within a single- or multiline
-// string, yielding each match as well as contextual details; this accepts
-// the same options (and provides the same context) as iterateMultiline.
-export function* matchMultiline(content, matchRegexp, options) {
-  const matchAllIterator =
-    content.matchAll(matchRegexp);
-
-  const cleanMatchAllIterator =
-    (function*() {
-      for (const match of matchAllIterator) {
-        yield {
-          index: match.index,
-          length: match[0].length,
-          match,
-        };
-      }
-    })();
-
-  const multilineIterator =
-    iterateMultiline(content, cleanMatchAllIterator, options);
-
-  yield* multilineIterator;
-}
-
-// Binds default values for arguments in a {key: value} type function argument
-// (typically the second argument, but may be overridden by providing a
-// [bindOpts.bindIndex] argument). Typically useful for preparing a function for
-// reuse within one or multiple other contexts, which may not be aware of
-// required or relevant values provided in the initial context.
-//
-// This function also passes the identity of `this` through (the returned value
-// is not an arrow function), though note it's not a true bound function either
-// (since Function.prototype.bind only supports positional arguments, not
-// "options" specified via key/value).
-//
-export function bindOpts(fn, bind) {
-  const bindIndex = bind[bindOpts.bindIndex] ?? 1;
-
-  const bound = function (...args) {
-    const opts = args[bindIndex] ?? {};
-    return Reflect.apply(fn, this, [
-      ...args.slice(0, bindIndex),
-      {...bind, ...opts}
-    ]);
-  };
-
-  annotateFunction(bound, {
-    name: fn,
-    trait: 'options-bound',
-  });
-
-  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
-    if (key === 'length') continue;
-    if (key === 'name') continue;
-    if (key === 'arguments') continue;
-    if (key === 'caller') continue;
-    if (key === 'prototype') continue;
-    Object.defineProperty(bound, key, descriptor);
-  }
-
-  return bound;
-}
-
-bindOpts.bindIndex = Symbol();
-
-// Sorts multiple arrays by an arbitrary function (which is the last argument).
-// Paired values from each array are provided to the callback sequentially:
-//
-//   (a_fromFirstArray, b_fromFirstArray,
-//    a_fromSecondArray, b_fromSecondArray,
-//    a_fromThirdArray, b_fromThirdArray) =>
-//     relative positioning (negative, positive, or zero)
-//
-// Like native single-array sort, this is a mutating function.
-export function sortMultipleArrays(...args) {
-  const arrays = args.slice(0, -1);
-  const fn = args.at(-1);
-
-  const length = arrays[0].length;
-  const symbols = new Array(length).fill(null).map(() => Symbol());
-  const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index]));
-
-  symbols.sort((a, b) => {
-    const indexA = indexes[a];
-    const indexB = indexes[b];
-
-    const args = [];
-    for (let i = 0; i < arrays.length; i++) {
-      args.push(arrays[i][indexA]);
-      args.push(arrays[i][indexB]);
-    }
-
-    return fn(...args);
-  });
-
-  for (const array of arrays) {
-    // Note: We're mutating this array pulling values from itself, but only all
-    // at once after all those values have been pulled.
-    array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]]));
-  }
-
-  return arrays;
-}
-
-// Filters multiple arrays by an arbitrary function (which is the last argument).
-// Values from each array are provided to the callback sequentially:
-//
-//   (value_fromFirstArray,
-//    value_fromSecondArray,
-//    value_fromThirdArray,
-//    index,
-//    [firstArray, secondArray, thirdArray]) =>
-//      true or false
-//
-// Please be aware that this is a mutating function, unlike native single-array
-// filter. The mutated arrays are returned. Also attached under `.removed` are
-// corresponding arrays of items filtered out.
-export function filterMultipleArrays(...args) {
-  const arrays = args.slice(0, -1);
-  const fn = args.at(-1);
-
-  const removed = new Array(arrays.length).fill(null).map(() => []);
-
-  for (let i = arrays[0].length - 1; i >= 0; i--) {
-    const args = arrays.map(array => array[i]);
-    args.push(i, arrays);
-
-    if (!fn(...args)) {
-      for (let j = 0; j < arrays.length; j++) {
-        const item = arrays[j][i];
-        arrays[j].splice(i, 1);
-        removed[j].unshift(item);
-      }
-    }
-  }
-
-  Object.assign(arrays, {removed});
-  return arrays;
-}
-
-// Corresponding filter function for sortByCount. By default, items whose
-// corresponding count is zero will be removed.
-export function filterByCount(data, counts, {
-  min = 1,
-  max = Infinity,
-} = {}) {
-  filterMultipleArrays(data, counts, (data, count) =>
-    count >= min && count <= max);
-}
-
-// Reduces multiple arrays with an arbitrary function (which is the last
-// argument). Note that this reduces into multiple accumulators, one for
-// each input array, not just a single value. That's reflected in both the
-// callback parameters:
-//
-//   (accumulator1,
-//    accumulator2,
-//    value_fromFirstArray,
-//    value_fromSecondArray,
-//    index,
-//    [firstArray, secondArray]) =>
-//      [newAccumulator1, newAccumulator2]
-//
-// As well as the final return value of reduceMultipleArrays:
-//
-//   [finalAccumulator1, finalAccumulator2]
-//
-// This is not a mutating function.
-export function reduceMultipleArrays(...args) {
-  const [arrays, fn, initialAccumulators] =
-    (typeof args.at(-1) === 'function'
-      ? [args.slice(0, -1), args.at(-1), null]
-      : [args.slice(0, -2), args.at(-2), args.at(-1)]);
-
-  if (empty(arrays[0])) {
-    throw new TypeError(`Reduce of empty arrays with no initial value`);
-  }
-
-  let [accumulators, i] =
-    (initialAccumulators
-      ? [initialAccumulators, 0]
-      : [arrays.map(array => array[0]), 1]);
-
-  for (; i < arrays[0].length; i++) {
-    const args = [...accumulators, ...arrays.map(array => array[i])];
-    args.push(i, arrays);
-    accumulators = fn(...args);
-  }
-
-  return accumulators;
-}
-
-export function chunkByConditions(array, conditions) {
-  if (empty(array)) {
-    return [];
-  }
-
-  if (empty(conditions)) {
-    return [array];
-  }
-
-  const out = [];
-  let cur = [array[0]];
-  for (let i = 1; i < array.length; i++) {
-    const item = array[i];
-    const prev = array[i - 1];
-    let chunk = false;
-    for (const condition of conditions) {
-      if (condition(item, prev)) {
-        chunk = true;
-        break;
-      }
-    }
-    if (chunk) {
-      out.push(cur);
-      cur = [item];
-    } else {
-      cur.push(item);
-    }
-  }
-  out.push(cur);
-  return out;
-}
-
-export function chunkByProperties(array, properties) {
-  return chunkByConditions(
-    array,
-    properties.map((p) => (a, b) => {
-      if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p];
-
-      if (a[p] !== b[p]) return true;
-
-      // Not sure if this line is still necessary with the specific check for
-      // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....?
-      if (a[p] != b[p]) return true;
-
-      return false;
-    })
-  ).map((chunk) => ({
-    ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])),
-    chunk,
-  }));
-}
-
-export function chunkMultipleArrays(...args) {
-  const arrays = args.slice(0, -1);
-  const fn = args.at(-1);
-
-  if (arrays[0].length === 0) {
-    return [];
-  }
-
-  const newChunk = index => arrays.map(array => [array[index]]);
-  const results = [newChunk(0)];
-
-  for (let i = 1; i < arrays[0].length; i++) {
-    const current = results.at(-1);
-
-    const args = [];
-    for (let j = 0; j < arrays.length; j++) {
-      const item = arrays[j][i];
-      const previous = current[j].at(-1);
-      args.push(item, previous);
-    }
-
-    if (fn(...args)) {
-      results.push(newChunk(i));
-      continue;
-    }
-
-    for (let j = 0; j < arrays.length; j++) {
-      current[j].push(arrays[j][i]);
-    }
-  }
-
-  return results;
-}
-
-// Delicious function annotations, such as:
-//
-//   (*bound) soWeAreBackInTheMine
-//   (data *unfulfilled) generateShrekTwo
-//
-export function annotateFunction(fn, {
-  name: nameOrFunction = null,
-  description: newDescription,
-  trait: newTrait,
-}) {
-  let name;
-
-  if (typeof nameOrFunction === 'function') {
-    name = nameOrFunction.name;
-  } else if (typeof nameOrFunction === 'string') {
-    name = nameOrFunction;
-  }
-
-  name ??= fn.name ?? 'anonymous';
-
-  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
-
-  let prefix, suffix, description, trait;
-  if (match) {
-    ({prefix, suffix, description, trait} = match.groups);
-  }
-
-  prefix ??= '';
-  suffix ??= name;
-  description ??= '';
-  trait ??= '';
-
-  if (newDescription) {
-    if (description) {
-      description += '; ' + newDescription;
-    } else {
-      description = newDescription;
-    }
-  }
-
-  if (newTrait) {
-    if (trait) {
-      trait += ' #' + newTrait;
-    } else {
-      trait = '#' + newTrait;
-    }
-  }
-
-  let parenthesesPart;
-
-  if (description && trait) {
-    parenthesesPart = `${description} ${trait}`;
-  } else if (description || trait) {
-    parenthesesPart = description || trait;
-  } else {
-    parenthesesPart = '';
-  }
-
-  let finalName;
-
-  if (prefix && parenthesesPart) {
-    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
-  } else if (parenthesesPart) {
-    finalName = `(${parenthesesPart}) ${suffix}`;
-  } else {
-    finalName = suffix;
-  }
-
-  Object.defineProperty(fn, 'name', {value: finalName});
-}
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
deleted file mode 100644
index f97ecd63..00000000
--- a/src/util/wiki-data.js
+++ /dev/null
@@ -1,475 +0,0 @@
-// Utility functions for interacting with wiki data.
-
-import {accumulateSum, empty, unique} from './sugar.js';
-import {sortByDate} from './sort.js';
-
-// This is a duplicate binding of filterMultipleArrays that's included purely
-// to leave wiki-data.js compatible with the release build of HSMusic.
-// Sorry! This is really ridiculous!! If the next update after 10/25/2023 has
-// released, this binding is no longer needed!
-export {filterMultipleArrays} from './sugar.js';
-
-// Generic value operations
-
-export function getKebabCase(name) {
-  return name
-
-    // Spaces to dashes
-    .split(' ')
-    .join('-')
-
-    // Punctuation as words
-    .replace(/&/g, '-and-')
-    .replace(/\+/g, '-plus-')
-    .replace(/%/g, '-percent-')
-
-    // Punctuation which only divides words, not single characters
-    .replace(/(\b[^\s-.]{2,})\./g, '$1-')
-    .replace(/\.([^\s-.]{2,})\b/g, '-$1')
-
-    // Punctuation which doesn't divide a number following a non-number
-    .replace(/(?<=[0-9])\^/g, '-')
-    .replace(/\^(?![0-9])/g, '-')
-
-    // General punctuation which always separates surrounding words
-    .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-')
-
-    // Accented characters
-    .replace(/[áâäàå]/gi, 'a')
-    .replace(/[çč]/gi, 'c')
-    .replace(/[éêëè]/gi, 'e')
-    .replace(/[íîïì]/gi, 'i')
-    .replace(/[óôöò]/gi, 'o')
-    .replace(/[úûüù]/gi, 'u')
-
-    // Strip other characters
-    .replace(/[^a-z0-9-]/gi, '')
-
-    // Combine consecutive dashes
-    .replace(/-{2,}/g, '-')
-
-    // Trim dashes on boundaries
-    .replace(/^-+|-+$/g, '')
-
-    // Always lowercase
-    .toLowerCase();
-}
-
-// Specific data utilities
-
-// Matches heading details from commentary data in roughly the formats:
-//
-//    <i>artistReference:</i> (annotation, date)
-//    <i>artistReference|artistDisplayText:</i> (annotation, date)
-//
-// where capturing group "annotation" can be any text at all, except that the
-// last entry (past a comma or the only content within parentheses), if parsed
-// as a date, is the capturing group "date". "Parsing as a date" means matching
-// one of these formats:
-//
-//   * "25 December 2019" - one or two number digits, followed by any text,
-//     followed by four number digits
-//   * "December 25, 2019" - one all-letters word, a space, one or two number
-//     digits, a comma, and four number digits
-//   * "12/25/2019" etc - three sets of one to four number digits, separated
-//     by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD)
-//
-// Note that the annotation and date are always wrapped by one opening and one
-// closing parentheses. The whole heading does NOT need to match the entire
-// line it occupies (though it does always start at the first position on that
-// line), and if there is more than one closing parenthesis on the line, the
-// annotation will always cut off only at the last parenthesis, or a comma
-// preceding a date and then the last parenthesis. This is to ensure that
-// parentheses can be part of the actual annotation content.
-//
-// Capturing group "artistReference" is all the characters between <i> and </i>
-// (apart from the pipe and "artistDisplayText" text, if present), and is either
-// the name of an artist or an "artist:directory"-style reference.
-//
-// This regular expression *doesn't* match bodies, which will need to be parsed
-// out of the original string based on the indices matched using this.
-//
-
-const dateRegex = groupName =>
-  String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`;
-
-const commentaryRegexRaw =
-  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
-export const commentaryRegexCaseInsensitive =
-  new RegExp(commentaryRegexRaw, 'gmi');
-export const commentaryRegexCaseSensitive =
-  new RegExp(commentaryRegexRaw, 'gm');
-export const commentaryRegexCaseSensitiveOneShot =
-  new RegExp(commentaryRegexRaw);
-
-export function filterAlbumsByCommentary(albums) {
-  return albums
-    .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
-}
-
-export function getAlbumCover(album, {to}) {
-  // Some albums don't have art! This function returns null in that case.
-  if (album.hasCoverArt) {
-    return to('media.albumCover', album.directory, album.coverArtFileExtension);
-  } else {
-    return null;
-  }
-}
-
-export function getAlbumListTag(album) {
-  return album.hasTrackNumbers ? 'ol' : 'ul';
-}
-
-// This gets all the track o8jects defined in every al8um, and sorts them 8y
-// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore
-// you pass it to this function, 8ut individual tracks can have their own
-// original release d8, distinct from the al8um's d8. I allowed that 8ecause
-// in Homestuck, the first four Vol.'s were com8ined into one al8um really
-// early in the history of the 8andcamp, and I still want to use that as the
-// al8um listing (not the original four al8um listings), 8ut if I only did
-// that, all the tracks would 8e sorted as though they were released at the
-// same time as the compilation al8um - i.e, after some other al8ums (including
-// Vol.'s 5 and 6!) were released. That would mess with chronological listings
-// including tracks from multiple al8ums, like artist pages. So, to fix that,
-// I gave tracks an Original Date field, defaulting to the release date of the
-// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can
-// 8e used for other projects too, like if you wanted to have an al8um listing
-// compiling a 8unch of songs with radically different & interspersed release
-// d8s, 8ut still keep the al8um listing in a specific order, since that isn't
-// sorted 8y date.
-export function getAllTracks(albumData) {
-  return sortByDate(albumData.flatMap((album) => album.tracks));
-}
-
-export function getArtistNumContributions(artist) {
-  return accumulateSum(
-    [
-      unique(
-        ([
-          artist.trackArtistContributions,
-          artist.trackContributorContributions,
-          artist.trackCoverArtistContributions,
-        ]).flat()
-          .map(({thing}) => thing)),
-
-      artist.albumCoverArtistContributions,
-      artist.flashContributorContributions,
-    ],
-    ({length}) => length);
-}
-
-export function getFlashCover(flash, {to}) {
-  return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
-}
-
-export function getFlashLink(flash) {
-  return `https://homestuck.com/story/${flash.page}`;
-}
-
-export function getTotalDuration(tracks, {
-  originalReleasesOnly = false,
-} = {}) {
-  if (originalReleasesOnly) {
-    tracks = tracks.filter(t => !t.originalReleaseTrack);
-  }
-
-  return accumulateSum(tracks, track => track.duration);
-}
-
-export function getTrackCover(track, {to}) {
-  // Some albums don't have any track art at all, and in those, every track
-  // just inherits the album's own cover art. Note that since cover art isn't
-  // guaranteed on albums either, it's possible that this function returns
-  // null!
-  if (!track.hasUniqueCoverArt) {
-    return getAlbumCover(track.album, {to});
-  } else {
-    return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension);
-  }
-}
-
-export function getArtistAvatar(artist, {to}) {
-  return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
-}
-
-// Big-ass homepage row functions
-
-export function getNewAdditions(numAlbums, {albumData}) {
-  const sortedAlbums = albumData
-    .filter((album) => album.isListedOnHomepage)
-    .sort((a, b) => {
-      if (a.dateAddedToWiki > b.dateAddedToWiki) return -1;
-      if (a.dateAddedToWiki < b.dateAddedToWiki) return 1;
-      if (a.date > b.date) return -1;
-      if (a.date < b.date) return 1;
-      return 0;
-    });
-
-  // When multiple al8ums are added to the wiki at a time, we want to show
-  // all of them 8efore pulling al8ums from the next (earlier) date. We also
-  // want to show a diverse selection of al8ums - with limited space, we'd
-  // rather not show only the latest al8ums, if those happen to all 8e
-  // closely rel8ted!
-  //
-  // Specifically, we're concerned with avoiding too much overlap amongst
-  // the primary (first/top-most) group. We do this 8y collecting every
-  // primary group present amongst the al8ums for a given d8 into one
-  // (ordered) array, initially sorted (inherently) 8y latest al8um from
-  // the group. Then we cycle over the array, adding one al8um from each
-  // group until all the al8ums from that release d8 have 8een added (or
-  // we've met the total target num8er of al8ums). Once we've added all the
-  // al8ums for a given group, it's struck from the array (so the groups
-  // with the most additions on one d8 will have their oldest releases
-  // collected more towards the end of the list).
-
-  const albums = [];
-
-  let i = 0;
-  outerLoop: while (i < sortedAlbums.length) {
-    // 8uild up a list of groups and their al8ums 8y order of decending
-    // release, iter8ting until we're on a different d8. (We use a map for
-    // indexing so we don't have to iter8te through the entire array each
-    // time we access one of its entries. This is 8asically unnecessary
-    // since this will never 8e an expensive enough task for that to
-    // matter.... 8ut it's nicer code. BBBB) )
-    const currentDate = sortedAlbums[i].dateAddedToWiki;
-    const groupMap = new Map();
-    const groupArray = [];
-    for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +currentDate; i++) {
-      const primaryGroup = album.groups[0];
-      if (groupMap.has(primaryGroup)) {
-        groupMap.get(primaryGroup).push(album);
-      } else {
-        const entry = [album];
-        groupMap.set(primaryGroup, entry);
-        groupArray.push(entry);
-      }
-    }
-
-    // Then cycle over that sorted array, adding one al8um from each to
-    // the main array until we've run out or have met the target num8er
-    // of al8ums.
-    while (!empty(groupArray)) {
-      let j = 0;
-      while (j < groupArray.length) {
-        const entry = groupArray[j];
-        const album = entry.shift();
-        albums.push(album);
-
-        // This is the only time we ever add anything to the main al8um
-        // list, so it's also the only place we need to check if we've
-        // met the target length.
-        if (albums.length === numAlbums) {
-          // If we've met it, 8r8k out of the outer loop - we're done
-          // here!
-          break outerLoop;
-        }
-
-        if (empty(entry)) {
-          groupArray.splice(j, 1);
-        } else {
-          j++;
-        }
-      }
-    }
-  }
-
-  return albums;
-}
-
-export function getNewReleases(numReleases, {albumData}) {
-  return albumData
-    .filter((album) => album.isListedOnHomepage)
-    .reverse()
-    .slice(0, numReleases);
-}
-
-// Carousel layout and utilities
-
-// Layout constants:
-//
-// Carousels support fitting 4-18 items, with a few "dead" zones to watch out
-// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles.
-//
-// Carousels are limited to 1-3 rows and 4-6 columns.
-// Lower edge case: 1-3 items are treated as 4 items (with blank space).
-// Upper edge case: all items past 18 are dropped (treated as 18 items).
-//
-// This is all done through JS instead of CSS because it's just... ANNOYING...
-// to write a mapping like this in CSS lol.
-const carouselLayoutMap = [
-  // 0-3
-  null, null, null, null,
-
-  // 4-6
-  {rows: 1, columns: 4}, //  4: 1x4, drop 0
-  {rows: 1, columns: 5}, //  5: 1x5, drop 0
-  {rows: 1, columns: 6}, //  6: 1x6, drop 0
-
-  // 7-12
-  {rows: 1, columns: 6}, //  7: 1x6, drop 1
-  {rows: 2, columns: 4}, //  8: 2x4, drop 0
-  {rows: 2, columns: 4}, //  9: 2x4, drop 1
-  {rows: 2, columns: 5}, // 10: 2x5, drop 0
-  {rows: 2, columns: 5}, // 11: 2x5, drop 1
-  {rows: 2, columns: 6}, // 12: 2x6, drop 0
-
-  // 13-18
-  {rows: 2, columns: 6}, // 13: 2x6, drop 1
-  {rows: 2, columns: 6}, // 14: 2x6, drop 2
-  {rows: 3, columns: 5}, // 15: 3x5, drop 0
-  {rows: 3, columns: 5}, // 16: 3x5, drop 1
-  {rows: 3, columns: 5}, // 17: 3x5, drop 2
-  {rows: 3, columns: 6}, // 18: 3x6, drop 0
-];
-
-const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null);
-const maxCarouselLayoutItems = carouselLayoutMap.length - 1;
-const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems];
-const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems];
-
-export function getCarouselLayoutForNumberOfItems(numItems) {
-  return (
-    numItems < minCarouselLayoutItems ? shortestCarouselLayout :
-    numItems > maxCarouselLayoutItems ? longestCarouselLayout :
-    carouselLayoutMap[numItems]);
-}
-
-export function filterItemsForCarousel(items) {
-  if (empty(items)) {
-    return [];
-  }
-
-  return items
-    .filter(item => item.hasCoverArt)
-    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
-    .slice(0, maxCarouselLayoutItems + 1);
-}
-
-// Ridiculous caching support nonsense
-
-export class TupleMap {
-  static maxNestedTupleLength = 25;
-
-  #store = [undefined, null, null, null];
-
-  #lifetime(value) {
-    if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) {
-      return 'tuple';
-    } else if (
-      typeof value === 'object' && value !== null ||
-      typeof value === 'function'
-    ) {
-      return 'weak';
-    } else {
-      return 'strong';
-    }
-  }
-
-  #getSubstoreShallow(value, store) {
-    const lifetime = this.#lifetime(value);
-    const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime];
-
-    let map = store[mapIndex];
-    if (map === null) {
-      map = store[mapIndex] =
-        (lifetime === 'weak' ? new WeakMap()
-       : lifetime === 'strong' ? new Map()
-       : lifetime === 'tuple' ? new TupleMap()
-       : null);
-    }
-
-    if (map.has(value)) {
-      return map.get(value);
-    } else {
-      const substore = [undefined, null, null, null];
-      map.set(value, substore);
-      return substore;
-    }
-  }
-
-  #getSubstoreDeep(tuple, store = this.#store) {
-    if (tuple.length === 0) {
-      return store;
-    } else {
-      const [first, ...rest] = tuple;
-      return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store));
-    }
-  }
-
-  get(tuple) {
-    const store = this.#getSubstoreDeep(tuple);
-    return store[0];
-  }
-
-  has(tuple) {
-    const store = this.#getSubstoreDeep(tuple);
-    return store[0] !== undefined;
-  }
-
-  set(tuple, value) {
-    const store = this.#getSubstoreDeep(tuple);
-    store[0] = value;
-    return value;
-  }
-}
-
-export class TupleMapForBabies {
-  #here = new WeakMap();
-  #next = new WeakMap();
-
-  set(...args) {
-    const first = args.at(0);
-    const last = args.at(-1);
-    const rest = args.slice(1, -1);
-
-    if (empty(rest)) {
-      this.#here.set(first, last);
-    } else if (this.#next.has(first)) {
-      this.#next.get(first).set(...rest, last);
-    } else {
-      const tupleMap = new TupleMapForBabies();
-      this.#next.set(first, tupleMap);
-      tupleMap.set(...rest, last);
-    }
-  }
-
-  get(...args) {
-    const first = args.at(0);
-    const rest = args.slice(1);
-
-    if (empty(rest)) {
-      return this.#here.get(first);
-    } else if (this.#next.has(first)) {
-      return this.#next.get(first).get(...rest);
-    } else {
-      return undefined;
-    }
-  }
-
-  has(...args) {
-    const first = args.at(0);
-    const rest = args.slice(1);
-
-    if (empty(rest)) {
-      return this.#here.has(first);
-    } else if (this.#next.has(first)) {
-      return this.#next.get(first).has(...rest);
-    } else {
-      return false;
-    }
-  }
-}
-
-const combinedWikiDataTupleMap = new TupleMapForBabies();
-
-export function combineWikiDataArrays(arrays) {
-  const map = combinedWikiDataTupleMap;
-  if (map.has(...arrays)) {
-    return map.get(...arrays);
-  } else {
-    const combined = arrays.flat();
-    map.set(...arrays, combined);
-    return combined;
-  }
-}