« get me outta code hell

Merge branch 'commentary-entries' into album-commentary-tweaks - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-18 20:41:08 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-18 20:41:08 -0400
commit5bc43a8bc8132a9d2cfa57937aa46fda56b663e5 (patch)
tree95f770dfa3c3bb5ed7e2abc30663b275da2973ff
parentf481591b859282e1ea5483c89552375f5570e9e5 (diff)
parente35d23f4e9492b497138dce3f21382872e329e71 (diff)
Merge branch 'commentary-entries' into album-commentary-tweaks
-rw-r--r--README.md2
-rw-r--r--package-lock.json14
-rw-r--r--package.json5
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js13
-rw-r--r--src/content/dependencies/transformContent.js57
-rw-r--r--src/data/composite/data/index.js1
-rw-r--r--src/data/composite/data/withUniqueItemsOnly.js40
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js40
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js28
-rw-r--r--src/data/yaml.js24
-rw-r--r--src/util/wiki-data.js8
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js84
-rw-r--r--test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js16
-rw-r--r--test/unit/data/things/track.js48
14 files changed, 301 insertions, 79 deletions
diff --git a/README.md b/README.md
index a7fc5824..2c99e3d8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagini
 
 Install dependencies:
 
-- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 16.x LTS should also work
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work
 - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
 
 Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
diff --git a/package-lock.json b/package-lock.json
index 6433ea16..9e8f4dfa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
                 "he": "^1.2.0",
                 "image-size": "^1.0.2",
                 "js-yaml": "^4.1.0",
-                "marked": "^5.0.2",
+                "marked": "^10.0.0",
                 "striptags": "^4.0.0-alpha.4",
                 "word-wrap": "^1.2.3"
             },
@@ -3303,9 +3303,9 @@
             }
         },
         "node_modules/marked": {
-            "version": "5.0.2",
-            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
-            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==",
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==",
             "bin": {
                 "marked": "bin/marked.js"
             },
@@ -8096,9 +8096,9 @@
             }
         },
         "marked": {
-            "version": "5.0.2",
-            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
-            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ=="
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA=="
         },
         "mimic-fn": {
             "version": "2.1.0",
diff --git a/package.json b/package.json
index 34b4ebf0..719a5b89 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,9 @@
         "#wiki-data": "./src/util/wiki-data.js",
         "#yaml": "./src/data/yaml.js"
     },
+    "engines": {
+        "node": ">= 20.9.0"
+    },
     "dependencies": {
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
@@ -49,7 +52,7 @@
         "he": "^1.2.0",
         "image-size": "^1.0.2",
         "js-yaml": "^4.1.0",
-        "marked": "^5.0.2",
+        "marked": "^10.0.0",
         "striptags": "^4.0.0-alpha.4",
         "word-wrap": "^1.2.3"
     },
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index b265ed41..0b2b2558 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateColorStyleVariables',
@@ -8,9 +10,10 @@ export default {
   extraDependencies: ['html', 'language'],
 
   relations: (relation, entry) => ({
-    artistLink:
-      (entry.artist && !entry.artistDisplayText
-        ? relation('linkArtist', entry.artist)
+    artistLinks:
+      (!empty(entry.artists) && !entry.artistDisplayText
+        ? entry.artists
+            .map(artist => relation('linkArtist', artist))
         : null),
 
     artistsContent:
@@ -45,8 +48,8 @@ export default {
       html.tag('span', {class: 'commentary-entry-artists'},
         (relations.artistsContent
           ? relations.artistsContent.slot('mode', 'inline')
-       : relations.artistLink
-          ? relations.artistLink
+       : relations.artistLinks
+          ? language.formatConjunctionList(relations.artistLinks)
           : language.$('misc.artistCommentary.entry.title.noArtists')));
 
     const accentParts = ['misc.artistCommentary.entry.title.accent'];
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index a85ac158..b0a7796c 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,7 +1,7 @@
 import {bindFind} from '#find';
 import {parseInput} from '#replacer';
 
-import {marked} from 'marked';
+import {Marked} from 'marked';
 
 export const replacerSpec = {
   album: {
@@ -147,6 +147,29 @@ const linkIndexRelationMap = {
   newsIndex: 'linkNewsIndex',
 };
 
+const commonMarkedOptions = {
+  headerIds: false,
+  mangle: false,
+};
+
+const multilineMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+const inlineMarked = new Marked({
+  ...commonMarkedOptions,
+
+  renderer: {
+    paragraph(text) {
+      return text;
+    },
+  },
+});
+
+const lyricsMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
 function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
@@ -447,21 +470,9 @@ export default {
       return link.data;
     }
 
-    // In inline mode, no further processing is needed!
-
-    if (slots.mode === 'inline') {
-      return html.tags(
-        contentFromNodes.map(node => node.data),
-        {[html.joinChildren]: ''});
-    }
-
-    // Multiline mode has a secondary processing stage where it's passed...
-    // through marked! Rolling your own Markdown only gets you so far :D
-
-    const markedOptions = {
-      headerIds: false,
-      mangle: false,
-    };
+    // Content always goes through marked (i.e. parsing as Markdown).
+    // This does require some attention to detail, mostly to do with line
+    // breaks (in multiline mode) and extracting/re-inserting non-text nodes.
 
     // The content of non-text nodes can end up getting mangled by marked.
     // To avoid this, we replace them with mundane placeholders, then
@@ -536,6 +547,16 @@ export default {
       return html.tags(tags, {[html.joinChildren]: ''});
     };
 
+    if (slots.mode === 'inline') {
+      const markedInput =
+        extractNonTextNodes();
+
+      const markedOutput =
+        inlineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
     // This is separated into its own function just since we're gonna reuse
     // it in a minute if everything goes to heck in lyrics mode.
     const transformMultiline = () => {
@@ -552,7 +573,7 @@ export default {
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        multilineMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }
@@ -602,7 +623,7 @@ export default {
         });
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        lyricsMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index db1c37cc..e2927afd 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -11,3 +11,4 @@ export {default as withPropertiesFromObject} from './withPropertiesFromObject.js
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertyFromObject} from './withPropertyFromObject.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..7ee08b08
--- /dev/null
+++ b/src/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,40 @@
+// Excludes duplicate items from a list and provides the results, overwriting
+// the list in-place, if possible.
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueItemsOnly`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#uniqueItems'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#values']:
+          unique(list),
+      }),
+    },
+
+    {
+      dependencies: ['#values', input.staticDependency('list')],
+      compute: (continuation, {
+        '#values': values,
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        [list ?? '#uniqueItems']:
+          values,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
index 7b1c9484..edfc9e3c 100644
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -4,7 +4,12 @@ import {stitchArrays} from '#sugar';
 import {isCommentary} from '#validators';
 import {commentaryRegex} from '#wiki-data';
 
-import {fillMissingListItems, withPropertiesFromList} from '#composite/data';
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
 
 import withResolvedReferenceList from './withResolvedReferenceList.js';
 
@@ -86,23 +91,42 @@ export default templateCompositeFrom({
       list: '#rawMatches.groups',
       prefix: input.value('#entries'),
       properties: input.value([
-        'artistReference',
+        'artistReferences',
         'artistDisplayText',
         'annotation',
         'date',
       ]),
     }),
 
-    // The artistReference group will always have a value, since it's required
+    // The artistReferences group will always have a value, since it's required
     // for the line to match in the first place.
 
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
     withResolvedReferenceList({
-      list: '#entries.artistReference',
+      list: '#flattenedList',
       data: 'artistData',
       find: input.value(find.artist),
       notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
     }).outputs({
-      '#resolvedReferenceList': '#entries.artist',
+      '#unflattenedList': '#entries.artists',
     }),
 
     fillMissingListItems({
@@ -127,7 +151,7 @@ export default templateCompositeFrom({
 
     {
       dependencies: [
-        '#entries.artist',
+        '#entries.artists',
         '#entries.artistDisplayText',
         '#entries.annotation',
         '#entries.date',
@@ -135,7 +159,7 @@ export default templateCompositeFrom({
       ],
 
       compute: (continuation, {
-        ['#entries.artist']: artist,
+        ['#entries.artists']: artists,
         ['#entries.artistDisplayText']: artistDisplayText,
         ['#entries.annotation']: annotation,
         ['#entries.date']: date,
@@ -143,7 +167,7 @@ export default templateCompositeFrom({
       }) => continuation({
         ['#parsedCommentaryEntries']:
           stitchArrays({
-            artist,
+            artists,
             artistDisplayText,
             annotation,
             date,
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
index 8720e66d..f400bbfc 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -4,8 +4,10 @@
 import {input, templateCompositeFrom} from '#composite';
 import {unique} from '#sugar';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromList} from '#composite/data';
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
+  from '#composite/data';
 import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
@@ -26,15 +28,23 @@ export default templateCompositeFrom({
 
     withPropertyFromList({
       list: '#parsedCommentaryEntries',
-      property: input.value('artist'),
+      property: input.value('artists'),
     }).outputs({
-      '#parsedCommentaryEntries.artist': '#artists',
+      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
-    {
-      dependencies: ['#artists'],
-      compute: ({'#artists': artists}) =>
-        unique(artists.filter(artist => artist !== null)),
-    },
+    withFlattenedList({
+      list: '#artistLists',
+    }).outputs({
+      '#flattenedList': '#artists',
+    }),
+
+    withUniqueItemsOnly({
+      list: '#artists',
+    }),
+
+    exposeDependency({
+      dependency: '#artists',
+    }),
   ],
 });
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 843e70b3..0734d539 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -21,6 +21,7 @@ import {
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   empty,
+  filterAggregate,
   filterProperties,
   openAggregate,
   showAggregate,
@@ -1686,8 +1687,10 @@ export function filterReferenceErrors(wikiData) {
                 if (value) {
                   value =
                     Array.from(value.matchAll(commentaryRegex))
-                      .map(({groups}) => groups.artistReference);
+                      .map(({groups}) => groups.artistReferences)
+                      .map(text => text.split(',').map(text => text.trim()));
                 }
+
                 writeProperty = false;
                 break;
             }
@@ -1804,11 +1807,22 @@ export function filterReferenceErrors(wikiData) {
 
             let newPropertyValue = value;
 
-            if (Array.isArray(value)) {
+            if (findFnKey === '_commentary') {
+              // Commentary doesn't write a property value, so no need to set.
+              filter(
+                value, {message: errorMessage},
+                decorateErrorWithIndex(refs =>
+                  (refs.length === 1
+                    ? suppress(findFn)(refs[0])
+                    : filterAggregate(
+                        refs, {message: `Errors in entry's artist references`},
+                        decorateErrorWithIndex(suppress(findFn)))
+                          .aggregate
+                          .close())));
+            } else if (Array.isArray(value)) {
               newPropertyValue = filter(
-                value,
-                decorateErrorWithIndex(suppress(findFn)),
-                {message: errorMessage});
+                value, {message: errorMessage},
+                decorateErrorWithIndex(suppress(findFn)));
             } else {
               nest({message: errorMessage},
                 suppress(({call}) => {
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 75a141d3..5e3182a9 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -641,8 +641,10 @@ export function sortFlashesChronologically(data, {
 //
 //   * "25 December 2019" - one or two number digits, followed by any text,
 //     followed by four number digits
-//   * "12/25/2019" - one or two number digits, a slash, one or two number
-//     digits, a slash, and two to 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)
 //
 // Capturing group "artistReference" is all the characters between <i> and </i>
 // (apart from the pipe and "artistDisplayText" text, if present), and is either
@@ -652,7 +654,7 @@ export function sortFlashesChronologically(data, {
 // out of the original string based on the indices matched using this.
 //
 export const commentaryRegex =
-  /^<i>(?<artistReference>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=[,)]))*?)(?:,? ?(?<date>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm;
+  /^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)$))*?)(?:,? ?(?<date>[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}))?\))?$/gm;
 
 export function filterAlbumsByCommentary(albums) {
   return albums
diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..965b14b5
--- /dev/null
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,84 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withUniqueItemsOnly} from '#composite/data';
+
+t.test(`withUniqueItemsOnly: basic behavior`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withUniqueItemsOnly({
+        list: 'list',
+      }),
+
+      exposeDependency({dependency: '#list'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['list'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'],
+  }), ['apple', 'banana', 'watermelon']);
+
+  t.same(composite.expose.compute({
+    list: [],
+  }), []);
+});
+
+t.test(`withUniqueItemsOnly: output shapes & values`, t => {
+  t.plan(2 * 3 ** 1);
+
+  const dependencies = {
+    ['list_dependency']:
+      [1, 1, 2, 3, 3, 4, 'foo', false, false, 4],
+    [input('list_neither')]:
+      [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
+  };
+
+  const mapLevel1 = [
+    ['list_dependency', {
+      '#list_dependency': [1, 2, 3, 4, 'foo', false],
+    }],
+    [input.value([-1, -1, 'interesting', 'very', 'interesting']), {
+      '#uniqueItems': [-1, 'interesting', 'very'],
+    }],
+    [input('list_neither'), {
+      '#uniqueItems': [8, 7, 6, 5, 'bar', true],
+    }],
+  ];
+
+  for (const [listInput, outputDict] of mapLevel1) {
+    const step = withUniqueItemsOnly({
+      list: listInput,
+    });
+
+    quickCheckOutputs(step, outputDict);
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
index 50570de6..babe4fae 100644
--- a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -41,7 +41,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
     },
   });
 
-  t.match(composite.expose.compute({
+  t.same(composite.expose.compute({
     artistData,
     from:
       `<i>Mobius Trip:</i>\n` +
@@ -49,7 +49,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       `Very cool.\n`,
   }), [
     {
-      artist: artist1,
+      artists: [artist1],
       artistDisplayText: null,
       annotation: null,
       date: null,
@@ -57,7 +57,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
     },
   ]);
 
-  t.match(composite.expose.compute({
+  t.same(composite.expose.compute({
     artistData,
     from:
       `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` +
@@ -67,32 +67,32 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       `Second commentary entry. Yes. So cool.\n` +
       `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` +
       `Oh no.. Oh dear...\n` +
-      `<i>Mobius Trip:</i>\n` +
+      `<i>Mobius Trip, Hadron Kaleido:</i>\n` +
       `And back around we go.`,
   }), [
     {
-      artist: artist1,
+      artists: [artist1],
       artistDisplayText: `Moo-bius Trip`,
       annotation: `music, art`,
       date: new Date('12 January 2015'),
       body: `First commentary entry.\nVery cool.`,
     },
     {
-      artist: artist2,
+      artists: [artist2],
       artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`,
       annotation: `moral support`,
       date: new Date('4 April 2022'),
       body: `Second commentary entry. Yes. So cool.`,
     },
     {
-      artist: null,
+      artists: [],
       artistDisplayText: null,
       annotation: `pingas`,
       date: new Date('25 August 2023'),
       body: `Oh no.. Oh dear...`,
     },
     {
-      artist: artist1,
+      artists: [artist1, artist2],
       artistDisplayText: null,
       annotation: null,
       date: null,
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index 806efbf1..571624a5 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -214,7 +214,7 @@ t.test(`Track.color`, t => {
 });
 
 t.test(`Track.commentatorArtists`, t => {
-  t.plan(6);
+  t.plan(8);
 
   const track = new Track();
   const artist1 = stubArtist(`SnooPING`);
@@ -226,47 +226,67 @@ t.test(`Track.commentatorArtists`, t => {
     artistData: [artist1, artist2, artist3],
   });
 
-  track.commentary =
+  // Keep track of the last commentary string in a separate value, since
+  // the track.commentary property exposes as a completely different format
+  // (i.e. an array of objects, one for each entry), and so isn't compatible
+  // with the += operator on its own.
+  let commentary;
+
+  track.commentary = commentary =
     `<i>SnooPING:</i>\n` +
     `Wow.\n`;
 
   t.same(track.commentatorArtists, [artist1],
     `Track.commentatorArtists #1: works with one commentator`);
 
-  track.commentary +=
+  track.commentary = commentary +=
     `<i>ASUsual:</i>\n` +
     `Yes!\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2],
     `Track.commentatorArtists #2: works with two commentators`);
 
-  track.commentary +=
-    `<i><b>Icy:</b></i>\n` +
+  track.commentary = commentary +=
+    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
     `Incredible.\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2, artist3],
-    `Track.commentatorArtists #3: works with boldface name`);
+    `Track.commentatorArtists #3: works with custom artist text`);
 
-  track.commentary =
+  track.commentary = commentary =
     `<i>Icy:</i> (project manager)\n` +
     `Very good track.\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #4: works with parenthical accent`);
+    `Track.commentatorArtists #4: works with annotation`);
 
-  track.commentary +=
-    `<i>SNooPING ASUsual Icy:</i>\n` +
-    `WITH ALL THREE POWERS COMBINED...`;
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager, 08/15/2023)\n` +
+    `Very very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #5: works with date`);
+
+  track.commentary = commentary +=
+    `<i>Ohohohoho:</i>\n` +
+    `OHOHOHOHOHOHO...\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #5: ignores artist names not found`);
+    `Track.commentatorArtists #6: ignores artist names not found`);
 
-  track.commentary +=
+  track.commentary = commentary +=
     `<i>Icy:</i>\n` +
     `I'm back!\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #6: ignores duplicate artist`);
+    `Track.commentatorArtists #7: ignores duplicate artist`);
+
+  track.commentary = commentary +=
+    `<i>SNooPING, ASUsual, Icy:</i>\n` +
+    `WITH ALL THREE POWERS COMBINED...`;
+
+  t.same(track.commentatorArtists, [artist3, artist1, artist2],
+    `Track.commentatorArtists #8: works with more than one artist in one entry`);
 });
 
 t.test(`Track.coverArtistContribs`, t => {