« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js129
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js151
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js224
-rw-r--r--src/content/dependencies/generateArtTagNavLinks.js80
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js88
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js10
-rw-r--r--src/content/dependencies/generateCoverArtwork.js14
-rw-r--r--src/content/dependencies/generateCoverGrid.js30
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js7
-rw-r--r--src/content/dependencies/generateQuickDescription.js132
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js2
-rw-r--r--src/content/dependencies/image.js4
-rw-r--r--src/content/dependencies/linkArtTagDynamically.js14
-rw-r--r--src/content/dependencies/linkArtTagGallery.js8
-rw-r--r--src/content/dependencies/linkArtTagInfo.js (renamed from src/content/dependencies/linkArtTag.js)2
-rw-r--r--src/content/dependencies/linkExternal.js12
-rw-r--r--src/content/dependencies/listArtTagsByName.js (renamed from src/content/dependencies/listTagsByName.js)15
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listTagsByUses.js59
-rw-r--r--src/content/dependencies/transformContent.js14
20 files changed, 917 insertions, 132 deletions
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 00000000..34a45ffc
--- /dev/null
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -0,0 +1,129 @@
+import {stitchArrays} from '#sugar';
+import {filterMultipleArrays, sortMultipleArrays} from '#wiki-data';
+
+export default {
+  contentDependencies: ['linkArtTagDynamically'],
+  extraDependencies: ['html', 'language'],
+
+  // Recursion ain't too pretty!
+
+  query(ancestorArtTag, targetArtTag) {
+    const recursive = artTag => {
+      const artTags =
+        artTag.directDescendantArtTags.slice();
+
+      const displayBriefly =
+        !artTags.includes(targetArtTag) &&
+        artTags.length > 3;
+
+      const artTagsIncludeTargetArtTag =
+        artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag));
+
+      const numExemptArtTags =
+        (displayBriefly
+          ? artTagsIncludeTargetArtTag
+              .filter(includesTargetArtTag => !includesTargetArtTag)
+              .length
+          : null);
+
+      const sublists =
+        stitchArrays({
+          artTag: artTags,
+          includesTargetArtTag: artTagsIncludeTargetArtTag,
+        }).map(({artTag, includesTargetArtTag}) =>
+            (includesTargetArtTag
+              ? recursive(artTag)
+              : null));
+
+      if (displayBriefly) {
+        filterMultipleArrays(artTags, sublists,
+          (artTag, sublist) =>
+            artTag === targetArtTag ||
+            sublist !== null);
+      } else {
+        sortMultipleArrays(artTags, sublists,
+          (artTagA, artTagB, sublistA, sublistB) =>
+            (sublistA && sublistB
+              ? 0
+           : !sublistA && !sublistB
+              ? 0
+           : sublistA
+              ? 1
+              : -1));
+      }
+
+      return {
+        displayBriefly,
+        numExemptArtTags,
+        artTags,
+        sublists,
+      };
+    };
+
+    return {root: recursive(ancestorArtTag)};
+  },
+
+  relations(relation, query, _ancestorArtTag, _targetArtTag) {
+    const recursive = ({artTags, sublists}) => ({
+      artTagLinks:
+        artTags
+          .map(artTag => relation('linkArtTagDynamically', artTag)),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  data(query, _ancestorArtTag, targetArtTag) {
+    const recursive = ({displayBriefly, numExemptArtTags, artTags, sublists}) => ({
+      displayBriefly,
+      numExemptArtTags,
+
+      artTagsAreTargetTag:
+        artTags
+          .map(artTag => artTag === targetArtTag),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  generate(data, relations, {html, language}) {
+    const recursive = (dataNode, relationsNode) =>
+      html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [
+        dataNode.displayBriefly &&
+          html.tag('dt',
+            language.$('artTagSidebar.otherTagsExempt', {
+              tags:
+                language.countArtTags(dataNode.numExemptArtTags, {unit: true}),
+            })),
+
+        stitchArrays({
+          isTargetTag: dataNode.artTagsAreTargetTag,
+          dataSublist: dataNode.sublists,
+
+          artTagLink: relationsNode.artTagLinks,
+          relationsSublist: relationsNode.sublists,
+        }).map(({
+            isTargetTag, dataSublist,
+            artTagLink, relationsSublist,
+          }) => [
+            html.tag('dt',
+              {class: (dataSublist || isTargetTag) && 'current'},
+              artTagLink),
+
+            dataSublist &&
+              html.tag('dd',
+                recursive(dataSublist, relationsSublist)),
+          ]),
+      ]);
+
+    return recursive(data.root, relations.root);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index c04bfb68..72badb73 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,13 +1,16 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays, unique} from '#sugar';
 import {sortAlbumsTracksChronologically} from '#wiki-data';
 
 export default {
   contentDependencies: [
+    'generateArtTagNavLinks',
     'generateCoverGrid',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
     'linkAlbum',
-    'linkArtTag',
+    'linkArtTagGallery',
+    'linkExternal',
     'linkTrack',
   ],
 
@@ -19,61 +22,94 @@ export default {
     };
   },
 
-  query(sprawl, tag) {
-    const things = tag.taggedInThings.slice();
+  query(sprawl, artTag) {
+    const directThings = artTag.directlyTaggedInThings;
+    const indirectThings = artTag.indirectlyTaggedInThings;
+    const allThings = unique([...directThings, ...indirectThings]);
 
-    sortAlbumsTracksChronologically(things, {
-      getDate: thing => thing.coverArtDate,
+    sortAlbumsTracksChronologically(allThings, {
+      getDate: thing => thing.coverArtDate ?? thing.date,
       latestFirst: true,
     });
 
-    return {things};
+    return {directThings, indirectThings, allThings};
   },
 
-  relations(relation, query, sprawl, tag) {
+  relations(relation, query, sprawl, artTag) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
-    relations.artTagMainLink =
-      relation('linkArtTag', tag);
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    if (!empty(artTag.extraReadingURLs)) {
+      relations.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      relations.ancestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      relations.descendantLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
 
     relations.coverGrid =
       relation('generateCoverGrid');
 
     relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
+      query.allThings
+        .map(thing =>
+          (thing.album
+            ? relation('linkTrack', thing)
+            : relation('linkAlbum', thing)));
 
     relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
+      query.allThings
+        .map(thing => relation('image', thing.artTags));
 
     return relations;
   },
 
-  data(query, sprawl, tag) {
+  data(query, sprawl, artTag) {
     const data = {};
 
     data.enableListings = sprawl.enableListings;
 
-    data.name = tag.name;
-    data.color = tag.color;
+    data.name = artTag.name;
+    data.color = artTag.color;
 
-    data.numArtworks = query.things.length;
+    data.numArtworks = query.allThings.length;
 
     data.names =
-      query.things.map(thing => thing.name);
+      query.allThings.map(thing => thing.name);
 
     data.paths =
-      query.things.map(thing =>
+      query.allThings.map(thing =>
         (thing.album
           ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
           : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
 
+    data.coverArtists =
+      query.allThings.map(thing =>
+        thing.coverArtistContribs
+          .map(({who: artist}) => artist.name));
+
+    data.onlyFeaturedIndirectly =
+      query.allThings.map(thing =>
+        !query.directThings.includes(thing));
+
     return data;
   },
 
@@ -81,7 +117,7 @@ export default {
     return relations.layout
       .slots({
         title:
-          language.$('tagPage.title', {
+          language.$('artTagGalleryPage.title', {
             tag: data.name,
           }),
 
@@ -91,44 +127,67 @@ export default {
 
         mainClasses: ['top-index'],
         mainContent: [
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
-            })),
+          relations.quickDescription.slots({
+            extraReadingLinks: relations.extraReadingLinks ?? null,
+          }),
+
+          html.tag('p', {class: 'quick-info'},
+            (data.numArtworks === 0
+              ? [
+                  language.$('artTagGalleryPage.infoLine.notFeatured'),
+                  html.tag('br'),
+                  language.$('artTagGalleryPage.infoLine.callToAction'),
+                ]
+              : language.$('artTagGalleryPage.infoLine', {
+                  coverArts: language.countArtworks(data.numArtworks, {
+                    unit: true,
+                  }),
+                }))),
+
+          relations.ancestorLinks &&
+            html.tag('p', {class: 'quick-info'},
+              language.$('artTagGalleryPage.descendsFrom', {
+                tags: language.formatConjunctionList(relations.ancestorLinks),
+              })),
+
+          relations.descendantLinks &&
+            html.tag('p', {clasS: 'quick-info'},
+              language.$('artTagGalleryPage.desendants', {
+                tags: language.formatUnitList(relations.descendantLinks),
+              })),
 
           relations.coverGrid
             .slots({
               links: relations.links,
               names: data.names,
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
+
               images:
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
                 }).map(({image, path}) =>
                     image.slot('path', path)),
+
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.albumGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
             }),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-
-          data.enableListings &&
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-
-          {
-            html:
-              language.$('tagPage.nav.tag', {
-                tag: relations.artTagMainLink,
-              }),
-          },
-        ],
+        navLinks:
+          relations.navLinks
+            .slot('currentExtra', 'gallery')
+            .content,
       });
   },
 };
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
new file mode 100644
index 00000000..056d9749
--- /dev/null
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -0,0 +1,224 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtTagNavLinks',
+    'generateArtTagSidebar',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtTagGallery',
+    'linkArtTagInfo',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  query(sprawl, artTag) {
+    const directThings = artTag.directlyTaggedInThings;
+    const indirectThings = artTag.indirectlyTaggedInThings;
+    const allThings = unique([...directThings, ...indirectThings]);
+
+    return {directThings, indirectThings, allThings};
+  },
+
+  relations(relation, query, sprawl, artTag) {
+    const relations = {};
+    const sec = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.sidebar =
+      relation('generateArtTagSidebar', artTag);
+
+    const info = sec.info = {};
+
+    if (artTag.description) {
+      info.description =
+        relation('transformContent', artTag.description);
+    }
+
+    if (!empty(query.allThings)) {
+      info.galleryLink =
+        relation('linkArtTagGallery', artTag);
+    }
+
+    if (!empty(artTag.extraReadingURLs)) {
+      info.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      const ancestors = sec.ancestors = {};
+
+      ancestors.heading =
+        relation('generateContentHeading');
+
+      ancestors.directAncestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      const descendants = sec.descendants = {};
+
+      descendants.heading =
+        relation('generateContentHeading');
+
+      descendants.directDescendantInfoLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag));
+
+      const allDescendantsHaveMoreDescendants =
+        artTag.directDescendantArtTags
+          .every(artTag => !empty(artTag.directDescendantArtTags));
+
+      if (!allDescendantsHaveMoreDescendants) {
+        descendants.directDescendantGalleryLinks =
+          artTag.directDescendantArtTags
+            .map(artTag => relation('linkArtTagGallery', artTag));
+      }
+    }
+
+    return relations;
+  },
+
+  data(query, sprawl, artTag) {
+    const data = {};
+
+    data.enableListings = sprawl.enableListings;
+
+    data.name = artTag.name;
+    data.color = artTag.color;
+
+    data.numArtworksIndirectly = query.indirectThings.length;
+    data.numArtworksDirectly = query.directThings.length;
+    data.numArtworksTotal = query.allThings.length;
+
+    data.names =
+      query.allThings.map(thing => thing.name);
+
+    data.paths =
+      query.allThings.map(thing =>
+        (thing.album
+          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
+          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+
+    data.onlyFeaturedIndirectly =
+      query.allThings.map(thing =>
+        !query.directThings.includes(thing));
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+    const nameOption = {tag: language.sanitize(data.name)};
+
+    return relations.layout
+      .slots({
+        title: language.$('artTagInfoPage.title', nameOption),
+        headingMode: 'sticky',
+        color: data.color,
+
+        mainContent: [
+          html.tag('p',
+            (data.numArtworksTotal === 0
+              ? language.$('artTagInfoPage.featuredIn.notFeatured')
+           : data.numArtworksDirectly === 0
+              ? language.$('artTagInfoPage.featuredIn.indirectlyOnly', {
+                  artworks: language.countArtworks(data.numArtworksIndirectly, {unit: true}),
+                })
+           : data.numArtworksIndirectly === 0
+              ? language.$('artTagInfoPage.featuredIn.directlyOnly', {
+                  artworks: language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                })
+              : language.$('artTagInfoPage.featuredIn.directlyAndIndirectly', {
+                  artworksDirectly: language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                  artworksIndirectly: language.countArtworks(data.numArtworksIndirectly, {unit: false}),
+                  artworksTotal: language.countArtworks(data.numArtworksTotal, {unit: false}),
+                }))),
+
+          sec.info.galleryLink &&
+            html.tag('p',
+              language.$('artTagInfoPage.viewArtGallery', {
+                link:
+                  sec.info.galleryLink
+                    .slot('content', language.$('artTagInfoPage.viewArtGallery.link')),
+              })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            sec.info.description
+              ?.slot('mode', 'multiline')),
+
+          sec.info.extraReadingLinks &&
+            html.tag('p',
+              language.$('artTagInfoPage.readMoreOn', {
+                ...nameOption,
+                links: language.formatDisjunctionList(sec.info.extraReadingLinks),
+              })),
+
+          sec.ancestors && [
+            sec.ancestors.heading
+              .slot('title',
+                language.$('artTagInfoPage.descendsFromTags', nameOption)),
+
+            html.tag('ul',
+              sec.ancestors.directAncestorLinks
+                .map(link =>
+                  html.tag('li',
+                    language.$('artTagInfoPage.descendsFromTags.item', {
+                      tag: link,
+                    })))),
+          ],
+
+          sec.descendants && [
+            sec.descendants.heading
+              .slot('title',
+                language.$('artTagInfoPage.descendantTags', nameOption)),
+
+            !sec.descendants.directDescendantGalleryLinks &&
+              html.tag('ul',
+                sec.descendants.directDescendantInfoLinks
+                  .map(link =>
+                    html.tag('li',
+                      language.$('artTagInfoPage.descendantTags.item', {
+                        tag: link,
+                      })))),
+
+            sec.descendants.directDescendantGalleryLinks &&
+              html.tag('ul',
+                stitchArrays({
+                  infoLink: sec.descendants.directDescendantInfoLinks,
+                  galleryLink: sec.descendants.directDescendantGalleryLinks,
+                }).map(({infoLink, galleryLink}) =>
+                    html.tag('li',
+                      language.$('artTagInfoPage.descendantTags.item.withGallery', {
+                        tag: infoLink,
+
+                        gallery:
+                          galleryLink.slot('content',
+                            language.$('artTagInfoPage.descendantTags.item.gallery')),
+                      })))),
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        ...relations.sidebar,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js
new file mode 100644
index 00000000..34f95f6e
--- /dev/null
+++ b/src/content/dependencies/generateArtTagNavLinks.js
@@ -0,0 +1,80 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkArtTagInfo',
+    'linkArtTagGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableListings: wikiInfo.enableListings}),
+
+  relations: (relation, sprawl, tag) => ({
+    mainLink:
+      relation('linkArtTagInfo', tag),
+
+    infoLink:
+      relation('linkArtTagInfo', tag),
+
+    galleryLink:
+      relation('linkArtTagGallery', tag),
+  }),
+
+  data: (sprawl) =>
+    ({enableListings: sprawl.enableListings}),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableListings) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const extraLinks = [
+      relations.galleryLink?.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      }),
+    ];
+
+    const extrasPart =
+      (empty(extraLinks)
+        ? ''
+        : language.formatUnitList([infoLink, ...extraLinks]));
+
+    const accent = `(${extrasPart})`;
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        accent,
+        html:
+          language.$('artTagPage.nav.tag', {
+            tag: relations.mainLink,
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
new file mode 100644
index 00000000..f6787a8c
--- /dev/null
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -0,0 +1,88 @@
+import {empty, stitchArrays} from '#sugar';
+import {collectTreeLeaves} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtTagAncestorDescendantMapList',
+    'linkArtTagDynamically',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query(sprawl, artTag) {
+    const baobab = artTag.ancestorArtTagBaobabTree;
+    const uniqueLeaves = new Set(collectTreeLeaves(baobab));
+
+    // Just match the order in tag data.
+    const furthestAncestorArtTags =
+      sprawl.artTagData
+        .filter(artTag => uniqueLeaves.has(artTag));
+
+    return {furthestAncestorArtTags};
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    artTagLink:
+      relation('linkArtTagDynamically', artTag),
+
+    directDescendantArtTagLinks:
+      artTag.directDescendantArtTags
+        .map(descendantArtTag =>
+          relation('linkArtTagDynamically', descendantArtTag)),
+
+    furthestAncestorArtTagMapLists:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag =>
+          relation('generateArtTagAncestorDescendantMapList',
+            ancestorArtTag,
+            artTag)),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    name: artTag.name,
+
+    furthestAncestorArtTagNames:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag => ancestorArtTag.name),
+  }),
+
+  generate: (data, relations, {html, language}) => ({
+    leftSidebarContent: [
+      html.tag('h1',
+        relations.artTagLink),
+
+      !empty(relations.directDescendantArtTagLinks) &&
+        html.tag('details', {class: 'current', open: true}, [
+          html.tag('summary',
+            html.tag('span', {class: 'group-name'},
+              language.sanitize(data.name))),
+
+          html.tag('ul',
+            relations.directDescendantArtTagLinks
+              .map(link => html.tag('li', link))),
+        ]),
+
+      stitchArrays({
+        name: data.furthestAncestorArtTagNames,
+        list: relations.furthestAncestorArtTagMapLists,
+      }).map(({name, list}) =>
+          html.tag('details',
+            {
+              class: 'has-tree-list',
+              open:
+                empty(relations.directDescendantArtTagLinks) &&
+                relations.furthestAncestorArtTagMapLists.length === 1,
+            },
+            [
+              html.tag('summary',
+                html.tag('span', {class: 'group-name'},
+                  language.sanitize(name))),
+
+              list,
+            ])),
+    ],
+  }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index aa6efe5e..adfee1da 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -30,7 +30,7 @@ export default {
         entry: {
           type: 'albumCover',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.coverArtistContribs,
         },
       })),
@@ -40,7 +40,7 @@ export default {
         entry: {
           type: 'albumWallpaper',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.wallpaperArtistContribs,
         },
       })),
@@ -50,7 +50,7 @@ export default {
         entry: {
           type: 'albumBanner',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.bannerArtistContribs,
         },
       })),
@@ -60,7 +60,7 @@ export default {
         entry: {
           type: 'trackCover',
           album: track.album,
-          date: track.coverArtDate,
+          date: track.coverArtDate ?? track.album.date,
           track: track,
           contribs: track.coverArtistContribs,
         },
@@ -69,7 +69,7 @@ export default {
 
     sortEntryThingPairs(entries,
       things => sortAlbumsTracksChronologically(things, {
-        getDate: thing => thing.coverArtDate,
+        getDate: thing => thing.coverArtDate ?? thing.date,
       }));
 
     const chunks =
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index aeba97de..31a4a6bb 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,7 +1,7 @@
 import {empty} from '#sugar';
 
 export default {
-  contentDependencies: ['image', 'linkArtTag'],
+  contentDependencies: ['image', 'linkArtTagGallery'],
   extraDependencies: ['html', 'language'],
 
   relations(relation, artTags) {
@@ -11,12 +11,12 @@ export default {
       relation('image', artTags);
 
     if (artTags) {
-      relations.tagLinks =
+      relations.artTagLinks =
         artTags
-          .filter(tag => !tag.isContentWarning)
-          .map(tag => relation('linkArtTag', tag));
+          .filter(artTag => !artTag.isContentWarning)
+          .map(artTag => relation('linkArtTagGallery', artTag));
     } else {
-      relations.tagLinks = null;
+      relations.artTagLinks = null;
     }
 
     return relations;
@@ -52,12 +52,12 @@ export default {
               square: true,
             }),
 
-          !empty(relations.tagLinks) &&
+          !empty(relations.artTagLinks) &&
             html.tag('p',
               language.$('releaseInfo.artTags.inline', {
                 tags:
                   language.formatUnitList(
-                    relations.tagLinks
+                    relations.artTagLinks
                       .map(tagLink => tagLink.slot('preferShortName', true))),
               })),
           ]);
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 5636e4f3..6ac0f941 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -16,6 +16,19 @@ export default {
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
 
+    // Differentiating from sparseArrayOf here - this list of classes should
+    // have the same length as the items above, i.e. nulls aren't going to be
+    // filtered out of it, but it is okay to *include* null (standing in for
+    // no classes for this grid item).
+    classes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(
+            v.oneOf(
+              v.isArray,
+              v.isString))),
+    },
+
     lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)},
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
@@ -24,13 +37,26 @@ export default {
     return (
       html.tag('div', {class: 'grid-listing'}, [
         stitchArrays({
+          classOrClasses: slots.classes,
           image: slots.images,
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({image, link, name, info}, index) =>
+        }).map(({classOrClasses, image, link, name, info}, index) =>
             link.slots({
-              attributes: {class: ['grid-item', 'box']},
+              attributes: {
+                class: [
+                  'grid-item',
+                  'box',
+                  ...
+                    (Array.isArray(classOrClasses)
+                      ? classOrClasses
+                   : typeof classOrClasses === 'string'
+                      ? [classOrClasses]
+                      : []),
+                ],
+              },
+
               content: [
                 image.slots({
                   thumb: 'medium',
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 259f5dce..28ab4f81 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -14,8 +14,10 @@ export default {
     'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
     'linkAlbum',
+    'linkGroup',
     'linkListing',
   ],
 
@@ -59,6 +61,9 @@ export default {
           .map(album => relation('image', album.artTags));
     }
 
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
     relations.coverGrid =
       relation('generateCoverGrid');
 
@@ -132,6 +137,8 @@ export default {
                     image.slot('path', path)),
             }),
 
+          relations.quickDescription,
+
           html.tag('p',
             {class: 'quick-info'},
             language.$('groupGalleryPage.infoLine', {
diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js
new file mode 100644
index 00000000..c67dd1ec
--- /dev/null
+++ b/src/content/dependencies/generateQuickDescription.js
@@ -0,0 +1,132 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  query: (thing) => ({
+    hasDescription:
+      !!thing.description,
+
+    hasLongerDescription:
+      thing.description &&
+      thing.descriptionShort &&
+      thing.descriptionShort !== thing.description,
+  }),
+
+  relations: (relation, query, thing) => ({
+    description:
+      (query.hasLongerDescription || !thing.description
+        ? null
+        : relation('transformContent', thing.description)),
+
+    descriptionShort:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.descriptionShort)
+        : null),
+
+    descriptionLong:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.description)
+        : null),
+  }),
+
+  data: (query) => ({
+    hasDescription: query.hasDescription,
+    hasLongerDescription: query.hasLongerDescription,
+  }),
+
+  slots: {
+    extraReadingLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const prefix = 'misc.quickDescription';
+
+    const actionsWithoutLongerDescription =
+      (data.hasLongerDescription
+        ? null
+     : slots.extraReadingLinks
+        ? language.$(prefix, 'readMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+          })
+        : null);
+
+    const wrapExpandCollapseLink = (expandCollapse, content) =>
+      html.tag('a',
+        {href: '#', class: `${expandCollapse}-link`},
+        content);
+
+    const actionsWhenCollapsed =
+      (!data.hasLongerDescription
+        ? null
+     : slots.extraReadingLinks
+        ? language.$(prefix, 'expandDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.orReadMore.expand')),
+          })
+        : language.$(prefix, 'expandDescription', {
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.expand')),
+          }));
+
+    const actionsWhenExpanded =
+      (!data.hasLongerDescription
+        ? null
+      : slots.extraReadingLinks
+        ? language.$(prefix, 'collapseDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.orReadMore.collapse')),
+          })
+        : language.$(prefix, 'collapseDescription', {
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.collapse')),
+          }));
+
+    const wrapActions = (classes, children) =>
+      html.tag('p',
+        {[html.onlyIfContent]: true, class: [
+          'quick-description-actions',
+          ...classes]},
+        children);
+
+    const wrapContent = (classes, content) =>
+      html.tag('div',
+        {[html.onlyIfContent]: true, class: classes},
+        content?.slot('mode', 'multiline'));
+
+    return (
+      html.tag('div', {
+        [html.onlyIfContent]: true,
+        class: [
+          'quick-description',
+
+          data.hasLongerDescription &&
+            'collapsed',
+
+          !data.hasLongerDescription &&
+          !slots.extraReadingLinks &&
+            'has-content-only',
+
+          !data.hasDescription &&
+          slots.extraReadingLinks &&
+            'has-external-links-only',
+        ],
+      }, [
+        wrapContent(['description-content'], relations.description),
+        wrapContent(['description-content', 'short'], relations.descriptionShort),
+        wrapContent(['description-content', 'long'], relations.descriptionLong),
+
+        wrapActions([], actionsWithoutLongerDescription),
+        wrapActions(['when-collapsed'], actionsWhenCollapsed),
+        wrapActions(['when-expanded'], actionsWhenExpanded),
+      ]));
+  }
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1083d863..93334948 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -82,7 +82,7 @@ export default {
             ...artist.albumsAsCoverArtist,
             ...artist.tracksAsCoverArtist,
           ], {
-            getDate: albumOrTrack => albumOrTrack.coverArtDate,
+            getDate: thing => thing.coverArtDate ?? thing.date,
           }),
       }),
 
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6c0aeecd..006be156 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -20,8 +20,8 @@ export default {
     if (artTags) {
       data.contentWarnings =
         artTags
-          .filter(tag => tag.isContentWarning)
-          .map(tag => tag.name);
+          .filter(artTag => artTag.isContentWarning)
+          .map(artTag => artTag.name);
     } else {
       data.contentWarnings = null;
     }
diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js
new file mode 100644
index 00000000..964258e1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, artTag) => ({
+    galleryLink: relation('linkArtTagGallery', artTag),
+    infoLink: relation('linkArtTagInfo', artTag),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'artTagInfo'
+      ? relations.infoLink
+      : relations.galleryLink),
+};
diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js
new file mode 100644
index 00000000..a92b69c1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagGallery', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTagInfo.js
index 7ddb7786..409cb3c0 100644
--- a/src/content/dependencies/linkArtTag.js
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['linkThing'],
 
   relations: (relation, artTag) =>
-    ({link: relation('linkThing', 'localized.tag', artTag)}),
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 73c656e3..b1859dde 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -56,6 +56,18 @@ export default {
             : language.$('misc.external.youtube.fullAlbum')
           : language.$('misc.external.youtube')
 
+    : domain.includes('fandom.com')
+        ? domain.includes('mspaintadventures.')
+          ? data.url.match(/\/wiki\/(.+)\/?$/)
+            ? language.$('misc.external.fandom.mspaintadventures.page', {
+                page:
+                  language.sanitize(
+                    decodeURIComponent(data.url.match(/\/wiki\/(.+)\/?$/)[1])
+                      .replace(/_/g, ' ')),
+              })
+            : language.$('misc.external.fandom.mspaintadventures')
+          : language.$('misc.external.fandom')
+
     : domain.includes('soundcloud')
         ? language.$('misc.external.soundcloud')
 
diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listArtTagsByName.js
index 8571ccd0..9bec9eaa 100644
--- a/src/content/dependencies/listTagsByName.js
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -1,8 +1,8 @@
-import {stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
 import {sortAlphabetically} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
   extraDependencies: ['language', 'wikiData'],
 
   sprawl({artTagData}) {
@@ -16,7 +16,7 @@ export default {
       artTags:
         sortAlphabetically(
           artTagData
-            .filter(tag => !tag.isContentWarning)),
+            .filter(artTag => !artTag.isContentWarning)),
     };
   },
 
@@ -26,15 +26,18 @@ export default {
 
       artTagLinks:
         query.artTags
-          .map(tag => relation('linkArtTag', tag)),
+          .map(artTag => relation('linkArtTagGallery', artTag)),
     };
   },
 
   data(query) {
     return {
       counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyTaggedInThings,
+            ...artTag.directlyTaggedInThings,
+          ]).length),
     };
   },
 
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
new file mode 100644
index 00000000..9eb6f185
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -0,0 +1,54 @@
+import {stitchArrays, unique} from '#sugar';
+import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(artTag => !artTag.isContentWarning));
+
+    const counts =
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyTaggedInThings,
+          ...artTag.indirectlyTaggedInThings,
+        ]).length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
+
+  data: (query) =>
+    ({counts: query.counts}),
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    }),
+};
diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
deleted file mode 100644
index 98a50b89..00000000
--- a/src/content/dependencies/listTagsByUses.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import {stitchArrays} from '#sugar';
-import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
-
-export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({artTagData}) {
-    return {artTagData};
-  },
-
-  query({artTagData}, spec) {
-    const artTags =
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning));
-
-    const counts =
-      artTags
-        .map(tag => tag.taggedInThings.length);
-
-    filterByCount(artTags, counts);
-    sortByCount(artTags, counts, {greatestFirst: true});
-
-    return {spec, artTags, counts};
-  },
-
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
-
-      artTagLinks:
-        query.artTags
-          .map(tag => relation('linkArtTag', tag)),
-    };
-  },
-
-  data(query) {
-    return {
-      counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
-    };
-  },
-
-  generate(data, relations, {language}) {
-    return relations.page.slots({
-      type: 'rows',
-      rows:
-        stitchArrays({
-          link: relations.artTagLinks,
-          count: data.counts,
-        }).map(({link, count}) => ({
-            tag: link,
-            timesUsed: language.countTimesUsed(count, {unit: true}),
-          })),
-    });
-  },
-};
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 3c2c3521..b3aa8da0 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -108,7 +108,11 @@ export const replacerSpec = {
   },
   tag: {
     find: 'artTag',
-    link: 'tag',
+    link: 'artTag',
+  },
+  'tag-info': {
+    find: 'artTag',
+    link: 'artTagInfo',
   },
   track: {
     find: 'track',
@@ -122,6 +126,8 @@ const linkThingRelationMap = {
   albumGallery: 'linkAlbumGallery',
   artist: 'linkArtist',
   artistGallery: 'linkArtistGallery',
+  artTag: 'linkArtTagDynamically',
+  artTagInfo: 'linkArtTagInfo',
   flash: 'linkFlash',
   flashAct: 'linkFlashAct',
   groupInfo: 'linkGroup',
@@ -129,7 +135,6 @@ const linkThingRelationMap = {
   listing: 'linkListing',
   newsEntry: 'linkNewsEntry',
   staticPage: 'linkStaticPage',
-  tag: 'linkArtTag',
   track: 'linkTrack',
 };
 
@@ -450,7 +455,10 @@ export default {
     // In inline mode, no further processing is needed!
 
     if (slots.mode === 'inline') {
-      return html.tags(contentFromNodes.map(node => node.data));
+      return (
+        html.tags(
+          contentFromNodes.map(node => node.data),
+          {[html.joinChildren]: ''}));
     }
 
     // Multiline mode has a secondary processing stage where it's passed...