« get me outta code hell

content: stub artist page - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-06-02 10:10:31 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-06-02 10:14:13 -0300
commit8c0e7e47cc0a44b8c37bda8b85029420a99d0098 (patch)
tree470ab36abb23138dba5ea1c33234825cfeba4cd4
parentc07c8f14e4bf7e50528750434b72fa579aa22311 (diff)
content: stub artist page
Only nav implemented so far.
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js712
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js94
-rw-r--r--src/page/artist.js669
-rw-r--r--src/page/index.js2
4 files changed, 818 insertions, 659 deletions
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
new file mode 100644
index 00000000..58b5c37c
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,712 @@
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generatePageLayout',
+  ],
+
+  relations(relation, artist) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    return relations;
+  },
+
+  data(artist) {
+    return {
+      name: artist.name,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        styleRules: [data.stylesheet].filter(Boolean),
+
+        mainClasses: ['long-content'],
+        mainContent: [
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      });
+  },
+};
+
+/*
+
+export function write(artist, {wikiData}) {
+  const {groupData, wikiInfo} = wikiData;
+
+  const {name, urls, contextNotes} = artist;
+
+  const artThingsAll = sortAlbumsTracksChronologically(
+    unique([
+      ...(artist.albumsAsCoverArtist ?? []),
+      ...(artist.albumsAsWallpaperArtist ?? []),
+      ...(artist.albumsAsBannerArtist ?? []),
+      ...(artist.tracksAsCoverArtist ?? []),
+    ]),
+    {getDate: (o) => o.coverArtDate});
+
+  const artThingsGallery = sortAlbumsTracksChronologically(
+    [
+      ...(artist.albumsAsCoverArtist ?? []),
+      ...(artist.tracksAsCoverArtist ?? []),
+    ],
+    {getDate: (o) => o.coverArtDate});
+
+  const commentaryThings = sortAlbumsTracksChronologically([
+    ...(artist.albumsAsCommentator ?? []),
+    ...(artist.tracksAsCommentator ?? []),
+  ]);
+
+  const hasGallery = !empty(artThingsGallery);
+
+  const getArtistsAndContrib = (thing, key) => ({
+    artists: thing[key]?.filter(({who}) => who !== artist),
+    contrib: thing[key]?.find(({who}) => who === artist),
+    thing,
+    key,
+  });
+
+  const artListChunks = chunkByProperties(
+    artThingsAll.flatMap((thing) =>
+      ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
+        .map((key) => getArtistsAndContrib(thing, key))
+        .filter(({contrib}) => contrib)
+        .map((props) => ({
+          album: thing.album || thing,
+          track: thing.album ? thing : null,
+          date: thing.date,
+          ...props,
+        }))),
+    ['date', 'album']);
+
+  const commentaryListChunks = chunkByProperties(
+    commentaryThings.map((thing) => ({
+      album: thing.album || thing,
+      track: thing.album ? thing : null,
+    })),
+    ['album']);
+
+  const allTracks = sortAlbumsTracksChronologically(
+    unique([
+      ...(artist.tracksAsArtist ?? []),
+      ...(artist.tracksAsContributor ?? []),
+    ]));
+
+  const chunkTracks = (tracks) =>
+    chunkByProperties(
+      tracks.map((track) => ({
+        track,
+        date: +track.date,
+        album: track.album,
+        duration: track.duration,
+        originalReleaseTrack: track.originalReleaseTrack,
+        artists: track.artistContribs.some(({who}) => who === artist)
+          ? track.artistContribs.filter(({who}) => who !== artist)
+          : track.contributorContribs.filter(({who}) => who !== artist),
+        contrib: {
+          who: artist,
+          whatArray: [
+            track.artistContribs.find(({who}) => who === artist)?.what,
+            track.contributorContribs.find(({who}) => who === artist)?.what,
+          ].filter(Boolean),
+        },
+      })),
+      ['date', 'album'])
+    .map(({date, album, chunk}) => ({
+      date,
+      album,
+      chunk,
+      duration: getTotalDuration(chunk, {originalReleasesOnly: true}),
+    }));
+
+  const trackListChunks = chunkTracks(allTracks);
+  const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack));
+
+  const countGroups = (things) => {
+    const usedGroups = things.flatMap(
+      (thing) => thing.groups || thing.album?.groups || []);
+    return groupData
+      .map((group) => ({
+        group,
+        contributions: usedGroups.filter(g => g === group).length,
+      }))
+      .filter(({contributions}) => contributions > 0)
+      .sort((a, b) => b.contributions - a.contributions);
+  };
+
+  const musicGroups = countGroups(allTracks);
+  const artGroups = countGroups(artThingsAll);
+
+  let flashes, flashListChunks;
+  if (wikiInfo.enableFlashesAndGames) {
+    flashes = sortChronologically(artist.flashesAsContributor.slice());
+    flashListChunks = chunkByProperties(
+      flashes.map((flash) => ({
+        act: flash.act,
+        flash,
+        date: flash.date,
+        // Manual artists/contrib properties here, 8ecause we don't
+        // want to show the full list of other contri8utors inline.
+        // (It can often 8e very, very large!)
+        artists: [],
+        contrib: flash.contributorContribs.find(({who}) => who === artist),
+      })),
+      ['act']
+    ).map(({act, chunk}) => ({
+      act,
+      chunk,
+      dateFirst: chunk[0].date,
+      dateLast: chunk[chunk.length - 1].date,
+    }));
+  }
+
+  const generateEntryAccents = ({
+    getArtistString,
+    language,
+    original,
+    entry,
+    artists,
+    contrib,
+  }) =>
+    original
+      ? language.$('artistPage.creditList.entry.rerelease', {entry})
+      : !empty(artists)
+      ? contrib.what || contrib.whatArray?.length
+        ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
+            entry,
+            artists: getArtistString(artists),
+            contribution: contrib.whatArray
+              ? language.formatUnitList(contrib.whatArray)
+              : contrib.what,
+          })
+        : language.$('artistPage.creditList.entry.withArtists', {
+            entry,
+            artists: getArtistString(artists),
+          })
+      : contrib.what || contrib.whatArray?.length
+      ? language.$('artistPage.creditList.entry.withContribution', {
+          entry,
+          contribution: contrib.whatArray
+            ? language.formatUnitList(contrib.whatArray)
+            : contrib.what,
+        })
+      : entry;
+
+  const unbound_generateTrackList = (chunks, {
+    getArtistString,
+    html,
+    language,
+    link,
+  }) =>
+    html.tag('dl',
+      chunks.flatMap(({date, album, chunk, duration}) => [
+        html.tag('dt',
+          date && duration ?
+            language.$('artistPage.creditList.album.withDate.withDuration', {
+              album: link.album(album),
+              date: language.formatDate(date),
+              duration: language.formatDuration(duration, {
+                approximate: true,
+              }),
+            }) :
+
+          date ?
+            language.$('artistPage.creditList.album.withDate', {
+              album: link.album(album),
+              date: language.formatDate(date),
+            }) :
+
+          duration ?
+            language.$('artistPage.creditList.album.withDuration', {
+              album: link.album(album),
+              duration: language.formatDuration(duration, {
+                approximate: true,
+              }),
+            }) :
+
+          language.$('artistPage.creditList.album', {
+            album: link.album(album),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            chunk
+              .map(({track, ...props}) => ({
+                original: track.originalReleaseTrack,
+                entry: language.$('artistPage.creditList.entry.track.withDuration', {
+                  track: link.track(track),
+                  duration: language.formatDuration(track.duration ?? 0),
+                }),
+                ...props,
+              }))
+              .map(({original, ...opts}) =>
+                html.tag('li',
+                  {class: original && 'rerelease'},
+                  generateEntryAccents({
+                    getArtistString,
+                    language,
+                    original,
+                    ...opts,
+                  })
+                )
+              ))),
+      ]));
+
+  const unbound_serializeArtistsAndContrib =
+    (key, {serializeContribs, serializeLink}) =>
+    (thing) => {
+      const {artists, contrib} = getArtistsAndContrib(thing, key);
+      const ret = {};
+      ret.link = serializeLink(thing);
+      if (contrib.what) ret.contribution = contrib.what;
+      if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
+      return ret;
+    };
+
+  const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
+    chunks.map(({date, album, chunk, duration}) => ({
+      album: serializeLink(album),
+      date,
+      duration,
+      tracks: chunk.map(({track}) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }));
+
+  const jumpTo = {
+    tracks: !empty(allTracks),
+    art: !empty(artThingsAll),
+    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
+    commentary: !empty(commentaryThings),
+  };
+
+  const showJumpTo = Object.values(jumpTo).includes(true);
+
+  const data = {
+    type: 'data',
+    path: ['artist', artist.directory],
+    data: ({serializeContribs, serializeLink}) => {
+      const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
+        serializeContribs,
+        serializeLink,
+      });
+
+      const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+        serializeLink,
+      });
+
+      return {
+        albums: {
+          asCoverArtist: artist.albumsAsCoverArtist
+            .map(serializeArtistsAndContrib('coverArtistContribs')),
+          asWallpaperArtist: artist.albumsAsWallpaperArtist
+            .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
+          asBannerArtist: artist.albumsAsBannerArtis
+            .map(serializeArtistsAndContrib('bannerArtistContribs')),
+        },
+        flashes: wikiInfo.enableFlashesAndGames
+          ? {
+              asContributor: artist.flashesAsContributor
+                .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
+                .map(({contrib, thing: flash}) => ({
+                  link: serializeLink(flash),
+                  contribution: contrib.what,
+                })),
+            }
+          : null,
+        tracks: {
+          asArtist: artist.tracksAsArtist
+            .map(serializeArtistsAndContrib('artistContribs')),
+          asContributor: artist.tracksAsContributo
+            .map(serializeArtistsAndContrib('contributorContribs')),
+          chunked: serializeTrackListChunks(trackListChunks),
+        },
+      };
+    },
+  };
+
+  const infoPage = {
+    type: 'page',
+    path: ['artist', artist.directory],
+    page: ({
+      fancifyURL,
+      generateInfoGalleryLinks,
+      getArtistAvatar,
+      getArtistString,
+      html,
+      link,
+      language,
+      transformMultiline,
+    }) => {
+      const generateTrackList = bindOpts(unbound_generateTrackList, {
+        getArtistString,
+        html,
+        language,
+        link,
+      });
+
+      return {
+        title: language.$('artistPage.title', {artist: name}),
+
+        cover: artist.hasAvatar && {
+          src: getArtistAvatar(artist),
+          alt: language.$('misc.alt.artistAvatar'),
+        },
+
+        main: {
+          headingMode: 'sticky',
+
+          content: [
+            ...html.fragment(
+              contextNotes && [
+                html.tag('p',
+                  language.$('releaseInfo.note')),
+
+                html.tag('blockquote',
+                  transformMultiline(contextNotes)),
+
+                html.tag('hr'),
+              ]),
+
+            !empty(urls) &&
+              html.tag('p',
+                language.$('releaseInfo.visitOn', {
+                  links: language.formatDisjunctionList(
+                    urls.map((url) => fancifyURL(url, {language}))
+                  ),
+                })),
+
+            hasGallery &&
+              html.tag('p',
+                language.$('artistPage.viewArtGallery', {
+                  link: link.artistGallery(artist, {
+                    text: language.$('artistPage.viewArtGallery.link'),
+                  }),
+                })),
+
+            showJumpTo &&
+              html.tag('p',
+                language.$('misc.jumpTo.withLinks', {
+                  links: language.formatUnitList(
+                    [
+                      jumpTo.tracks &&
+                        html.tag('a',
+                          {href: '#tracks'},
+                          language.$('artistPage.trackList.title')),
+
+                      jumpTo.art &&
+                        html.tag('a',
+                          {href: '#art'},
+                          language.$('artistPage.artList.title')),
+
+                      jumpTo.flashes &&
+                        html.tag('a',
+                          {href: '#flashes'},
+                          language.$('artistPage.flashList.title')),
+
+                      jumpTo.commentary &&
+                        html.tag('a',
+                          {href: '#commentary'},
+                          language.$('artistPage.commentaryList.title')),
+                    ].filter(Boolean)),
+                })),
+
+            ...html.fragment(
+              !empty(allTracks) && [
+                html.tag('h2',
+                  {id: 'tracks', class: ['content-heading']},
+                  language.$('artistPage.trackList.title')),
+
+                totalDuration > 0 &&
+                  html.tag('p',
+                    language.$('artistPage.contributedDurationLine', {
+                      artist: artist.name,
+                      duration: language.formatDuration(
+                        totalDuration,
+                        {
+                          approximate: true,
+                          unit: true,
+                        }
+                      ),
+                    })),
+
+                !empty(musicGroups) &&
+                  html.tag('p',
+                    language.$('artistPage.musicGroupsLine', {
+                      groups: language.formatUnitList(
+                        musicGroups.map(({group, contributions}) =>
+                          language.$('artistPage.groupsLine.item', {
+                            group: link.groupInfo(group),
+                            contributions:
+                              language.countContributions(
+                                contributions
+                              ),
+                          })
+                        )
+                      ),
+                    })),
+
+                generateTrackList(trackListChunks),
+              ]),
+
+            ...html.fragment(
+              !empty(artThingsAll) && [
+                html.tag('h2',
+                  {id: 'art', class: ['content-heading']},
+                  language.$('artistPage.artList.title')),
+
+                hasGallery &&
+                  html.tag('p',
+                    language.$('artistPage.viewArtGallery.orBrowseList', {
+                      link: link.artistGallery(artist, {
+                        text: language.$('artistPage.viewArtGallery.link'),
+                      })
+                    })),
+
+                !empty(artGroups) &&
+                  html.tag('p',
+                    language.$('artistPage.artGroupsLine', {
+                    groups: language.formatUnitList(
+                      artGroups.map(({group, contributions}) =>
+                        language.$('artistPage.groupsLine.item', {
+                          group: link.groupInfo(group),
+                          contributions:
+                            language.countContributions(
+                              contributions
+                            ),
+                        })
+                      )
+                    ),
+                  })),
+
+                html.tag('dl',
+                  artListChunks.flatMap(({date, album, chunk}) => [
+                    html.tag('dt', language.$('artistPage.creditList.album.withDate', {
+                      album: link.album(album),
+                      date: language.formatDate(date),
+                    })),
+
+                    html.tag('dd',
+                      html.tag('ul',
+                        chunk
+                          .map(({track, key, ...props}) => ({
+                            ...props,
+                            entry:
+                              track
+                                ? language.$('artistPage.creditList.entry.track', {
+                                    track: link.track(track),
+                                  })
+                                : html.tag('i',
+                                    language.$('artistPage.creditList.entry.album.' + {
+                                      wallpaperArtistContribs:
+                                        'wallpaperArt',
+                                      bannerArtistContribs:
+                                        'bannerArt',
+                                      coverArtistContribs:
+                                        'coverArt',
+                                    }[key])),
+                          }))
+                          .map((opts) => generateEntryAccents({
+                            getArtistString,
+                            language,
+                            ...opts,
+                          }))
+                          .map(row => html.tag('li', row)))),
+                  ])),
+              ]),
+
+            ...html.fragment(
+              wikiInfo.enableFlashesAndGames &&
+              !empty(flashes) && [
+                html.tag('h2',
+                  {id: 'flashes', class: ['content-heading']},
+                  language.$('artistPage.flashList.title')),
+
+                html.tag('dl',
+                  flashListChunks.flatMap(({
+                    act,
+                    chunk,
+                    dateFirst,
+                    dateLast,
+                  }) => [
+                    html.tag('dt',
+                      language.$('artistPage.creditList.flashAct.withDateRange', {
+                        act: link.flash(chunk[0].flash, {
+                          text: act.name,
+                        }),
+                        dateRange: language.formatDateRange(
+                          dateFirst,
+                          dateLast
+                        ),
+                      })),
+
+                    html.tag('dd',
+                      html.tag('ul',
+                        chunk
+                          .map(({flash, ...props}) => ({
+                            ...props,
+                            entry: language.$('artistPage.creditList.entry.flash', {
+                              flash: link.flash(flash),
+                            }),
+                          }))
+                          .map(opts => generateEntryAccents({
+                            getArtistString,
+                            language,
+                            ...opts,
+                          }))
+                          .map(row => html.tag('li', row)))),
+                  ])),
+              ]),
+
+            ...html.fragment(
+              !empty(commentaryThings) && [
+                html.tag('h2',
+                  {id: 'commentary', class: ['content-heading']},
+                  language.$('artistPage.commentaryList.title')),
+
+                html.tag('dl',
+                  commentaryListChunks.flatMap(({album, chunk}) => [
+                    html.tag('dt',
+                      language.$('artistPage.creditList.album', {
+                        album: link.album(album),
+                      })),
+
+                    html.tag('dd',
+                      html.tag('ul',
+                        chunk
+                          .map(({track}) => track
+                            ? language.$('artistPage.creditList.entry.track', {
+                                track: link.track(track),
+                              })
+                            : html.tag('i',
+                                language.$('artistPage.creditList.entry.album.commentary')))
+                          .map(row => html.tag('li', row)))),
+                  ])),
+              ]),
+          ],
+        },
+
+        nav: generateNavForArtist(artist, false, hasGallery, {
+          generateInfoGalleryLinks,
+          link,
+          language,
+          wikiData,
+        }),
+      };
+    },
+  };
+
+  const galleryPage = hasGallery && {
+    type: 'page',
+    path: ['artistGallery', artist.directory],
+    page: ({
+      generateInfoGalleryLinks,
+      getAlbumCover,
+      getGridHTML,
+      getTrackCover,
+      html,
+      link,
+      language,
+    }) => ({
+      title: language.$('artistGalleryPage.title', {artist: name}),
+
+      main: {
+        classes: ['top-index'],
+        headingMode: 'static',
+
+        content: [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('artistGalleryPage.infoLine', {
+              coverArts: language.countCoverArts(artThingsGallery.length, {
+                unit: true,
+              }),
+            })),
+
+          html.tag('div',
+            {class: 'grid-listing'},
+            getGridHTML({
+              entries: artThingsGallery.map((item) => ({item})),
+              srcFn: (thing) =>
+                thing.album
+                  ? getTrackCover(thing)
+                  : getAlbumCover(thing),
+              linkFn: (thing, opts) =>
+                thing.album
+                  ? link.track(thing, opts)
+                  : link.album(thing, opts),
+            })),
+        ],
+      },
+
+      nav: generateNavForArtist(artist, true, hasGallery, {
+        generateInfoGalleryLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
+
+  return [data, infoPage, galleryPage].filter(Boolean);
+}
+
+// Utility functions
+
+function generateNavForArtist(artist, isGallery, hasGallery, {
+  generateInfoGalleryLinks,
+  language,
+  link,
+  wikiData,
+}) {
+  const {wikiInfo} = wikiData;
+
+  const infoGalleryLinks =
+    hasGallery &&
+    generateInfoGalleryLinks(artist, isGallery, {
+      link,
+      language,
+      linkKeyGallery: 'artistGallery',
+      linkKeyInfo: 'artist',
+    });
+
+  return {
+    linkContainerClasses: ['nav-links-hierarchy'],
+    links: [
+      {toHome: true},
+      wikiInfo.enableListings && {
+        path: ['localized.listingIndex'],
+        title: language.$('listingIndex.title'),
+      },
+      {
+        html: language.$('artistPage.nav.artist', {
+          artist: link.artist(artist, {class: 'current'}),
+        }),
+      },
+      hasGallery && {
+        divider: false,
+        html: `(${infoGalleryLinks})`,
+      },
+    ],
+  };
+}
+
+*/
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
new file mode 100644
index 00000000..c10c02cd
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,94 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, artist) {
+    const relations = {};
+
+    relations.artistInfoLink =
+      relation('linkArtist', artist);
+
+    if (
+      !empty(artist.albumsAsCoverArtist) ||
+      !empty(artist.tracksAsCoverArtist)
+    ) {
+      relations.artistGalleryLink =
+        relation('linkArtistGallery', artist);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.template({
+      annotation: `generateArtistNav`,
+      slots: {
+        showExtraLinks: {type: 'boolean', default: false},
+
+        currentExtra: {
+          validate: v => v.is('gallery'),
+        },
+      },
+
+      content(slots) {
+        const infoLink =
+          relations.artistInfoLink?.slots({
+            attributes: {class: slots.currentExtra === null && 'current'},
+            content: language.$('misc.nav.info'),
+          });
+
+        const {content: extraLinks = []} =
+          slots.showExtraLinks &&
+            {content: [
+              relations.artistGalleryLink?.slots({
+                attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+                content: language.$('misc.nav.gallery'),
+              }),
+            ]};
+
+        const mostAccentLinks = [
+          ...extraLinks,
+        ].filter(Boolean);
+
+        // Don't show the info accent link all on its own.
+        const allAccentLinks =
+          (empty(mostAccentLinks)
+            ? []
+            : [infoLink, ...mostAccentLinks]);
+
+        const accent =
+          (empty(allAccentLinks)
+            ? html.blank()
+            : `(${language.formatUnitList(allAccentLinks)})`);
+
+        return [
+          {auto: 'home'},
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+          {auto: 'current', accent},
+        ];
+      },
+    });
+  },
+};
diff --git a/src/page/artist.js b/src/page/artist.js
index 87859c89..852310d3 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -21,663 +21,16 @@ export function targets({wikiData}) {
   return wikiData.artistData;
 }
 
-export function write(artist, {wikiData}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  const {name, urls, contextNotes} = artist;
-
-  const artThingsAll = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.albumsAsWallpaperArtist ?? []),
-      ...(artist.albumsAsBannerArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ]),
-    {getDate: (o) => o.coverArtDate});
-
-  const artThingsGallery = sortAlbumsTracksChronologically(
-    [
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ],
-    {getDate: (o) => o.coverArtDate});
-
-  const commentaryThings = sortAlbumsTracksChronologically([
-    ...(artist.albumsAsCommentator ?? []),
-    ...(artist.tracksAsCommentator ?? []),
-  ]);
-
-  const hasGallery = !empty(artThingsGallery);
-
-  const getArtistsAndContrib = (thing, key) => ({
-    artists: thing[key]?.filter(({who}) => who !== artist),
-    contrib: thing[key]?.find(({who}) => who === artist),
-    thing,
-    key,
-  });
-
-  const artListChunks = chunkByProperties(
-    artThingsAll.flatMap((thing) =>
-      ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
-        .map((key) => getArtistsAndContrib(thing, key))
-        .filter(({contrib}) => contrib)
-        .map((props) => ({
-          album: thing.album || thing,
-          track: thing.album ? thing : null,
-          date: thing.date,
-          ...props,
-        }))),
-    ['date', 'album']);
-
-  const commentaryListChunks = chunkByProperties(
-    commentaryThings.map((thing) => ({
-      album: thing.album || thing,
-      track: thing.album ? thing : null,
-    })),
-    ['album']);
-
-  const allTracks = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.tracksAsArtist ?? []),
-      ...(artist.tracksAsContributor ?? []),
-    ]));
-
-  const chunkTracks = (tracks) =>
-    chunkByProperties(
-      tracks.map((track) => ({
-        track,
-        date: +track.date,
-        album: track.album,
-        duration: track.duration,
-        originalReleaseTrack: track.originalReleaseTrack,
-        artists: track.artistContribs.some(({who}) => who === artist)
-          ? track.artistContribs.filter(({who}) => who !== artist)
-          : track.contributorContribs.filter(({who}) => who !== artist),
-        contrib: {
-          who: artist,
-          whatArray: [
-            track.artistContribs.find(({who}) => who === artist)?.what,
-            track.contributorContribs.find(({who}) => who === artist)?.what,
-          ].filter(Boolean),
-        },
-      })),
-      ['date', 'album'])
-    .map(({date, album, chunk}) => ({
-      date,
-      album,
-      chunk,
-      duration: getTotalDuration(chunk, {originalReleasesOnly: true}),
-    }));
-
-  const trackListChunks = chunkTracks(allTracks);
-  const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack));
-
-  const countGroups = (things) => {
-    const usedGroups = things.flatMap(
-      (thing) => thing.groups || thing.album?.groups || []);
-    return groupData
-      .map((group) => ({
-        group,
-        contributions: usedGroups.filter(g => g === group).length,
-      }))
-      .filter(({contributions}) => contributions > 0)
-      .sort((a, b) => b.contributions - a.contributions);
-  };
-
-  const musicGroups = countGroups(allTracks);
-  const artGroups = countGroups(artThingsAll);
-
-  let flashes, flashListChunks;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashes = sortChronologically(artist.flashesAsContributor.slice());
-    flashListChunks = chunkByProperties(
-      flashes.map((flash) => ({
-        act: flash.act,
-        flash,
-        date: flash.date,
-        // Manual artists/contrib properties here, 8ecause we don't
-        // want to show the full list of other contri8utors inline.
-        // (It can often 8e very, very large!)
-        artists: [],
-        contrib: flash.contributorContribs.find(({who}) => who === artist),
-      })),
-      ['act']
-    ).map(({act, chunk}) => ({
-      act,
-      chunk,
-      dateFirst: chunk[0].date,
-      dateLast: chunk[chunk.length - 1].date,
-    }));
-  }
-
-  const generateEntryAccents = ({
-    getArtistString,
-    language,
-    original,
-    entry,
-    artists,
-    contrib,
-  }) =>
-    original
-      ? language.$('artistPage.creditList.entry.rerelease', {entry})
-      : !empty(artists)
-      ? contrib.what || contrib.whatArray?.length
-        ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
-            entry,
-            artists: getArtistString(artists),
-            contribution: contrib.whatArray
-              ? language.formatUnitList(contrib.whatArray)
-              : contrib.what,
-          })
-        : language.$('artistPage.creditList.entry.withArtists', {
-            entry,
-            artists: getArtistString(artists),
-          })
-      : contrib.what || contrib.whatArray?.length
-      ? language.$('artistPage.creditList.entry.withContribution', {
-          entry,
-          contribution: contrib.whatArray
-            ? language.formatUnitList(contrib.whatArray)
-            : contrib.what,
-        })
-      : entry;
-
-  const unbound_generateTrackList = (chunks, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('dl',
-      chunks.flatMap(({date, album, chunk, duration}) => [
-        html.tag('dt',
-          date && duration ?
-            language.$('artistPage.creditList.album.withDate.withDuration', {
-              album: link.album(album),
-              date: language.formatDate(date),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          date ?
-            language.$('artistPage.creditList.album.withDate', {
-              album: link.album(album),
-              date: language.formatDate(date),
-            }) :
-
-          duration ?
-            language.$('artistPage.creditList.album.withDuration', {
-              album: link.album(album),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          language.$('artistPage.creditList.album', {
-            album: link.album(album),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            chunk
-              .map(({track, ...props}) => ({
-                original: track.originalReleaseTrack,
-                entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                  track: link.track(track),
-                  duration: language.formatDuration(track.duration ?? 0),
-                }),
-                ...props,
-              }))
-              .map(({original, ...opts}) =>
-                html.tag('li',
-                  {class: original && 'rerelease'},
-                  generateEntryAccents({
-                    getArtistString,
-                    language,
-                    original,
-                    ...opts,
-                  })
-                )
-              ))),
-      ]));
-
-  const unbound_serializeArtistsAndContrib =
-    (key, {serializeContribs, serializeLink}) =>
-    (thing) => {
-      const {artists, contrib} = getArtistsAndContrib(thing, key);
-      const ret = {};
-      ret.link = serializeLink(thing);
-      if (contrib.what) ret.contribution = contrib.what;
-      if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
-      return ret;
-    };
-
-  const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
-    chunks.map(({date, album, chunk, duration}) => ({
-      album: serializeLink(album),
-      date,
-      duration,
-      tracks: chunk.map(({track}) => ({
-        link: serializeLink(track),
-        duration: track.duration,
-      })),
-    }));
-
-  const jumpTo = {
-    tracks: !empty(allTracks),
-    art: !empty(artThingsAll),
-    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
-    commentary: !empty(commentaryThings),
-  };
-
-  const showJumpTo = Object.values(jumpTo).includes(true);
-
-  const data = {
-    type: 'data',
-    path: ['artist', artist.directory],
-    data: ({serializeContribs, serializeLink}) => {
-      const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
-        serializeContribs,
-        serializeLink,
-      });
-
-      const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
-        serializeLink,
-      });
-
-      return {
-        albums: {
-          asCoverArtist: artist.albumsAsCoverArtist
-            .map(serializeArtistsAndContrib('coverArtistContribs')),
-          asWallpaperArtist: artist.albumsAsWallpaperArtist
-            .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
-          asBannerArtist: artist.albumsAsBannerArtis
-            .map(serializeArtistsAndContrib('bannerArtistContribs')),
-        },
-        flashes: wikiInfo.enableFlashesAndGames
-          ? {
-              asContributor: artist.flashesAsContributor
-                .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
-                .map(({contrib, thing: flash}) => ({
-                  link: serializeLink(flash),
-                  contribution: contrib.what,
-                })),
-            }
-          : null,
-        tracks: {
-          asArtist: artist.tracksAsArtist
-            .map(serializeArtistsAndContrib('artistContribs')),
-          asContributor: artist.tracksAsContributo
-            .map(serializeArtistsAndContrib('contributorContribs')),
-          chunked: serializeTrackListChunks(trackListChunks),
-        },
-      };
-    },
-  };
-
-  const infoPage = {
-    type: 'page',
-    path: ['artist', artist.directory],
-    page: ({
-      fancifyURL,
-      generateInfoGalleryLinks,
-      getArtistAvatar,
-      getArtistString,
-      html,
-      link,
-      language,
-      transformMultiline,
-    }) => {
-      const generateTrackList = bindOpts(unbound_generateTrackList, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('artistPage.title', {artist: name}),
-
-        cover: artist.hasAvatar && {
-          src: getArtistAvatar(artist),
-          alt: language.$('misc.alt.artistAvatar'),
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            ...html.fragment(
-              contextNotes && [
-                html.tag('p',
-                  language.$('releaseInfo.note')),
-
-                html.tag('blockquote',
-                  transformMultiline(contextNotes)),
-
-                html.tag('hr'),
-              ]),
-
-            !empty(urls) &&
-              html.tag('p',
-                language.$('releaseInfo.visitOn', {
-                  links: language.formatDisjunctionList(
-                    urls.map((url) => fancifyURL(url, {language}))
-                  ),
-                })),
-
-            hasGallery &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery', {
-                  link: link.artistGallery(artist, {
-                    text: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
-
-            showJumpTo &&
-              html.tag('p',
-                language.$('misc.jumpTo.withLinks', {
-                  links: language.formatUnitList(
-                    [
-                      jumpTo.tracks &&
-                        html.tag('a',
-                          {href: '#tracks'},
-                          language.$('artistPage.trackList.title')),
-
-                      jumpTo.art &&
-                        html.tag('a',
-                          {href: '#art'},
-                          language.$('artistPage.artList.title')),
-
-                      jumpTo.flashes &&
-                        html.tag('a',
-                          {href: '#flashes'},
-                          language.$('artistPage.flashList.title')),
-
-                      jumpTo.commentary &&
-                        html.tag('a',
-                          {href: '#commentary'},
-                          language.$('artistPage.commentaryList.title')),
-                    ].filter(Boolean)),
-                })),
-
-            ...html.fragment(
-              !empty(allTracks) && [
-                html.tag('h2',
-                  {id: 'tracks', class: ['content-heading']},
-                  language.$('artistPage.trackList.title')),
-
-                totalDuration > 0 &&
-                  html.tag('p',
-                    language.$('artistPage.contributedDurationLine', {
-                      artist: artist.name,
-                      duration: language.formatDuration(
-                        totalDuration,
-                        {
-                          approximate: true,
-                          unit: true,
-                        }
-                      ),
-                    })),
-
-                !empty(musicGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.musicGroupsLine', {
-                      groups: language.formatUnitList(
-                        musicGroups.map(({group, contributions}) =>
-                          language.$('artistPage.groupsLine.item', {
-                            group: link.groupInfo(group),
-                            contributions:
-                              language.countContributions(
-                                contributions
-                              ),
-                          })
-                        )
-                      ),
-                    })),
-
-                generateTrackList(trackListChunks),
-              ]),
-
-            ...html.fragment(
-              !empty(artThingsAll) && [
-                html.tag('h2',
-                  {id: 'art', class: ['content-heading']},
-                  language.$('artistPage.artList.title')),
-
-                hasGallery &&
-                  html.tag('p',
-                    language.$('artistPage.viewArtGallery.orBrowseList', {
-                      link: link.artistGallery(artist, {
-                        text: language.$('artistPage.viewArtGallery.link'),
-                      })
-                    })),
-
-                !empty(artGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.artGroupsLine', {
-                    groups: language.formatUnitList(
-                      artGroups.map(({group, contributions}) =>
-                        language.$('artistPage.groupsLine.item', {
-                          group: link.groupInfo(group),
-                          contributions:
-                            language.countContributions(
-                              contributions
-                            ),
-                        })
-                      )
-                    ),
-                  })),
-
-                html.tag('dl',
-                  artListChunks.flatMap(({date, album, chunk}) => [
-                    html.tag('dt', language.$('artistPage.creditList.album.withDate', {
-                      album: link.album(album),
-                      date: language.formatDate(date),
-                    })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track, key, ...props}) => ({
-                            ...props,
-                            entry:
-                              track
-                                ? language.$('artistPage.creditList.entry.track', {
-                                    track: link.track(track),
-                                  })
-                                : html.tag('i',
-                                    language.$('artistPage.creditList.entry.album.' + {
-                                      wallpaperArtistContribs:
-                                        'wallpaperArt',
-                                      bannerArtistContribs:
-                                        'bannerArt',
-                                      coverArtistContribs:
-                                        'coverArt',
-                                    }[key])),
-                          }))
-                          .map((opts) => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashes) && [
-                html.tag('h2',
-                  {id: 'flashes', class: ['content-heading']},
-                  language.$('artistPage.flashList.title')),
-
-                html.tag('dl',
-                  flashListChunks.flatMap(({
-                    act,
-                    chunk,
-                    dateFirst,
-                    dateLast,
-                  }) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.flashAct.withDateRange', {
-                        act: link.flash(chunk[0].flash, {
-                          text: act.name,
-                        }),
-                        dateRange: language.formatDateRange(
-                          dateFirst,
-                          dateLast
-                        ),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({flash, ...props}) => ({
-                            ...props,
-                            entry: language.$('artistPage.creditList.entry.flash', {
-                              flash: link.flash(flash),
-                            }),
-                          }))
-                          .map(opts => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              !empty(commentaryThings) && [
-                html.tag('h2',
-                  {id: 'commentary', class: ['content-heading']},
-                  language.$('artistPage.commentaryList.title')),
-
-                html.tag('dl',
-                  commentaryListChunks.flatMap(({album, chunk}) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.album', {
-                        album: link.album(album),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track}) => track
-                            ? language.$('artistPage.creditList.entry.track', {
-                                track: link.track(track),
-                              })
-                            : html.tag('i',
-                                language.$('artistPage.creditList.entry.album.commentary')))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-          ],
-        },
-
-        nav: generateNavForArtist(artist, false, hasGallery, {
-          generateInfoGalleryLinks,
-          link,
-          language,
-          wikiData,
-        }),
-      };
-    },
-  };
-
-  const galleryPage = hasGallery && {
-    type: 'page',
-    path: ['artistGallery', artist.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      getAlbumCover,
-      getGridHTML,
-      getTrackCover,
-      html,
-      link,
-      language,
-    }) => ({
-      title: language.$('artistGalleryPage.title', {artist: name}),
-
-      main: {
-        classes: ['top-index'],
-        headingMode: 'static',
-
-        content: [
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(artThingsGallery.length, {
-                unit: true,
-              }),
-            })),
-
-          html.tag('div',
-            {class: 'grid-listing'},
-            getGridHTML({
-              entries: artThingsGallery.map((item) => ({item})),
-              srcFn: (thing) =>
-                thing.album
-                  ? getTrackCover(thing)
-                  : getAlbumCover(thing),
-              linkFn: (thing, opts) =>
-                thing.album
-                  ? link.track(thing, opts)
-                  : link.album(thing, opts),
-            })),
-        ],
-      },
-
-      nav: generateNavForArtist(artist, true, hasGallery, {
-        generateInfoGalleryLinks,
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [data, infoPage, galleryPage].filter(Boolean);
-}
-
-// Utility functions
-
-function generateNavForArtist(artist, isGallery, hasGallery, {
-  generateInfoGalleryLinks,
-  language,
-  link,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
-
-  const infoGalleryLinks =
-    hasGallery &&
-    generateInfoGalleryLinks(artist, isGallery, {
-      link,
-      language,
-      linkKeyGallery: 'artistGallery',
-      linkKeyInfo: 'artist',
-    });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
-      },
-      {
-        html: language.$('artistPage.nav.artist', {
-          artist: link.artist(artist, {class: 'current'}),
-        }),
+export function pathsForTarget(artist) {
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
       },
-      hasGallery && {
-        divider: false,
-        html: `(${infoGalleryLinks})`,
-      },
-    ],
-  };
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index dc059fdc..77ebfb6f 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -9,7 +9,7 @@
 
 export * as album from './album.js';
 // export * as albumCommentary from './album-commentary.js';
-// export * as artist from './artist.js';
+export * as artist from './artist.js';
 // export * as artistAlias from './artist-alias.js';
 // export * as flash from './flash.js';
 // export * as group from './group.js';