« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js129
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js147
-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.js11
-rw-r--r--src/content/dependencies/generateQuickDescription.js41
-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/listTagsByName.js15
-rw-r--r--src/content/dependencies/listTagsByUses.js49
-rw-r--r--src/content/dependencies/transformContent.js9
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutDependency.js2
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js2
-rw-r--r--src/data/composite/things/art-tag/index.js2
-rw-r--r--src/data/composite/things/art-tag/withAllDescendantArtTags.js45
-rw-r--r--src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js46
-rw-r--r--src/data/things/art-tag.js66
-rw-r--r--src/data/things/language.js1
-rw-r--r--src/data/yaml.js10
-rw-r--r--src/find.js10
-rw-r--r--src/page/art-tag.js (renamed from src/page/tag.js)14
-rw-r--r--src/page/index.js2
-rw-r--r--src/static/site4.css38
-rw-r--r--src/strings-default.json37
-rw-r--r--src/url-spec.js5
-rw-r--r--src/util/wiki-data.js34
-rw-r--r--tap-snapshots/test/snapshot/linkExternal.js.test.cjs9
-rw-r--r--test/snapshot/linkExternal.js12
-rw-r--r--test/snapshot/linkThing.js2
38 files changed, 1108 insertions, 119 deletions
diff --git a/package.json b/package.json
index 16261f9..260037e 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
         "#composite/wiki-data": "./src/data/composite/wiki-data/index.js",
         "#composite/wiki-properties": "./src/data/composite/wiki-properties/index.js",
         "#composite/things/album": "./src/data/composite/things/album/index.js",
+        "#composite/things/art-tag": "./src/data/composite/things/art-tag/index.js",
         "#composite/things/track": "./src/data/composite/things/track/index.js",
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 0000000..34a45ff
--- /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 c04bfb6..4304b95 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',
+    'linkArtTagInfo',
+    'linkArtTagGallery',
     'linkTrack',
   ],
 
@@ -19,61 +22,91 @@ 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.infoPageLink =
+      relation('linkArtTagInfo', artTag);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    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 +114,7 @@ export default {
     return relations.layout
       .slots({
         title:
-          language.$('tagPage.title', {
+          language.$('artTagGalleryPage.title', {
             tag: data.name,
           }),
 
@@ -91,44 +124,66 @@ export default {
 
         mainClasses: ['top-index'],
         mainContent: [
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
-            })),
+          relations.quickDescription
+            .slot('infoPageLink', relations.infoPageLink),
+
+          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 0000000..056d974
--- /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 0000000..34f95f6
--- /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 0000000..f6787a8
--- /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 aa6efe5..adfee1d 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 aeba97d..31a4a6b 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 5636e4f..6ac0f94 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 47239f5..49869c7 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -13,8 +13,10 @@ export default {
     'generateGroupNavLinks',
     'generateGroupSidebar',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
     'linkAlbum',
+    'linkGroup',
     'linkListing',
   ],
 
@@ -70,6 +72,12 @@ export default {
           .map(album => relation('image', album.artTags));
     }
 
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
+    relations.quickDescriptionInfoLink =
+      relation('linkGroup', group);
+
     relations.coverGrid =
       relation('generateCoverGrid');
 
@@ -143,6 +151,9 @@ export default {
                     image.slot('path', path)),
             }),
 
+          relations.quickDescription
+            .slot('infoPageLink', relations.quickDescriptionInfoLink),
+
           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 0000000..136769a
--- /dev/null
+++ b/src/content/dependencies/generateQuickDescription.js
@@ -0,0 +1,41 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, thing) =>
+    ({description:
+        (thing.descriptionShort || thing.description
+          ? relation('transformContent',
+              thing.descriptionShort ?? thing.description)
+          : null)}),
+
+  data: (thing) =>
+    ({hasLongerDescription:
+        thing.description &&
+        thing.descriptionShort &&
+        thing.descriptionShort !== thing.description}),
+
+  slots: {
+    infoPageLink: {type: 'html'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    return html.tag('p',
+      {
+        [html.joinChildren]: html.tag('br'),
+        [html.onlyIfContent]: true,
+        class:' quick-info',
+      },
+      [
+        relations.description?.slot('mode', 'inline'),
+
+        data.hasLongerDescription &&
+        slots.infoPageLink &&
+          language.$('misc.quickDescription.moreInfo', {
+            link:
+              slots.infoPageLink
+                .slot('content', language.$('misc.quickDescription.moreInfo.link')),
+          }),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1083d86..9333494 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 6c0aeec..006be15 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 0000000..964258e
--- /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 0000000..a92b69c
--- /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 7ddb778..409cb3c 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 73c656e..b1859dd 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/listTagsByName.js
index 8571ccd..9bec9ea 100644
--- a/src/content/dependencies/listTagsByName.js
+++ b/src/content/dependencies/listTagsByName.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/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
index 98a50b8..9eb6f18 100644
--- a/src/content/dependencies/listTagsByUses.js
+++ b/src/content/dependencies/listTagsByUses.js
@@ -1,23 +1,25 @@
-import {stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
 import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({artTagData}) {
-    return {artTagData};
-  },
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
 
   query({artTagData}, spec) {
     const artTags =
       sortAlphabetically(
         artTagData
-          .filter(tag => !tag.isContentWarning));
+          .filter(artTag => !artTag.isContentWarning));
 
     const counts =
-      artTags
-        .map(tag => tag.taggedInThings.length);
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyTaggedInThings,
+          ...artTag.indirectlyTaggedInThings,
+        ]).length);
 
     filterByCount(artTags, counts);
     sortByCount(artTags, counts, {greatestFirst: true});
@@ -25,26 +27,20 @@ export default {
     return {spec, artTags, counts};
   },
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      artTagLinks:
-        query.artTags
-          .map(tag => relation('linkArtTag', tag)),
-    };
-  },
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
 
-  data(query) {
-    return {
-      counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
-    };
-  },
+  data: (query) =>
+    ({counts: query.counts}),
 
-  generate(data, relations, {language}) {
-    return relations.page.slots({
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
       type: 'rows',
       rows:
         stitchArrays({
@@ -54,6 +50,5 @@ export default {
             tag: link,
             timesUsed: language.countTimesUsed(count, {unit: true}),
           })),
-    });
-  },
+    }),
 };
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 9a5ac45..9c1f952 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -104,7 +104,11 @@ export const replacerSpec = {
   },
   tag: {
     find: 'artTag',
-    link: 'tag',
+    link: 'artTag',
+  },
+  'tag-info': {
+    find: 'artTag',
+    link: 'artTagInfo',
   },
   track: {
     find: 'track',
@@ -118,13 +122,14 @@ const linkThingRelationMap = {
   albumGallery: 'linkAlbumGallery',
   artist: 'linkArtist',
   artistGallery: 'linkArtistGallery',
+  artTag: 'linkArtTagDynamically',
+  artTagInfo: 'linkArtTagInfo',
   flash: 'linkFlash',
   groupInfo: 'linkGroup',
   groupGallery: 'linkGroupGallery',
   listing: 'linkListing',
   newsEntry: 'linkNewsEntry',
   staticPage: 'linkStaticPage',
-  tag: 'linkArtTag',
   track: 'linkTrack',
 };
 
diff --git a/src/data/composite/control-flow/raiseOutputWithoutDependency.js b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
index 03d8036..3d04f8a 100644
--- a/src/data/composite/control-flow/raiseOutputWithoutDependency.js
+++ b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
@@ -17,7 +17,7 @@ export default templateCompositeFrom({
 
   outputs: ({
     [input.staticValue('output')]: output,
-  }) => Object.keys(output),
+  }) => Object.keys(output ?? {}),
 
   steps: () => [
     withResultOfAvailabilityCheck({
diff --git a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
index 3c39f5b..ffa83a9 100644
--- a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
+++ b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
@@ -16,7 +16,7 @@ export default templateCompositeFrom({
 
   outputs: ({
     [input.staticValue('output')]: output,
-  }) => Object.keys(output),
+  }) => Object.keys(output ?? {}),
 
   steps: () => [
     withResultOfAvailabilityCheck({
diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js
new file mode 100644
index 0000000..bbd3829
--- /dev/null
+++ b/src/data/composite/things/art-tag/index.js
@@ -0,0 +1,2 @@
+export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js';
+export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js';
diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
new file mode 100644
index 0000000..c643cf2
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
@@ -0,0 +1,45 @@
+// Gets all the art tags which descend from this one - that means its own direct
+// descendants, but also all the direct and indirect desceands of each of those!
+// The results aren't specially sorted, but they won't contain any duplicates
+// (for example if two descendant tags both route deeper to end up including
+// some of the same tags).
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {unique} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAllDescendantArtTags`,
+
+  outputs: ['#allDescendantArtTags'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'directDescendantArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#allDescendantArtTags': []})
+    }),
+
+    withResolvedReferenceList({
+      list: 'directDescendantArtTags',
+      data: 'artTagData',
+      find: input.value(find.artTag),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: directDescendantArtTags,
+      }) => continuation({
+        ['#allDescendantArtTags']:
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      }),
+    },
+  ],
+})
diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
new file mode 100644
index 0000000..d5caa99
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
@@ -0,0 +1,46 @@
+// Gets all the art tags which are ancestors of this one as a "baobab tree" -
+// what you'd typically think of as roots are all up in the air! Since this
+// really is backwards from the way that the art tag tree is written in data,
+// chances are pretty good that there will be many of the exact same "leaf"
+// nodes - art tags which don't themselves have any ancestors. In the actual
+// data structure, each node is a Map, with keys for each ancestor and values
+// for each ancestor's own baobab (thus a branching structure, just like normal
+// trees in this regard).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAncestorArtTagBaobabTree`,
+
+  outputs: ['#ancestorArtTagBaobabTree'],
+
+  steps: () => [
+    withReverseReferenceList({
+      data: 'artTagData',
+      list: input.value('directDescendantArtTags'),
+    }).outputs({
+      ['#reverseReferenceList']: '#directAncestorArtTags',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directAncestorArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#ancestorArtTagBaobabTree': {}})
+    }),
+
+    {
+      dependencies: ['#directAncestorArtTags'],
+      compute: (continuation, {
+        ['#directAncestorArtTags']: directAncestorArtTags,
+      }) => continuation({
+        ['#ancestorArtTagBaobabTree']:
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      }),
+    },
+  ],
+});
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 1266a4e..a530ba8 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,17 +1,27 @@
 import {input} from '#composite';
-import {sortAlbumsTracksChronologically} from '#wiki-data';
+import find from '#find';
+import {unique} from '#sugar';
 import {isName} from '#validators';
+import {sortAlbumsTracksChronologically} from '#wiki-data';
 
-import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
 
 import {
   color,
   directory,
   flag,
+  referenceList,
+  reverseReferenceList,
+  simpleString,
   name,
+  urls,
   wikiData,
 } from '#composite/wiki-properties';
 
+import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
+  from '#composite/things/art-tag';
+
 import Thing from './thing.js';
 
 export class ArtTag extends Thing {
@@ -24,6 +34,7 @@ export class ArtTag extends Thing {
     directory: directory(),
     color: color(),
     isContentWarning: flag(false),
+    extraReadingURLs: urls(),
 
     nameShort: [
       exposeUpdateValueOrContinue({
@@ -37,14 +48,36 @@ export class ArtTag extends Thing {
       },
     ],
 
+    description: simpleString(),
+
+    directDescendantArtTags: referenceList({
+      class: input.value(ArtTag),
+      find: input.value(find.artTag),
+      data: 'artTagData',
+    }),
+
     // Update only
 
     albumData: wikiData(Album),
+    artTagData: wikiData(ArtTag),
     trackData: wikiData(Track),
 
     // Expose only
 
-    taggedInThings: {
+    descriptionShort: [
+      exitWithoutDependency({
+        dependency: 'description',
+        mode: input.value('falsy'),
+      }),
+
+      {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          description.split('<hr class="split">')[0],
+      },
+    ],
+
+    directlyTaggedInThings: {
       flags: {expose: true},
 
       expose: {
@@ -56,5 +89,32 @@ export class ArtTag extends Thing {
             {getDate: o => o.coverArtDate}),
       },
     },
+
+    indirectlyTaggedInThings: [
+      withAllDescendantArtTags(),
+
+      {
+        dependencies: ['#allDescendantArtTags'],
+        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+          unique(
+            allDescendantArtTags
+              .flatMap(artTag => artTag.directlyTaggedInThings)),
+      },
+    ],
+
+    allDescendantArtTags: [
+      withAllDescendantArtTags(),
+      exposeDependency({dependency: '#allDescendantArtTags'}),
+    ],
+
+    directAncestorArtTags: reverseReferenceList({
+      data: 'artTagData',
+      list: input.value('directDescendantArtTags'),
+    }),
+
+    ancestorArtTagBaobabTree: [
+      withAncestorArtTagBaobabTree(),
+      exposeDependency({dependency: '#ancestorArtTagBaobabTree'}),
+    ],
   });
 }
diff --git a/src/data/things/language.js b/src/data/things/language.js
index fe74f7b..cd719d0 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -376,6 +376,7 @@ Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
   countArtworks: countHelper('artworks'),
+  countArtTags: countHelper('artTags', 'tags'),
   countFlashes: countHelper('flashes'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index c799be5..0946564 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -459,9 +459,13 @@ export const processArtTagDocument = makeProcessDocument(T.ArtTag, {
     name: 'Tag',
     nameShort: 'Short Name',
     directory: 'Directory',
+    description: 'Description',
+    extraReadingURLs: 'Extra Reading URLs',
 
     color: 'Color',
     isContentWarning: 'Is CW',
+
+    directDescendantArtTags: 'Direct Descendant Tags',
   },
 });
 
@@ -1359,7 +1363,7 @@ export function linkWikiDataArrays(wikiData, {
   assignWikiData(WD.groupCategoryData, 'groupData');
   assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData');
   assignWikiData(WD.flashActData, 'flashData');
-  assignWikiData(WD.artTagData, 'albumData', 'trackData');
+  assignWikiData(WD.artTagData, 'albumData', 'artTagData', 'trackData');
   assignWikiData(WD.homepageLayout?.rows, 'albumData', 'groupData');
 }
 
@@ -1477,6 +1481,10 @@ export function filterReferenceErrors(wikiData) {
       artTags: 'artTag',
     }],
 
+    ['artTagData', processArtTagDocument, {
+      directDescendantArtTags: 'artTag',
+    }],
+
     ['trackData', processTrackDocument, {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
diff --git a/src/find.js b/src/find.js
index c8edce9..141dd59 100644
--- a/src/find.js
+++ b/src/find.js
@@ -69,7 +69,7 @@ function findHelper({
   // errors for null matches (with details about the error), while 'warn' and
   // 'quiet' both return null, with 'warn' logging details directly to the
   // console.
-  return (fullRef, data, {mode = 'warn'}) => {
+  return (fullRef, data, {mode = 'warn'} = {}) => {
     if (!fullRef) return null;
     if (typeof fullRef !== 'string') {
       throw new TypeError(`Expected a string, got ${typeAppearance(fullRef)}`);
@@ -134,10 +134,10 @@ const find = {
   artTag: findHelper({
     referenceTypes: ['tag'],
 
-    getMatchableNames: tag =>
-      (tag.isContentWarning
-        ? [`cw: ${tag.name}`]
-        : [tag.name]),
+    getMatchableNames: artTag =>
+      (artTag.isContentWarning
+        ? [`cw: ${artTag.name}`]
+        : [artTag.name]),
   }),
 
   flash: findHelper({
diff --git a/src/page/tag.js b/src/page/art-tag.js
index 8942aea..5b61229 100644
--- a/src/page/tag.js
+++ b/src/page/art-tag.js
@@ -1,6 +1,6 @@
 // Art tag page specification.
 
-export const description = `per-artwork-tag gallery pages`;
+export const description = `per-art-tag info & gallery pages`;
 
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableArtTagUI;
@@ -14,7 +14,17 @@ export function pathsForTarget(tag) {
   return [
     {
       type: 'page',
-      path: ['tag', tag.directory],
+      path: ['artTagInfo', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagInfoPage',
+        args: [tag],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['artTagGallery', tag.directory],
 
       contentFunction: {
         name: 'generateArtTagGalleryPage',
diff --git a/src/page/index.js b/src/page/index.js
index 48e22d2..557384a 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,11 +1,11 @@
 export * as album from './album.js';
 export * as artist from './artist.js';
 export * as artistAlias from './artist-alias.js';
+export * as artTag from './art-tag.js';
 export * as flash from './flash.js';
 export * as group from './group.js';
 export * as homepage from './homepage.js';
 export * as listing from './listing.js';
 export * as news from './news.js';
 export * as static from './static.js';
-export * as tag from './tag.js';
 export * as track from './track.js';
diff --git a/src/static/site4.css b/src/static/site4.css
index ab17bf0..65df265 100644
--- a/src/static/site4.css
+++ b/src/static/site4.css
@@ -296,6 +296,11 @@ body::before {
   margin: 0;
 }
 
+.sidebar h2:first-child {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+}
+
 .sidebar h3 {
   font-size: 1.1em;
   font-style: oblique;
@@ -347,6 +352,36 @@ body::before {
   padding-left: 10px;
 }
 
+.sidebar details.has-tree-list[open] summary {
+  font-weight: 800;
+}
+
+.sidebar dl.tree-list {
+  margin-top: 0.25em;
+  line-height: 1.25em;
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dt {
+  display: list-item;
+  list-style-type: disc;
+  padding-left: 0;
+  margin-left: 20px;
+}
+
+.sidebar dl.tree-list dl {
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dd {
+  margin-left: 0;
+}
+
+.sidebar dl.tree-list dt.current a {
+  font-weight: 800;
+  border-bottom: 1px solid;
+}
+
 .sidebar li.current {
   font-weight: 800;
 }
@@ -612,6 +647,9 @@ html[data-url-key="localized.home"] #content h1 {
 
 .quick-info {
   text-align: center;
+  padding-left: 12%;
+  padding-right: 12%;
+  line-height: 1.25em;
 }
 
 ul.quick-info {
diff --git a/src/strings-default.json b/src/strings-default.json
index 0ad7a51..a955eef 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -29,6 +29,13 @@
   "count.artworks.withUnit.few": "",
   "count.artworks.withUnit.many": "",
   "count.artworks.withUnit.other": "{ARTWORKS} artworks",
+  "count.artTags": "{TAGS}",
+  "count.artTags.withUnit.zero": "",
+  "count.artTags.withUnit.one": "{TAGS} tag",
+  "count.artTags.withUnit.two": "",
+  "count.artTags.withUnit.few": "",
+  "count.artTags.withUnit.many": "",
+  "count.artTags.withUnit.other": "{TAGS} tags",
   "count.commentaryEntries": "{ENTRIES}",
   "count.commentaryEntries.withUnit.zero": "",
   "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
@@ -179,6 +186,9 @@
   "misc.external.bandcamp": "Bandcamp",
   "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
   "misc.external.deviantart": "DeviantArt",
+  "misc.external.fandom": "Fandom",
+  "misc.external.fandom.mspaintadventures": "MSPA Wiki",
+  "misc.external.fandom.mspaintadventures.page": "MSPA Wiki ({PAGE})",
   "misc.external.instagram": "Instagram",
   "misc.external.mastodon": "Mastodon",
   "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
@@ -205,6 +215,8 @@
   "misc.nav.gallery": "Gallery",
   "misc.pageTitle": "{TITLE}",
   "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
+  "misc.quickDescription.moreInfo": "({LINK})",
+  "misc.quickDescription.moreInfo.link": "More info...",
   "misc.skippers.skipTo": "Skip to:",
   "misc.skippers.content": "Content",
   "misc.skippers.sidebar": "Sidebar",
@@ -315,6 +327,28 @@
   "artistPage.nav.artist": "Artist: {ARTIST}",
   "artistGalleryPage.title": "{ARTIST} - Gallery",
   "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
+  "artTagPage.nav.tag": "Tag: {TAG}",
+  "artTagInfoPage.title": "{TAG}",
+  "artTagInfoPage.viewArtGallery": "View this tag's {LINK}!",
+  "artTagInfoPage.viewArtGallery.link": "art gallery",
+  "artTagInfoPage.readMoreOn": "Read more about {TAG} on {LINKS}.",
+  "artTagInfoPage.featuredIn.notFeatured": "This tag hasn't been featured in any artworks yet.",
+  "artTagInfoPage.featuredIn.directlyOnly": "This tag is featured in {ARTWORKS}.",
+  "artTagInfoPage.featuredIn.indirectlyOnly": "This tag is featured in {ARTWORKS}, but only indirectly - have a look at its descendant tags!",
+  "artTagInfoPage.featuredIn.directlyAndIndirectly": "This tag is directly featured in {ARTWORKS_DIRECTLY}, and indirectly in {ARTWORKS_INDIRECTLY} more, for a total of {ARTWORKS_TOTAL}.",
+  "artTagInfoPage.descendsFromTags": "Tags that {TAG} descends from:",
+  "artTagInfoPage.descendsFromTags.item": "{TAG}",
+  "artTagInfoPage.descendantTags": "Tags that descend from {TAG}:",
+  "artTagInfoPage.descendantTags.item": "{TAG}",
+  "artTagInfoPage.descendantTags.item.withGallery": "{TAG} ({GALLERY})",
+  "artTagInfoPage.descendantTags.item.gallery": "Gallery",
+  "artTagGalleryPage.title": "{TAG}",
+  "artTagGalleryPage.infoLine": "Featured in {COVER_ARTS}.",
+  "artTagGalleryPage.infoLine.notFeatured": "This tag hasn't been featured in any artworks yet.",
+  "artTagGalleryPage.infoLine.callToAction": "Maybe your track will be the first!",
+  "artTagGalleryPage.descendsFrom": "Descends from {TAGS}.",
+  "artTagGalleryPage.desendants": "Direct descendants: {TAGS}.",
+  "artTagSidebar.otherTagsExempt": "(…another {TAGS}…)",
   "commentaryIndex.title": "Commentary",
   "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
   "commentaryIndex.albumList.title": "Choose an album:",
@@ -495,9 +529,6 @@
   "newsEntryPage.published": "(Published {DATE}.)",
   "redirectPage.title": "Moved to {TITLE}",
   "redirectPage.infoLine": "This page has been moved to {TARGET}.",
-  "tagPage.title": "{TAG}",
-  "tagPage.infoLine": "Appears in {COVER_ARTS}.",
-  "tagPage.nav.tag": "Tag: {TAG}",
   "trackPage.title": "{TRACK}",
   "trackPage.referenceList.fandom": "Fandom:",
   "trackPage.referenceList.official": "Official:",
diff --git a/src/url-spec.js b/src/url-spec.js
index 4d10313..e4c0c65 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -47,7 +47,10 @@ const urlSpec = {
       newsEntry: 'news/<>/',
 
       staticPage: '<>/',
-      tag: 'tag/<>/',
+
+      artTagInfo: 'tag/<>/info/',
+      artTagGallery: 'tag/<>/',
+
       track: 'track/<>/',
     },
   },
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index ac652b2..a85dd9a 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -97,6 +97,38 @@ export function chunkMultipleArrays(...args) {
   return results;
 }
 
+// This (or its helper function) should probably be a generator, but generators
+// are scary... Note that the root node is never considered a leaf, even if it
+// doesn't have any branches. It does NOT pay attention to the *values* of the
+// leaf nodes - it's suited to handle this kind of form:
+//
+//   {
+//     foo: {
+//       bar: {},
+//       baz: {},
+//       qux: {
+//         woz: {},
+//       },
+//     },
+//   }
+//
+// for which it outputs ['bar', 'baz', 'woz'].
+//
+export function collectTreeLeaves(tree) {
+  const recursive = ([key, value]) =>
+    (value instanceof Map
+      ? (value.size === 0
+          ? [key]
+          : Array.from(value.entries()).flatMap(recursive))
+      : (empty(Object.keys(value))
+          ? [key]
+          : Object.entries(value).flatMap(recursive)));
+
+  const root = Symbol();
+  const leaves = recursive([root, tree]);
+  return (leaves[0] === root ? [] : leaves);
+}
+
 // 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
@@ -871,7 +903,7 @@ export function filterItemsForCarousel(items) {
 
   return items
     .filter(item => item.hasCoverArt)
-    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
+    .filter(item => item.artTags.every(artTag => !artTag.isContentWarning))
     .slice(0, maxCarouselLayoutItems + 1);
 }
 
diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
index cd6dca7..c3cd7ea 100644
--- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
@@ -30,6 +30,15 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom
 <a href="https://youtube.com/Playlist?list=kweh" class="nowrap">YouTube (playlist)</a>
 `
 
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > fandom page match 1`] = `
+<a href="https://community.fandom.com/" class="nowrap">Fandom</a>
+<a href="https://community.fandom.com/wiki/" class="nowrap">Fandom</a>
+<a href="https://community.fandom.com/wiki/Community_Central" class="nowrap">Fandom</a>
+<a href="https://mspaintadventures.fandom.com/" class="nowrap">MSPA Wiki</a>
+<a href="https://mspaintadventures.fandom.com/wiki/" class="nowrap">MSPA Wiki</a>
+<a href="https://mspaintadventures.fandom.com/wiki/Draconian_Dignitary" class="nowrap">MSPA Wiki (Draconian Dignitary)</a>
+`
+
 exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > missing domain (arbitrary local path) 1`] = `
 <a href="/foo/bar/baz.mp3" class="nowrap">Wiki Archive (local upload)</a>
 `
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
index 3e8aee0..8942458 100644
--- a/test/snapshot/linkExternal.js
+++ b/test/snapshot/linkExternal.js
@@ -51,4 +51,16 @@ testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => {
       {args: ['https://types.pl/']},
     ],
   });
+
+  evaluate.snapshot('fandom page match', {
+    name: 'linkExternal',
+    multiple: [
+      {args: ['https://community.fandom.com/']},
+      {args: ['https://community.fandom.com/wiki/']},
+      {args: ['https://community.fandom.com/wiki/Community_Central']},
+      {args: ['https://mspaintadventures.fandom.com/']},
+      {args: ['https://mspaintadventures.fandom.com/wiki/']},
+      {args: ['https://mspaintadventures.fandom.com/wiki/Draconian_Dignitary']},
+    ],
+  });
 });
diff --git a/test/snapshot/linkThing.js b/test/snapshot/linkThing.js
index 195d8c0..a55b858 100644
--- a/test/snapshot/linkThing.js
+++ b/test/snapshot/linkThing.js
@@ -20,7 +20,7 @@ testContentFunctions(t, 'linkThing (snapshot)', async (t, evaluate) => {
   });
 
   quickSnapshot('preferShortName', {
-    args: ['localized.tag', {
+    args: ['localized.artTagGallery', {
       directory: 'five-oceanfalls',
       name: 'Five (Oceanfalls)',
       nameShort: 'Five',