« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/page
diff options
context:
space:
mode:
Diffstat (limited to 'src/page')
-rw-r--r--src/page/album-commentary.js143
-rw-r--r--src/page/album.js612
-rw-r--r--src/page/artist-alias.js32
-rw-r--r--src/page/artist.js593
-rw-r--r--src/page/flash-act.js23
-rw-r--r--src/page/flash.js277
-rw-r--r--src/page/group.js300
-rw-r--r--src/page/homepage.js138
-rw-r--r--src/page/index.js43
-rw-r--r--src/page/listing.js226
-rw-r--r--src/page/news.js141
-rw-r--r--src/page/static.js52
-rw-r--r--src/page/tag.js115
-rw-r--r--src/page/track.js337
14 files changed, 554 insertions, 2478 deletions
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
deleted file mode 100644
index c03ae3d..0000000
--- a/src/page/album-commentary.js
+++ /dev/null
@@ -1,143 +0,0 @@
-// Album commentary page and index specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    filterAlbumsByCommentary
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData).length;
-}
-
-export function targets({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData);
-}
-
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary);
-    const words = entries.join(' ').split(' ').length;
-
-    const page = {
-        type: 'page',
-        path: ['albumCommentary', album.directory],
-        page: ({
-            getAlbumStylesheet,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings,
-            to,
-            transformMultiline
-        }) => ({
-            title: strings('albumCommentaryPage.title', {album: album.name}),
-            stylesheet: getAlbumStylesheet(album),
-            theme: getThemeString(album.color),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('albumCommentaryPage.title', {
-                            album: link.album(album)
-                        })}</h1>
-                        <p>${strings('albumCommentaryPage.infoLine', {
-                            words: `<b>${strings.count.words(words, {unit: true})}</b>`,
-                            entries: `<b>${strings.count.commentaryEntries(entries.length, {unit: true})}</b>`
-                        })}</p>
-                        ${album.commentary && fixWS`
-                            <h3>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                        ${album.tracks.filter(t => t.commentary).map(track => fixWS`
-                            <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', {
-                                track: link.track(track)
-                            })}</h3>
-                            <blockquote style="${getLinkThemeString(track.color)}">
-                                ${transformMultiline(track.commentary)}
-                            </blockquote>
-                        `).join('\n')}
-                    </div>
-                `
-            },
-
-            nav: {
-                links: [
-                    {toHome: true},
-                    {
-                        path: ['localized.commentaryIndex'],
-                        title: strings('commentaryIndex.title')
-                    },
-                    {
-                        html: strings('albumCommentaryPage.nav.album', {
-                            album: link.albumCommentary(album, {class: 'current'})
-                        })
-                    }
-                ]
-            }
-        })
-    };
-
-    return [page];
-}
-
-export function writeTargetless({wikiData}) {
-    const data = filterAlbumsByCommentary(wikiData.albumData)
-        .map(album => ({
-            album,
-            entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary)
-        }))
-        .map(({ album, entries }) => ({
-            album, entries,
-            words: entries.join(' ').split(' ').length
-        }));
-
-    const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0);
-    const totalWords = data.reduce((acc, {words}) => acc + words, 0);
-
-    const page = {
-        type: 'page',
-        path: ['commentaryIndex'],
-        page: ({
-            link,
-            strings
-        }) => ({
-            title: strings('commentaryIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('commentaryIndex.title')}</h1>
-                        <p>${strings('commentaryIndex.infoLine', {
-                            words: `<b>${strings.count.words(totalWords, {unit: true})}</b>`,
-                            entries: `<b>${strings.count.commentaryEntries(totalEntries, {unit: true})}</b>`
-                        })}</p>
-                        <p>${strings('commentaryIndex.albumList.title')}</p>
-                        <ul>
-                            ${data
-                                .map(({ album, entries, words }) => fixWS`
-                                    <li>${strings('commentaryIndex.albumList.item', {
-                                        album: link.albumCommentary(album),
-                                        words: strings.count.words(words, {unit: true}),
-                                        entries: strings.count.commentaryEntries(entries.length, {unit: true})
-                                    })}</li>
-                                `)
-                                .join('\n')}
-                        </ul>
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
diff --git a/src/page/album.js b/src/page/album.js
index 19efc70..af41076 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,402 +1,272 @@
-// Album page specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    bindOpts
-} from '../util/sugar.js';
-
-import {
-    getAlbumCover,
-    getAlbumListTag,
-    getTotalDuration
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-album info, artwork gallery & commentary pages`;
 
 export function targets({wikiData}) {
-    return wikiData.albumData;
+  return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const unbound_trackToListItem = (track, {
-        getArtistString,
-        getLinkThemeString,
-        link,
-        strings
-    }) => {
-        const itemOpts = {
-            duration: strings.count.duration(track.duration),
-            track: link.track(track)
-        };
-        return `<li style="${getLinkThemeString(track.color)}">${
-            (track.artists === album.artists
-                ? strings('trackList.item.withDuration', itemOpts)
-                : strings('trackList.item.withDuration.withArtists', {
-                    ...itemOpts,
-                    by: `<span class="by">${
-                        strings('trackList.item.withArtists.by', {
-                            artists: getArtistString(track.artists)
-                        })
-                    }</span>`
-                }))
-        }</li>`;
-    };
+export function pathsForTarget(album) {
+  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
 
-    const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
-    const albumDuration = getTotalDuration(album.tracks);
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
 
-    const listTag = getAlbumListTag(album);
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
 
-    const data = {
-        type: 'data',
-        path: ['album', album.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForAlbum,
-            serializeLink
-        }) => ({
-            name: album.name,
-            directory: album.directory,
-            dates: {
-                released: album.date,
-                trackArtAdded: album.trackArtDate,
-                coverArtAdded: album.coverArtDate,
-                addedToWiki: album.dateAdded
-            },
-            duration: albumDuration,
-            color: album.color,
-            cover: serializeCover(album, getAlbumCover),
-            artists: serializeContribs(album.artists || []),
-            coverArtists: serializeContribs(album.coverArtists || []),
-            wallpaperArtists: serializeContribs(album.wallpaperArtists || []),
-            bannerArtists: serializeContribs(album.bannerArtists || []),
-            groups: serializeGroupsForAlbum(album),
-            trackGroups: album.trackGroups?.map(trackGroup => ({
-                name: trackGroup.name,
-                color: trackGroup.color,
-                tracks: trackGroup.tracks.map(track => track.directory)
-            })),
-            tracks: album.tracks.map(track => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        })
-    };
+    {
+      type: 'page',
+      path: ['albumGallery', album.directory],
 
-    const page = {
-        type: 'page',
-        path: ['album', album.directory],
-        page: ({
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings,
-            transformMultiline
-        }) => {
-            const trackToListItem = bindOpts(unbound_trackToListItem, {
-                getArtistString,
-                getLinkThemeString,
-                link,
-                strings
-            });
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
 
-            return {
-                title: strings('albumPage.title', {album: album.name}),
-                stylesheet: getAlbumStylesheet(album),
-                theme: getThemeString(album.color, [
-                    `--album-directory: ${album.directory}`
-                ]),
+    hasCommentaryPage && {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
 
-                banner: album.bannerArtists && {
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory],
-                    alt: strings('misc.alt.albumBanner'),
-                    position: 'top'
-                },
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [album],
+      },
+    },
 
-                main: {
-                    content: fixWS`
-                        ${generateCoverLink({
-                            path: ['media.albumCover', album.directory],
-                            alt: strings('misc.alt.albumCover'),
-                            tags: album.artTags
-                        })}
-                        <h1>${strings('albumPage.title', {album: album.name})}</h1>
-                        <p>
-                            ${[
-                                album.artists && strings('releaseInfo.by', {
-                                    artists: getArtistString(album.artists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.coverArtists && strings('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(album.coverArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.wallpaperArtists && strings('releaseInfo.wallpaperArtBy', {
-                                    artists: getArtistString(album.wallpaperArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.bannerArtists && strings('releaseInfo.bannerArtBy', {
-                                    artists: getArtistString(album.bannerArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                strings('releaseInfo.released', {
-                                    date: strings.count.date(album.date)
-                                }),
-                                +album.coverArtDate !== +album.date && strings('releaseInfo.artReleased', {
-                                    date: strings.count.date(album.coverArtDate)
-                                }),
-                                strings('releaseInfo.duration', {
-                                    duration: strings.count.duration(albumDuration, {approximate: album.tracks.length > 1})
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        ${commentaryEntries && `<p>${
-                            strings('releaseInfo.viewCommentary', {
-                                link: link.albumCommentary(album, {
-                                    text: strings('releaseInfo.viewCommentary.link')
-                                })
-                            })
-                        }</p>`}
-                        ${album.urls.length && `<p>${
-                            strings('releaseInfo.listenOn', {
-                                links: strings.list.or(album.urls.map(url => fancifyURL(url, {album: true})))
-                            })
-                        }</p>`}
-                        ${album.trackGroups ? fixWS`
-                            <dl class="album-group-list">
-                                ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                                    <dt>${
-                                        strings('trackList.group', {
-                                            duration: strings.count.duration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
-                                            group: name
-                                        })
-                                    }</dt>
-                                    <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                                        ${tracks.map(trackToListItem).join('\n')}
-                                    </${listTag}></dd>
-                                `).join('\n')}
-                            </dl>
-                        ` : fixWS`
-                            <${listTag}>
-                                ${album.tracks.map(trackToListItem).join('\n')}
-                            </${listTag}>
-                        `}
-                        <p>
-                            ${[
-                                strings('releaseInfo.addedToWiki', {
-                                    date: strings.count.date(album.dateAdded)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        ${album.commentary && fixWS`
-                            <p>${strings('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                    `
-                },
+    /*
+    {
+      type: 'data',
+      path: ['album', album.directory],
 
-                sidebarLeft: generateAlbumSidebar(album, null, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    transformMultiline,
-                    wikiData
-                }),
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
+    */
+  ];
+}
 
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            html: strings('albumPage.nav.album', {
-                                album: link.album(album, {class: 'current'})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, null, {strings})
-                        }
-                    ],
-                    content: html.tag('div', generateAlbumChronologyLinks(album, null, {generateChronologyLinks}))
-                }
-            };
-        }
-    };
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    {
+      type: 'page',
+      path: ['commentaryIndex'],
+      contentFunction: {name: 'generateCommentaryIndexPage'},
+    },
 
-    return [page, data];
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'list/all-commentary'],
+        toPath: ['commentaryIndex'],
+        title: 'Album Commentary',
+      },
+  ];
 }
 
-// Utility functions
+/*
+export function write(album, {wikiData}) {
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasDuration = albumDuration > 0;
+    const hasTracks = album.tracks.length > 0;
+    const hasDate = !!album.date;
+    if (!hasDuration && !hasTracks && !hasDate) return '';
 
-export function generateAlbumSidebar(album, currentTrack, {
-    fancifyURL,
-    getLinkThemeString,
-    link,
-    strings,
-    transformMultiline,
-    wikiData
-}) {
-    const listTag = getAlbumListTag(album);
+    return language.formatString(
+      'albumPage.socialEmbed.body' + [
+        hasDuration && '.withDuration',
+        hasTracks && '.withTracks',
+        hasDate && '.withReleaseDate',
+      ].filter(Boolean).join(''),
+      Object.fromEntries([
+        hasDuration &&
+          ['duration', language.formatDuration(albumDuration)],
+        hasTracks &&
+          ['tracks', language.countTracks(album.tracks.length, {unit: true})],
+        hasDate &&
+          ['date', language.formatDate(album.date)],
+      ].filter(Boolean)));
+  };
 
-    const trackGroups = album.trackGroups || [{
-        name: strings('albumSidebar.trackList.fallbackGroupName'),
-        color: album.color,
-        startIndex: 0,
-        tracks: album.tracks
-    }];
+  const data = {
+    type: 'data',
+    path: ['album', album.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForAlbum,
+      serializeLink,
+    }) => ({
+      name: album.name,
+      directory: album.directory,
+      dates: {
+        released: album.date,
+        trackArtAdded: album.trackArtDate,
+        coverArtAdded: album.coverArtDate,
+        addedToWiki: album.dateAddedToWiki,
+      },
+      duration: albumDuration,
+      color: album.color,
+      cover: serializeCover(album, getAlbumCover),
+      artistContribs: serializeContribs(album.artistContribs),
+      coverArtistContribs: serializeContribs(album.coverArtistContribs),
+      wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs),
+      bannerArtistContribs: serializeContribs(album.bannerArtistContribs),
+      groups: serializeGroupsForAlbum(album),
+      trackSections: album.trackSections?.map((section) => ({
+        name: section.name,
+        color: section.color,
+        tracks: section.tracks.map((track) => track.directory),
+      })),
+      tracks: album.tracks.map((track) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }),
+  };
 
-    const trackToListItem = track => html.tag('li',
-        {class: track === currentTrack && 'current'},
-        strings('albumSidebar.trackList.item', {
-            track: link.track(track)
-        }));
+  // TODO: only gen if there are any tracks with art
+  const galleryPage = {
+    type: 'page',
+    path: ['albumGallery', album.directory],
+    page: ({
+      // generateInfoGalleryLinks,
+      // generateNavigationLinks,
+      getAlbumCover,
+      getAlbumStylesheet,
+      getGridHTML,
+      getTrackCover,
+      // getLinkThemeString,
+      getThemeString,
+      html,
+      language,
+      link,
+    }) => ({
+      title: language.$('albumGalleryPage.title', {album: album.name}),
+      stylesheet: getAlbumStylesheet(album),
 
-    const trackListPart = fixWS`
-        <h1>${link.album(album)}</h1>
-        ${trackGroups.map(({ name, color, startIndex, tracks }) =>
-            html.tag('details', {
-                // Leave side8ar track groups collapsed on al8um homepage,
-                // since there's already a view of all the groups expanded
-                // in the main content area.
-                open: currentTrack && tracks.includes(currentTrack),
-                class: tracks.includes(currentTrack) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(color)},
-                    (listTag === 'ol'
-                        ? strings('albumSidebar.trackList.group.withRange', {
-                            group: `<span class="group-name">${name}</span>`,
-                            range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
-                        })
-                        : strings('albumSidebar.trackList.group', {
-                            group: `<span class="group-name">${name}</span>`
-                        }))
-                ),
-                fixWS`
-                    <${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                        ${tracks.map(trackToListItem).join('\n')}
-                    </${listTag}>
-                `
-            ])).join('\n')}
-    `;
+      themeColor: album.color,
+      theme: getThemeString(album.color),
 
-    const { groups } = album;
+      main: {
+        classes: ['top-index'],
+        headingMode: 'static',
 
-    const groupParts = groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1];
-        const previous = group.albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => fixWS`
-        <h1>${
-            strings('albumSidebar.groupBox.title', {
-                group: link.groupInfo(group)
-            })
-        }</h1>
-        ${!currentTrack && transformMultiline(group.descriptionShort)}
-        ${group.urls.length && `<p>${
-            strings('releaseInfo.visitOn', {
-                links: strings.list.or(group.urls.map(url => fancifyURL(url)))
-            })
-        }</p>`}
-        ${!currentTrack && fixWS`
-            ${next && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.next', {
-                    album: link.album(next)
-                })
-            }</p>`}
-            ${previous && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.previous', {
-                    album: link.album(previous)
+        content: [
+          html.tag('p',
+            {class: 'quick-info'},
+            (album.date
+              ? language.$('albumGalleryPage.infoLine.withDate', {
+                  tracks: html.tag('b',
+                    language.countTracks(album.tracks.length, {unit: true})),
+                  duration: html.tag('b',
+                    language.formatDuration(albumDuration, {unit: true})),
+                  date: html.tag('b',
+                    language.formatDate(album.date)),
                 })
-            }</p>`}
-        `}
-    `);
+              : language.$('albumGalleryPage.infoLine', {
+                  tracks: html.tag('b',
+                    language.countTracks(album.tracks.length, {unit: true})),
+                  duration: html.tag('b',
+                    language.formatDuration(albumDuration, {unit: true})),
+                }))),
+
+          html.tag('div',
+            {class: 'grid-listing'},
+            getGridHTML({
+              linkFn: (t, opts) => t.album ? link.track(t, opts) : link.album(t, opts),
+              noSrcTextFn: t =>
+                language.$('misc.albumGalleryGrid.noCoverArt', {
+                  name: t.name,
+                }),
 
-    if (groupParts.length) {
-        if (currentTrack) {
-            const combinedGroupPart = groupParts.join('\n<hr>\n');
-            return {
-                multiple: [
-                    trackListPart,
-                    combinedGroupPart
-                ]
-            };
-        } else {
-            return {
-                multiple: [
-                    ...groupParts,
-                    trackListPart
-                ]
-            };
-        }
-    } else {
-        return {
-            content: trackListPart
-        };
-    }
+              srcFn(t) {
+                if (!t.album) {
+                  return getAlbumCover(t);
+                } else if (t.hasUniqueCoverArt) {
+                  return getTrackCover(t);
+                } else {
+                  return null;
+                }
+              },
+
+              entries: [
+                // {item: album},
+                ...album.tracks.map(track => ({item: track})),
+              ],
+            })),
+        ],
+      },
+
+      nav: generateAlbumExtrasPageNav(album, 'gallery', {
+        html,
+        language,
+        link,
+      }),
+    }),
+  };
 }
 
-export function generateAlbumNavLinks(album, currentTrack, {
-    generatePreviousNextLinks,
-    strings
+export function generateAlbumSecondaryNav(album, currentTrack, {
+  getLinkThemeString,
+  html,
+  language,
+  link,
 }) {
-    if (album.tracks.length <= 1) {
-        return '';
-    }
+  const isAlbumPage = !currentTrack;
 
-    const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track'
-    });
-    const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-        (currentTrack
-            ? strings('trackPage.nav.random')
-            : strings('albumPage.nav.randomTrack'))
-    }</a>`;
+  const {groups} = album;
 
-    return (previousNextLinks
-        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-        : `<span class="js-hide-until-data">(${randomLink})</span>`);
-}
+  if (empty(groups)) {
+    return null;
+  }
+
+  const groupParts = groups
+    .map((group) => {
+      const albums = group.albums.filter((album) => album.date);
+      const index = albums.indexOf(album);
+      const next = index >= 0 && albums[index + 1];
+      const previous = index > 0 && albums[index - 1];
+      return {group, next, previous};
+    })
+    .map(({group, next, previous}) => {
+      const previousLink =
+        isAlbumPage &&
+        previous &&
+          link.album(previous, {
+            color: false,
+            text: language.$('misc.nav.previous'),
+          });
+      const nextLink =
+        isAlbumPage &&
+        next &&
+          link.album(next, {
+            color: false,
+            text: language.$('misc.nav.next'),
+          });
+      const links = [previousLink, nextLink].filter(Boolean);
+      return html.tag('span',
+        {style: getLinkThemeString(group.color)},
+        [
+          language.$('albumSidebar.groupBox.title', {
+            group: link.groupInfo(group),
+          }),
+          !empty(links) && `(${language.formatUnitList(links)})`,
+        ]);
+    });
 
-export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) {
-    return [
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'artists',
-            getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor],
-            headingString: 'misc.chronology.heading.track'
-        }),
-        generateChronologyLinks(currentTrack || album, {
-            contribKey: 'coverArtists',
-            dateKey: 'coverArtDate',
-            getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist],
-            headingString: 'misc.chronology.heading.coverArt'
-        })
-    ].filter(Boolean).join('\n');
+  return {
+    classes: ['nav-links-groups'],
+    content: groupParts,
+  };
 }
+*/
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index d03510a..d230522 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,22 +1,24 @@
-// Artist alias redirect pages.
-// (Makes old permalinks bring visitors to the up-to-date page.)
+export const description = `redirects for aliased artist names`;
 
 export function targets({wikiData}) {
-    return wikiData.artistAliasData;
+  return wikiData.artistAliasData;
 }
 
-export function write(aliasArtist, {wikiData}) {
-    // This function doesn't actually use wikiData, 8ut, um, consistency?
+export function pathsForTarget(aliasArtist) {
+  const {aliasedArtist} = aliasArtist;
 
-    const { alias: targetArtist } = aliasArtist;
+  // Don't generate a redirect page if this aliased name resolves to the same
+  // directory as the original artist! See issue #280.
+  if (aliasArtist.directory === aliasedArtist.directory) {
+    return [];
+  }
 
-    const redirect = {
-        type: 'redirect',
-        fromPath: ['artist', aliasArtist.directory],
-        toPath: ['artist', targetArtist.directory],
-        title: () => aliasArtist.name
-    };
-
-    return [redirect];
+  return [
+    {
+      type: 'redirect',
+      fromPath: ['artist', aliasArtist.directory],
+      toPath: ['artist', aliasedArtist.directory],
+      title: aliasedArtist.name,
+    },
+  ];
 }
-
diff --git a/src/page/artist.js b/src/page/artist.js
index e6160be..27ff896 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -1,512 +1,103 @@
-// Artist page specification.
-//
-// NB: See artist-alias.js for artist alias redirect pages.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    bindOpts,
-    unique
-} from '../util/sugar.js';
+import {empty} from '#sugar';
 
-import {
-    chunkByProperties,
-    getTotalDuration,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-artist info & artwork gallery pages`;
 
+// NB: See artist-alias.js for artist alias redirect pages.
 export function targets({wikiData}) {
-    return wikiData.artistData;
+  return wikiData.artistData;
 }
 
-export function write(artist, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    const {
-        name,
-        urls = [],
-        note = ''
-    } = artist;
-
-    const artThingsAll = sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist]));
-    const artThingsGallery = sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]);
-    const commentaryThings = sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]);
-
-    const hasGallery = artThingsGallery.length > 0;
-
-    const getArtistsAndContrib = (thing, key) => ({
-        artists: thing[key]?.filter(({ who }) => who !== artist),
-        contrib: thing[key]?.find(({ who }) => who === artist),
-        thing,
-        key
-    });
-
-    const artListChunks = chunkByProperties(sortByDate(artThingsAll.flatMap(thing =>
-        (['coverArtists', 'wallpaperArtists', 'bannerArtists']
-            .map(key => getArtistsAndContrib(thing, key))
-            .filter(({ contrib }) => contrib)
-            .map(props => ({
-                album: thing.album || thing,
-                track: thing.album ? thing : null,
-                date: +(thing.coverArtDate || thing.date),
-                ...props
-            })))
-    )), ['date', 'album']);
-
-    const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
-        album: thing.album || thing,
-        track: thing.album ? thing : null
-    })), ['album']);
-
-    const allTracks = sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]));
-    const unreleasedTracks = allTracks.filter(track => track.album.directory === UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = allTracks.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-
-    const chunkTracks = tracks => (
-        chunkByProperties(tracks.map(track => ({
-            track,
-            date: +track.date,
-            album: track.album,
-            duration: track.duration,
-            artists: (track.artists.some(({ who }) => who === artist)
-                ? track.artists.filter(({ who }) => who !== artist)
-                : track.contributors.filter(({ who }) => who !== artist)),
-            contrib: {
-                who: artist,
-                what: [
-                    track.artists.find(({ who }) => who === artist)?.what,
-                    track.contributors.find(({ who }) => who === artist)?.what
-                ].filter(Boolean).join(', ')
-            }
-        })), ['date', 'album'])
-        .map(({date, album, chunk}) => ({
-            date, album, chunk,
-            duration: getTotalDuration(chunk),
-        })));
-
-    const unreleasedTrackListChunks = chunkTracks(unreleasedTracks);
-    const releasedTrackListChunks = chunkTracks(releasedTracks);
-
-    const totalReleasedDuration = getTotalDuration(releasedTracks);
-
-    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(releasedTracks);
-    const artGroups = countGroups(artThingsAll);
-
-    let flashes, flashListChunks;
-    if (wikiInfo.features.flashesAndGames) {
-        flashes = sortByDate(artist.flashes.asContributor.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.contributors.find(({ who }) => who === artist)
-            })), ['act'])
-            .map(({ act, chunk }) => ({
-                act, chunk,
-                dateFirst: chunk[0].date,
-                dateLast: chunk[chunk.length - 1].date
-            })));
-    }
-
-    const generateEntryAccents = ({
-        getArtistString, strings,
-        aka, entry, artists, contrib
-    }) =>
-        (aka
-            ? strings('artistPage.creditList.entry.rerelease', {entry})
-            : (artists.length
-                ? (contrib.what
-                    ? strings('artistPage.creditList.entry.withArtists.withContribution', {
-                        entry,
-                        artists: getArtistString(artists),
-                        contribution: contrib.what
-                    })
-                    : strings('artistPage.creditList.entry.withArtists', {
-                        entry,
-                        artists: getArtistString(artists)
-                    }))
-                : (contrib.what
-                    ? strings('artistPage.creditList.entry.withContribution', {
-                        entry,
-                        contribution: contrib.what
-                    })
-                    : entry)));
-
-    const unbound_generateTrackList = (chunks, {
-        getArtistString, link, strings
-    }) => fixWS`
-        <dl>
-            ${chunks.map(({date, album, chunk, duration}) => fixWS`
-                <dt>${strings('artistPage.creditList.album.withDate.withDuration', {
-                    album: link.album(album),
-                    date: strings.count.date(date),
-                    duration: strings.count.duration(duration, {approximate: true})
-                })}</dt>
-                <dd><ul>
-                    ${(chunk
-                        .map(({track, ...props}) => ({
-                            aka: track.aka,
-                            entry: strings('artistPage.creditList.entry.track.withDuration', {
-                                track: link.track(track),
-                                duration: strings.count.duration(track.duration)
-                            }),
-                            ...props
-                        }))
-                        .map(({aka, ...opts}) => html.tag('li',
-                            {class: aka && 'rerelease'},
-                            generateEntryAccents({getArtistString, strings, aka, ...opts})))
-                        .join('\n'))}
-                </ul></dd>
-            `).join('\n')}
-        </dl>
-    `;
-
-    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 (artists.length) 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 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.albums.asCoverArtist.map(serializeArtistsAndContrib('coverArtists')),
-                    asWallpaperArtist: artist.albums.asWallpaperArtist.map(serializeArtistsAndContrib('wallpaperArtists')),
-                    asBannerArtist: artist.albums.asBannerArtist.map(serializeArtistsAndContrib('bannerArtists'))
-                },
-                flashes: wikiInfo.features.flashesAndGames ? {
-                    asContributor: artist.flashes.asContributor
-                        .map(flash => getArtistsAndContrib(flash, 'contributors'))
-                        .map(({ contrib, thing: flash }) => ({
-                            link: serializeLink(flash),
-                            contribution: contrib.what
-                        }))
-                } : null,
-                tracks: {
-                    asArtist: artist.tracks.asArtist.map(serializeArtistsAndContrib('artists')),
-                    asContributor: artist.tracks.asContributor.map(serializeArtistsAndContrib('contributors')),
-                    chunked: {
-                        released: serializeTrackListChunks(releasedTrackListChunks),
-                        unreleased: serializeTrackListChunks(unreleasedTrackListChunks)
-                    }
-                }
-            };
-        }
-    };
-
-    const infoPage = {
-        type: 'page',
-        path: ['artist', artist.directory],
-        page: ({
-            fancifyURL,
-            generateCoverLink,
-            generateInfoGalleryLinks,
-            getArtistString,
-            link,
-            strings,
-            to,
-            transformMultiline
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {
-                getArtistString,
-                link,
-                strings
-            });
-
-            return {
-                title: strings('artistPage.title', {artist: name}),
-
-                main: {
-                    content: fixWS`
-                        ${artist.hasAvatar && generateCoverLink({
-                            path: ['localized.artistAvatar', artist.directory],
-                            alt: strings('misc.alt.artistAvatar')
-                        })}
-                        <h1>${strings('artistPage.title', {artist: name})}</h1>
-                        ${note && fixWS`
-                            <p>${strings('releaseInfo.note')}</p>
-                            <blockquote>
-                                ${transformMultiline(note)}
-                            </blockquote>
-                            <hr>
-                        `}
-                        ${urls.length && `<p>${strings('releaseInfo.visitOn', {
-                            links: strings.list.or(urls.map(url => fancifyURL(url, {strings})))
-                        })}</p>`}
-                        ${hasGallery && `<p>${strings('artistPage.viewArtGallery', {
-                            link: link.artistGallery(artist, {
-                                text: strings('artistPage.viewArtGallery.link')
-                            })
-                        })}</p>`}
-                        <p>${strings('misc.jumpTo.withLinks', {
-                            links: strings.list.unit([
-                                [
-                                    [...releasedTracks, ...unreleasedTracks].length && `<a href="#tracks">${strings('artistPage.trackList.title')}</a>`,
-                                    unreleasedTracks.length && `(<a href="#unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</a>)`
-                                ].filter(Boolean).join(' '),
-                                artThingsAll.length && `<a href="#art">${strings('artistPage.artList.title')}</a>`,
-                                wikiInfo.features.flashesAndGames && flashes.length && `<a href="#flashes">${strings('artistPage.flashList.title')}</a>`,
-                                commentaryThings.length && `<a href="#commentary">${strings('artistPage.commentaryList.title')}</a>`
-                            ].filter(Boolean))
-                        })}</p>
-                        ${(releasedTracks.length || unreleasedTracks.length) && fixWS`
-                            <h2 id="tracks">${strings('artistPage.trackList.title')}</h2>
-                        `}
-                        ${releasedTracks.length && fixWS`
-                            <p>${strings('artistPage.contributedDurationLine', {
-                                artist: artist.name,
-                                duration: strings.count.duration(totalReleasedDuration, {approximate: true, unit: true})
-                            })}</p>
-                            <p>${strings('artistPage.musicGroupsLine', {
-                                groups: strings.list.unit(musicGroups
-                                    .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: strings.count.contributions(contributions)
-                                    })))
-                            })}</p>
-                            ${generateTrackList(releasedTrackListChunks)}
-                        `}
-                        ${unreleasedTracks.length && fixWS`
-                            <h3 id="unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</h3>
-                            ${generateTrackList(unreleasedTrackListChunks)}
-                        `}
-                        ${artThingsAll.length && fixWS`
-                            <h2 id="art">${strings('artistPage.artList.title')}</h2>
-                            ${hasGallery && `<p>${strings('artistPage.viewArtGallery.orBrowseList', {
-                                link: link.artistGallery(artist, {
-                                    text: strings('artistPage.viewArtGallery.link')
-                                })
-                            })}</p>`}
-                            <p>${strings('artistPage.artGroupsLine', {
-                                groups: strings.list.unit(artGroups
-                                    .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: strings.count.contributions(contributions)
-                                    })))
-                            })}</p>
-                            <dl>
-                                ${artListChunks.map(({date, album, chunk}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.album.withDate', {
-                                        album: link.album(album),
-                                        date: strings.count.date(date)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, key, ...props}) => ({
-                                                entry: (track
-                                                    ? strings('artistPage.creditList.entry.track', {
-                                                        track: link.track(track)
-                                                    })
-                                                    : `<i>${strings('artistPage.creditList.entry.album.' + {
-                                                        wallpaperArtists: 'wallpaperArt',
-                                                        bannerArtists: 'bannerArt',
-                                                        coverArtists: 'coverArt'
-                                                    }[key])}</i>`),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, strings, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${wikiInfo.features.flashesAndGames && flashes.length && fixWS`
-                            <h2 id="flashes">${strings('artistPage.flashList.title')}</h2>
-                            <dl>
-                                ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.flashAct.withDateRange', {
-                                        act: link.flash(chunk[0].flash, {text: act.name}),
-                                        dateRange: strings.count.dateRange([dateFirst, dateLast])
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({flash, ...props}) => ({
-                                                entry: strings('artistPage.creditList.entry.flash', {
-                                                    flash: link.flash(flash)
-                                                }),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, strings, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${commentaryThings.length && fixWS`
-                            <h2 id="commentary">${strings('artistPage.commentaryList.title')}</h2>
-                            <dl>
-                                ${commentaryListChunks.map(({album, chunk}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.album', {
-                                        album: link.album(album)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, ...props}) => track
-                                                ? strings('artistPage.creditList.entry.track', {
-                                                    track: link.track(track)
-                                                })
-                                                : `<i>${strings('artistPage.creditList.entry.album.commentary')}</i>`)
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                    `
-                },
-
-                nav: generateNavForArtist(artist, false, hasGallery, {
-                    generateInfoGalleryLinks,
-                    link,
-                    strings,
-                    wikiData
-                })
-            };
-        }
-    };
-
-    const galleryPage = hasGallery && {
-        type: 'page',
-        path: ['artistGallery', artist.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            getAlbumCover,
-            getGridHTML,
-            getTrackCover,
-            link,
-            strings,
-            to
-        }) => ({
-            title: strings('artistGalleryPage.title', {artist: name}),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('artistGalleryPage.title', {artist: name})}</h1>
-                    <p class="quick-info">${strings('artistGalleryPage.infoLine', {
-                        coverArts: strings.count.coverArts(artThingsGallery.length, {unit: true})
-                    })}</p>
-                    <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))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateNavForArtist(artist, true, hasGallery, {
-                generateInfoGalleryLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [data, infoPage, galleryPage].filter(Boolean);
+export function pathsForTarget(artist) {
+  const hasGalleryPage =
+    !empty(artist.tracksAsCoverArtist) ||
+    !empty(artist.albumsAsCoverArtist);
+
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
+      },
+    },
+
+    hasGalleryPage && {
+      type: 'page',
+      path: ['artistGallery', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistGalleryPage',
+        args: [artist],
+      },
+    },
+  ];
 }
 
-// Utility functions
-
-function generateNavForArtist(artist, isGallery, hasGallery, {
-    generateInfoGalleryLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { wikiInfo } = wikiData;
+/*
+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 data = {
+  type: 'data',
+  path: ['artist', artist.directory],
+  data: ({serializeContribs, serializeLink}) => {
+    const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
+      serializeContribs,
+      serializeLink,
+    });
 
-    const infoGalleryLinks = (hasGallery &&
-        generateInfoGalleryLinks(artist, isGallery, {
-            link, strings,
-            linkKeyGallery: 'artistGallery',
-            linkKeyInfo: 'artist'
-        }))
+    const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+      serializeLink,
+    });
 
     return {
-        links: [
-            {toHome: true},
-            wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('artistPage.nav.artist', {
-                    artist: link.artist(artist, {class: 'current'})
-                })
-            },
-            hasGallery &&
-            {
-                divider: false,
-                html: `(${infoGalleryLinks})`
-            }
-        ]
+      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),
+      },
     };
-}
+  },
+};
+*/
diff --git a/src/page/flash-act.js b/src/page/flash-act.js
new file mode 100644
index 0000000..e54525a
--- /dev/null
+++ b/src/page/flash-act.js
@@ -0,0 +1,23 @@
+export const description = `flash act gallery pages`;
+
+export function condition({wikiData}) {
+  return wikiData.wikiInfo.enableFlashesAndGames;
+}
+
+export function targets({wikiData}) {
+  return wikiData.flashActData;
+}
+
+export function pathsForTarget(flashAct) {
+  return [
+    {
+      type: 'page',
+      path: ['flashActGallery', flashAct.directory],
+
+      contentFunction: {
+        name: 'generateFlashActGalleryPage',
+        args: [flashAct],
+      },
+    },
+  ];
+}
diff --git a/src/page/flash.js b/src/page/flash.js
index 9c59016..7df7415 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -1,264 +1,33 @@
-// Flash page and index specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    getFlashLink
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `flash & game pages`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.flashesAndGames;
+  return wikiData.wikiInfo.enableFlashesAndGames;
 }
 
 export function targets({wikiData}) {
-    return wikiData.flashData;
+  return wikiData.flashData;
 }
 
-export function write(flash, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['flash', flash.directory],
-        page: ({
-            fancifyFlashURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            getArtistString,
-            getFlashCover,
-            getThemeString,
-            link,
-            strings,
-            transformInline
-        }) => ({
-            title: strings('flashPage.title', {flash: flash.name}),
-            theme: getThemeString(flash.color, [
-                `--flash-directory: ${flash.directory}`
-            ]),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('flashPage.title', {flash: flash.name})}</h1>
-                    ${generateCoverLink({
-                        src: getFlashCover(flash),
-                        alt: strings('misc.alt.flashArt')
-                    })}
-                    <p>${strings('releaseInfo.released', {date: strings.count.date(flash.date)})}</p>
-                    ${(flash.page || flash.urls.length) && `<p>${strings('releaseInfo.playOn', {
-                        links: strings.list.or([
-                            flash.page && getFlashLink(flash),
-                            ...flash.urls
-                        ].map(url => fancifyFlashURL(url, flash)))
-                    })}</p>`}
-                    ${flash.tracks.length && fixWS`
-                        <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
-                        <ul>
-                            ${(flash.tracks
-                                .map(track => strings('trackList.item.withArtists', {
-                                    track: link.track(track),
-                                    by: `<span class="by">${
-                                        strings('trackList.item.withArtists.by', {
-                                            artists: getArtistString(track.artists)
-                                        })
-                                    }</span>`
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    `}
-                    ${flash.contributors.textContent && fixWS`
-                        <p>
-                            ${strings('releaseInfo.contributors')}
-                            <br>
-                            ${transformInline(flash.contributors.textContent)}
-                        </p>
-                    `}
-                    ${flash.contributors.length && fixWS`
-                        <p>${strings('releaseInfo.contributors')}</p>
-                        <ul>
-                            ${flash.contributors
-                                .map(contrib => `<li>${getArtistString([contrib], {
-                                    showContrib: true,
-                                    showIcons: true
-                                })}</li>`)
-                                .join('\n')}
-                        </ul>
-                    `}
-                `
-            },
-
-            sidebarLeft: generateSidebarForFlash(flash, {link, strings, wikiData}),
-            nav: generateNavForFlash(flash, {
-                generateChronologyLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+export function pathsForTarget(flash) {
+  return [
+    {
+      type: 'page',
+      path: ['flash', flash.directory],
+
+      contentFunction: {
+        name: 'generateFlashInfoPage',
+        args: [flash],
+      },
+    },
+  ];
 }
 
-export function writeTargetless({wikiData}) {
-    const { flashActData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['flashIndex'],
-        page: ({
-            getFlashGridHTML,
-            getLinkThemeString,
-            link,
-            strings
-        }) => ({
-            title: strings('flashIndex.title'),
-
-            main: {
-                classes: ['flash-index'],
-                content: fixWS`
-                    <h1>${strings('flashIndex.title')}</h1>
-                    <div class="long-content">
-                        <p class="quick-info">${strings('misc.jumpTo')}</p>
-                        <ul class="quick-info">
-                            ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS`
-                                <li><a href="#${anchor}" style="${getLinkThemeString(jumpColor)}">${jump}</a></li>
-                            `).join('\n')}
-                        </ul>
-                    </div>
-                    ${flashActData.map((act, i) => fixWS`
-                        <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}">${link.flash(act.flashes[0], {text: act.name})}</h2>
-                        <div class="grid-listing">
-                            ${getFlashGridHTML({
-                                entries: act.flashes.map(flash => ({item: flash})),
-                                lazy: i === 0 ? 4 : true
-                            })}
-                        </div>
-                    `).join('\n')}
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateNavForFlash(flash, {
-    generateChronologyLinks,
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { flashData, wikiInfo } = wikiData;
-
-    const previousNextLinks = generatePreviousNextLinks(flash, {
-        data: flashData,
-        linkKey: 'flash'
-    });
-
-    return {
-        links: [
-            {
-                path: ['localized.home'],
-                title: wikiInfo.shortName
-            },
-            {
-                path: ['localized.flashIndex'],
-                title: strings('flashIndex.title')
-            },
-            {
-                html: strings('flashPage.nav.flash', {
-                    flash: link.flash(flash, {class: 'current'})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ],
-
-        content: fixWS`
-            <div>
-                ${generateChronologyLinks(flash, {
-                    headingString: 'misc.chronology.heading.flash',
-                    contribKey: 'contributors',
-                    getThings: artist => artist.flashes.asContributor
-                })}
-            </div>
-        `
-    };
-}
-
-function generateSidebarForFlash(flash, {link, strings, wikiData}) {
-    // all hard-coded, sorry :(
-    // this doesnt have a super portable implementation/design...yet!!
-
-    const { flashActData } = wikiData;
-
-    const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6'));
-    const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon'));
-    const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon'));
-    const actIndex = flashActData.indexOf(flash.act);
-    const side = (
-        (actIndex < 0) ? 0 :
-        (actIndex < act6) ? 1 :
-        (actIndex <= outsideCanon) ? 2 :
-        3
-    );
-    const currentAct = flash && flash.act;
-
-    return {
-        content: fixWS`
-            <h1>${link.flashIndex('', {text: strings('flashIndex.title')})}</h1>
-            <dl>
-                ${flashActData.filter(act =>
-                    act.name.startsWith('Act 1') ||
-                    act.name.startsWith('Act 6 Act 1') ||
-                    act.name.startsWith('Hiveswap') ||
-                    // Sorry not sorry -Yiffy
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))()
-                ).flatMap(act => [
-                    act.name.startsWith('Act 1') && html.tag('dt',
-                        {class: ['side', side === 1 && 'current']},
-                        link.flash(act.flashes[0], {color: '#4ac925', text: `Side 1 (Acts 1-5)`}))
-                    || act.name.startsWith('Act 6 Act 1') && html.tag('dt',
-                        {class: ['side', side === 2 && 'current']},
-                        link.flash(act.flashes[0], {color: '#1076a2', text: `Side 2 (Acts 6-7)`}))
-                    || act.name.startsWith('Hiveswap Act 1') && html.tag('dt',
-                        {class: ['side', side === 3 && 'current']},
-                        link.flash(act.flashes[0], {color: '#008282', text: `Outside Canon (Misc. Games)`})),
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))() && html.tag('dt',
-                        {class: act === currentAct && 'current'},
-                        link.flash(act.flashes[0], {text: act.name})),
-                    act === currentAct && fixWS`
-                        <dd><ul>
-                            ${act.flashes.map(f => html.tag('li',
-                                {class: f === flash && 'current'},
-                                link.flash(f))).join('\n')}
-                        </ul></dd>
-                    `
-                ]).filter(Boolean).join('\n')}
-            </dl>
-        `
-    };
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['flashIndex'],
+      contentFunction: {name: 'generateFlashIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/group.js b/src/page/group.js
index 7282fc8..b0ed5ba 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -1,263 +1,53 @@
-// Group page specifications.
+import {empty} from '#sugar';
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import * as html from '../util/html.js';
-
-import {
-    getTotalDuration,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-group info & album gallery pages`;
 
 export function targets({wikiData}) {
-    return wikiData.groupData;
+  return wikiData.groupData;
 }
 
-export function write(group, {wikiData}) {
-    const { listingSpec, wikiInfo } = wikiData;
-
-    const releasedAlbums = group.albums.filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = releasedAlbums.flatMap(album => album.tracks);
-    const totalDuration = getTotalDuration(releasedTracks);
-
-    const albumLines = group.albums.map(album => ({
-        album,
-        otherGroup: album.groups.find(g => g !== group)
-    }));
-
-    const infoPage = {
-        type: 'page',
-        path: ['groupInfo', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getLinkThemeString,
-            getThemeString,
-            fancifyURL,
-            link,
-            strings,
-            transformMultiline
-        }) => ({
-            title: strings('groupInfoPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('groupInfoPage.title', {group: group.name})}</h1>
-                    ${group.urls.length && `<p>${
-                        strings('releaseInfo.visitOn', {
-                            links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
-                        })
-                    }</p>`}
-                    <blockquote>
-                        ${transformMultiline(group.description)}
-                    </blockquote>
-                    <h2>${strings('groupInfoPage.albumList.title')}</h2>
-                    <p>${
-                        strings('groupInfoPage.viewAlbumGallery', {
-                            link: link.groupGallery(group, {
-                                text: strings('groupInfoPage.viewAlbumGallery.link')
-                            })
-                        })
-                    }</p>
-                    <ul>
-                        ${albumLines.map(({ album, otherGroup }) => {
-                            const item = strings('groupInfoPage.albumList.item', {
-                                year: album.date.getFullYear(),
-                                album: link.album(album)
-                            });
-                            return html.tag('li', (otherGroup
-                                ? strings('groupInfoPage.albumList.item.withAccent', {
-                                    item,
-                                    accent: html.tag('span',
-                                        {class: 'other-group-accent'},
-                                        strings('groupInfoPage.albumList.item.otherGroupAccent', {
-                                            group: link.groupInfo(otherGroup, {color: false})
-                                        }))
-                                })
-                                : item));
-                        }).join('\n')}
-                    </ul>
-                `
-            },
-
-            sidebarLeft: generateGroupSidebar(group, false, {
-                getLinkThemeString,
-                link,
-                strings,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, false, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    const galleryPage = {
-        type: 'page',
-        path: ['groupGallery', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getAlbumGridHTML,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings
-        }) => ({
-            title: strings('groupGalleryPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('groupGalleryPage.title', {group: group.name})}</h1>
-                    <p class="quick-info">${
-                        strings('groupGalleryPage.infoLine', {
-                            tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                            albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                            time: `<b>${strings.count.duration(totalDuration, {unit: true})}</b>`
-                        })
-                    }</p>
-                    ${wikiInfo.features.groupUI && wikiInfo.features.listings && html.tag('p',
-                        {class: 'quick-info'},
-                        strings('groupGalleryPage.anotherGroupLine', {
-                            link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), {
-                                text: strings('groupGalleryPage.anotherGroupLine.link')
-                            })
-                        })
-                    )}
-                    <div class="grid-listing">
-                        ${getAlbumGridHTML({
-                            entries: sortByDate(group.albums.map(item => ({item}))).reverse(),
-                            details: true
-                        })}
-                    </div>
-                `
-            },
-
-            sidebarLeft: generateGroupSidebar(group, true, {
-                getLinkThemeString,
-                link,
-                strings,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, true, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [infoPage, galleryPage];
+export function pathsForTarget(group) {
+  const hasGalleryPage = !empty(group.albums);
+
+  return [
+    {
+      type: 'page',
+      path: ['groupInfo', group.directory],
+
+      contentFunction: {
+        name: 'generateGroupInfoPage',
+        args: [group],
+      },
+    },
+
+    hasGalleryPage && {
+      type: 'page',
+      path: ['groupGallery', group.directory],
+
+      contentFunction: {
+        name: 'generateGroupGalleryPage',
+        args: [group],
+      },
+    },
+  ];
 }
 
-// Utility functions
-
-function generateGroupSidebar(currentGroup, isGallery, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    const { groupCategoryData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.features.groupUI) {
-        return null;
-    }
-
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    return {
-        content: fixWS`
-            <h1>${strings('groupSidebar.title')}</h1>
-            ${groupCategoryData.map(category =>
-                html.tag('details', {
-                    open: category === currentGroup.category,
-                    class: category === currentGroup.category && 'current'
-                }, [
-                    html.tag('summary',
-                        {style: getLinkThemeString(category.color)},
-                        strings('groupSidebar.groupList.category', {
-                            category: `<span class="group-name">${category.name}</span>`
-                        })),
-                    html.tag('ul',
-                        category.groups.map(group => html.tag('li',
-                            {
-                                class: group === currentGroup && 'current',
-                                style: getLinkThemeString(group.color)
-                            },
-                            strings('groupSidebar.groupList.item', {
-                                group: link[linkKey](group)
-                            }))))
-                ])).join('\n')}
-            </dl>
-        `
-    };
-}
-
-function generateGroupNav(currentGroup, isGallery, {
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.features.groupUI) {
-        return {simple: true};
-    }
-
-    const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
-        linkKeyGallery: 'groupGallery',
-        linkKeyInfo: 'groupInfo'
-    });
-
-    const previousNextLinks = generatePreviousNextLinks(currentGroup, {
-        data: groupData,
-        linkKey
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('groupPage.nav.group', {
-                    group: link[linkKey](currentGroup, {class: 'current'})
-                })
-            },
-            {
-                divider: false,
-                html: (previousNextLinks
-                    ? `(${infoGalleryLinks}; ${previousNextLinks})`
-                    : `(${previousNextLinks})`)
-            }
-        ]
-    };
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'albums/fandom'],
+        toPath: ['groupGallery', 'fandom'],
+        title: 'Fandom - Gallery',
+      },
+
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'albums/official'],
+        toPath: ['groupGallery', 'official'],
+        title: 'Official - Gallery',
+      },
+  ];
 }
diff --git a/src/page/homepage.js b/src/page/homepage.js
index e60256d..53ee6e4 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -1,125 +1,15 @@
-// Homepage specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import find from '../util/find.js';
-
-import * as html from '../util/html.js';
-
-import {
-    getNewAdditions,
-    getNewReleases
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function writeTargetless({wikiData}) {
-    const { newsData, staticPageData, homepageInfo, wikiInfo } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['home'],
-        page: ({
-            getAlbumGridHTML,
-            getLinkThemeString,
-            link,
-            strings,
-            to,
-            transformInline,
-            transformMultiline
-        }) => ({
-            title: wikiInfo.name,
-
-            meta: {
-                description: wikiInfo.description
-            },
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${wikiInfo.name}</h1>
-                    ${homepageInfo.rows.map((row, i) => fixWS`
-                        <section class="row" style="${getLinkThemeString(row.color)}">
-                            <h2>${row.name}</h2>
-                            ${row.type === 'albums' && fixWS`
-                                <div class="grid-listing">
-                                    ${getAlbumGridHTML({
-                                        entries: (
-                                            row.group === 'new-releases' ? getNewReleases(row.groupCount, {wikiData}) :
-                                            row.group === 'new-additions' ? getNewAdditions(row.groupCount, {wikiData}) :
-                                            ((find.group(row.group, {wikiData})?.albums || [])
-                                                .slice()
-                                                .reverse()
-                                                .slice(0, row.groupCount)
-                                                .map(album => ({item: album})))
-                                        ).concat(row.albums
-                                            .map(album => find.album(album, {wikiData}))
-                                            .map(album => ({item: album}))
-                                        ),
-                                        lazy: i > 0
-                                    })}
-                                    ${row.actions.length && fixWS`
-                                        <div class="grid-actions">
-                                            ${row.actions.map(action => transformInline(action)
-                                                .replace('<a', '<a class="box grid-item"')).join('\n')}
-                                        </div>
-                                    `}
-                                </div>
-                            `}
-                        </section>
-                    `).join('\n')}
-                `
-            },
-
-            sidebarLeft: homepageInfo.sidebar && {
-                wide: true,
-                collapse: false,
-                // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
-                // gets treated like it's a reference to the track named "news",
-                // which o8viously isn't what we're going for. Gotta catch that
-                // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
-                // get repl8ced with just the word "news" (or anything else that
-                // transformMultiline does with references it can't match) -- and
-                // we can't match that for replacing it with the news column!
-                //
-                // And no, I will not make [[news]] into part of transformMultiline
-                // (even though that would 8e hilarious).
-                content: (transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__'))
-                    .replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS`
-                        <h1>${strings('homepage.news.title')}</h1>
-                        ${newsData.slice(0, 3).map((entry, i) => html.tag('article',
-                            {class: ['news-entry', i === 0 && 'first-news-entry']},
-                            fixWS`
-                                <h2><time>${strings.count.date(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.bodyShort)}
-                                ${entry.bodyShort !== entry.body && link.newsEntry(entry, {
-                                    text: strings('homepage.news.entry.viewRest')
-                                })}
-                            `)).join('\n')}
-                    ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`))
-            },
-
-            nav: {
-                content: fixWS`
-                    <h2 class="dot-between-spans">
-                        ${[
-                            link.home('', {text: wikiInfo.shortName, class: 'current', to}),
-                            wikiInfo.features.listings &&
-                            link.listingIndex('', {text: strings('listingIndex.title'), to}),
-                            wikiInfo.features.news &&
-                            link.newsIndex('', {text: strings('newsIndex.title'), to}),
-                            wikiInfo.features.flashesAndGames &&
-                            link.flashIndex('', {text: strings('flashIndex.title'), to}),
-                            ...staticPageData.filter(page => page.listed).map(page =>
-                                link.staticPage(page, {text: page.shortName}))
-                        ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')}
-                    </h2>
-                `
-            }
-        })
-    };
-
-    return [page];
+export const description = `main wiki homepage`;
+
+export function pathsTargetless({wikiData}) {
+  return [
+    {
+      type: 'page',
+      path: ['home'],
+
+      contentFunction: {
+        name: 'generateWikiHomePage',
+        args: [wikiData.homepageLayout],
+      },
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbe..21d93c8 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,49 +1,8 @@
-// NB: This is the index for the page/ directory and contains exports for all
-// other modules here! It's not the page spec for the homepage - see
-// homepage.js for that.
-//
-// Each module published in this list should follow a particular format,
-// including any of the following exports:
-//
-// condition({wikiData})
-//     Returns a boolean indicating whether to process targets/writes (true) or
-//     skip this page spec altogether (false). This is usually used for
-//     selectively toggling pages according to site feature flags, though it may
-//     also be used to e.g. skip out if no targets would be found (preventing
-//     writeTargetless from generating an empty index page).
-//
-// targets({wikiData})
-//     Gets the objects which this page's write() function should be called on.
-//     Usually this will simply mean returning the appropriate thingData array,
-//     but it may also apply filter/map/etc if useful.
-//
-// write(thing, {wikiData})
-//     Provides descriptors for any page and data writes associated with the
-//     given thing (which will be a value from the targets() array). This
-//     includes page (HTML) writes, data (JSON) writes, etc. Notably, this
-//     function does not perform any file operations itself; it only describes
-//     the operations which will be processed elsewhere, once for each
-//     translation language.  The write function also immediately transforms
-//     any data which will be reused across writes of the same page, so that
-//     this data is effectively cached (rather than recalculated for each
-//     language/write).
-//
-// writeTargetless({wikiData})
-//     Provides descriptors for page/data/etc writes which will be used
-//     without concern for targets. This is usually used for writing index pages
-//     which should be generated just once (rather than corresponding to
-//     targets).
-//
-// As these modules are effectively the HTML templates for all site layout,
-// common patterns may also be exported alongside the special exports above.
-// These functions should be referenced only from adjacent modules, as they
-// pertain only to site page generation.
-
 export * as album from './album.js';
-export * as albumCommentary from './album-commentary.js';
 export * as artist from './artist.js';
 export * as artistAlias from './artist-alias.js';
 export * as flash from './flash.js';
+export * as flashAct from './flash-act.js';
 export * as group from './group.js';
 export * as homepage from './homepage.js';
 export * as listing from './listing.js';
diff --git a/src/page/listing.js b/src/page/listing.js
index d3ab79e..bb22c21 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -1,207 +1,41 @@
-// Listing page specification.
-//
+export const description = `wiki-wide listing pages & index`;
+
 // The targets here are a bit different than for most pages: rather than data
 // objects loaded from text files in the wiki data directory, they're hard-
-// coded specifications, with various JS functions for processing wiki data
-// and turning it into user-readable HTML listings.
+// coded specifications, each directly identifying the hard-coded content
+// function used to generate that listing.
 //
 // Individual listing specs are described in src/listing-spec.js, but are
 // provided via wikiData like other (normal) data objects.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    getTotalDuration
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.listings;
-}
-
+//
 export function targets({wikiData}) {
-    return wikiData.listingSpec;
+  return (
+    wikiData.listingSpec
+      .filter(listing => listing.contentFunction)
+      .filter(listing =>
+        !listing.featureFlag ||
+        wikiData.wikiInfo[listing.featureFlag]));
 }
 
-export function write(listing, {wikiData}) {
-    if (listing.condition && !listing.condition({wikiData})) {
-        return null;
-    }
-
-    const { wikiInfo } = wikiData;
-
-    const data = (listing.data
-        ? listing.data({wikiData})
-        : null);
-
-    const page = {
-        type: 'page',
-        path: ['listing', listing.directory],
-        page: opts => {
-            const { getLinkThemeString, link, strings } = opts;
-            const titleKey = `listingPage.${listing.stringsKey}.title`;
-
-            return {
-                title: strings(titleKey),
-
-                main: {
-                    content: fixWS`
-                        <h1>${strings(titleKey)}</h1>
-                        ${listing.html && (listing.data
-                            ? listing.html(data, opts)
-                            : listing.html(opts))}
-                        ${listing.row && fixWS`
-                            <ul>
-                                ${(data
-                                    .map(item => listing.row(item, opts))
-                                    .map(row => `<li>${row}</li>`)
-                                    .join('\n'))}
-                            </ul>
-                        `}
-                    `
-                },
-
-                sidebarLeft: {
-                    content: generateSidebarForListings(listing, {
-                        getLinkThemeString,
-                        link,
-                        strings,
-                        wikiData
-                    })
-                },
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.listingIndex'],
-                            title: strings('listingIndex.title')
-                        },
-                        {toCurrentPage: true}
-                    ]
-                }
-            };
-        }
-    };
-
-    return [page];
-}
-
-export function writeTargetless({wikiData}) {
-    const { albumData, trackData, wikiInfo } = wikiData;
-
-    const releasedTracks = trackData.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const releasedAlbums = albumData.filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const duration = getTotalDuration(releasedTracks);
-
-    const page = {
-        type: 'page',
-        path: ['listingIndex'],
-        page: ({
-            getLinkThemeString,
-            strings,
-            link
-        }) => ({
-            title: strings('listingIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('listingIndex.title')}</h1>
-                    <p>${strings('listingIndex.infoLine', {
-                        wiki: wikiInfo.name,
-                        tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                        albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                        duration: `<b>${strings.count.duration(duration, {approximate: true, unit: true})}</b>`
-                    })}</p>
-                    <hr>
-                    <p>${strings('listingIndex.exploreList')}</p>
-                    ${generateLinkIndexForListings(null, false, {link, strings, wikiData})}
-                `
-            },
-
-            sidebarLeft: {
-                content: generateSidebarForListings(null, {
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    wikiData
-                })
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-};
-
-// Utility functions
-
-function generateSidebarForListings(currentListing, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    return fixWS`
-        <h1>${link.listingIndex('', {text: strings('listingIndex.title')})}</h1>
-        ${generateLinkIndexForListings(currentListing, true, {
-            getLinkThemeString,
-            link,
-            strings,
-            wikiData
-        })}
-    `;
+export function pathsForTarget(listing) {
+  return [
+    {
+      type: 'page',
+      path: ['listing', listing.directory],
+      contentFunction: {
+        name: listing.contentFunction,
+        args: [listing],
+      },
+    },
+  ];
 }
 
-function generateLinkIndexForListings(currentListing, forSidebar, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    const { listingTargetSpec, wikiInfo } = wikiData;
-
-    const filteredByCondition = listingTargetSpec
-        .map(({ listings, ...rest }) => ({
-            ...rest,
-            listings: listings.filter(({ condition: c }) => !c || c({wikiData}))
-        }))
-        .filter(({ listings }) => listings.length > 0);
-
-    const genUL = listings => html.tag('ul',
-        listings.map(listing => html.tag('li',
-            {class: [listing === currentListing && 'current']},
-            link.listing(listing, {text: strings(`listingPage.${listing.stringsKey}.title.short`)})
-        )));
-
-    if (forSidebar) {
-        return filteredByCondition.map(({ title, listings }) =>
-            html.tag('details', {
-                open: !forSidebar || listings.includes(currentListing),
-                class: listings.includes(currentListing) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(wikiInfo.color)},
-                    html.tag('span',
-                        {class: 'group-name'},
-                        title({strings}))),
-                genUL(listings)
-            ])).join('\n');
-    } else {
-        return html.tag('dl',
-            filteredByCondition.flatMap(({ title, listings }) => [
-                html.tag('dt', title({strings})),
-                html.tag('dd', genUL(listings))
-            ]));
-    }
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['listingIndex'],
+      contentFunction: {name: 'generateListingsIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/news.js b/src/page/news.js
index 99cbe8d..194ffdc 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -1,129 +1,32 @@
-// News entry & index page specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `per-entry news pages & index`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.news;
+  return wikiData.wikiInfo.enableNews;
 }
 
 export function targets({wikiData}) {
-    return wikiData.newsData;
+  return wikiData.newsData;
 }
 
-export function write(entry, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['newsEntry', entry.directory],
-        page: ({
-            generatePreviousNextLinks,
-            link,
-            strings,
-            transformMultiline,
-        }) => ({
-            title: strings('newsEntryPage.title', {entry: entry.name}),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('newsEntryPage.title', {entry: entry.name})}</h1>
-                        <p>${strings('newsEntryPage.published', {date: strings.count.date(entry.date)})}</p>
-                        ${transformMultiline(entry.body)}
-                    </div>
-                `
-            },
-
-            nav: generateNewsEntryNav(entry, {
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+export function pathsForTarget(newsEntry) {
+  return [
+    {
+      type: 'page',
+      path: ['newsEntry', newsEntry.directory],
+      contentFunction: {
+        name: 'generateNewsEntryPage',
+        args: [newsEntry],
+      },
+    },
+  ];
 }
 
-export function writeTargetless({wikiData}) {
-    const { newsData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['newsIndex'],
-        page: ({
-            link,
-            strings,
-            transformMultiline
-        }) => ({
-            title: strings('newsIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content news-index">
-                        <h1>${strings('newsIndex.title')}</h1>
-                        ${newsData.map(entry => fixWS`
-                            <article id="${entry.directory}">
-                                <h2><time>${strings.count.date(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.bodyShort)}
-                                ${entry.bodyShort !== entry.body && `<p>${link.newsEntry(entry, {
-                                    text: strings('newsIndex.entry.viewRest')
-                                })}</p>`}
-                            </article>
-                        `).join('\n')}
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateNewsEntryNav(entry, {
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { wikiInfo, newsData } = wikiData;
-
-    // The newsData list is sorted reverse chronologically (newest ones first),
-    // so the way we find next/previous entries is flipped from normal.
-    const previousNextLinks = generatePreviousNextLinks(entry, {
-        link, strings,
-        data: newsData.slice().reverse(),
-        linkKey: 'newsEntry'
-    });
-
-    return {
-        links: [
-            {
-                path: ['localized.home'],
-                title: wikiInfo.shortName
-            },
-            {
-                path: ['localized.newsIndex'],
-                title: strings('newsEntryPage.nav.news')
-            },
-            {
-                html: strings('newsEntryPage.nav.entry', {
-                    date: strings.count.date(entry.date),
-                    entry: link.newsEntry(entry, {class: 'current'})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ]
-    };
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['newsIndex'],
+      contentFunction: {name: 'generateNewsIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/static.js b/src/page/static.js
index ff57c4f..c9d806f 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -1,40 +1,22 @@
-// Static content page specification. (These are static pages coded into the
-// wiki data folder, used for a variety of purposes, e.g. wiki info,
-// changelog, and so on.)
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `static wiki-wide content pages specified in data`;
 
+// Static pages are written in the wiki's data folder and contain content and
+// basic page metadata. They're used for a variety of purposes, such as an
+// "about" page, a changelog, links to places beyond the wiki, and so on.
 export function targets({wikiData}) {
-    return wikiData.staticPageData;
+  return wikiData.staticPageData;
 }
 
-export function write(staticPage, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['staticPage', staticPage.directory],
-        page: ({
-            strings,
-            transformMultiline
-        }) => ({
-            title: staticPage.name,
-            stylesheet: staticPage.stylesheet,
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${staticPage.name}</h1>
-                        ${transformMultiline(staticPage.content)}
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
+export function pathsForTarget(staticPage) {
+  return [
+    {
+      type: 'page',
+      path: ['staticPage', staticPage.directory],
+
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
+      },
+    },
+  ];
 }
diff --git a/src/page/tag.js b/src/page/tag.js
index 791c713..8942aea 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -1,110 +1,25 @@
 // Art tag page specification.
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `per-artwork-tag gallery pages`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.artTagUI;
+  return wikiData.wikiInfo.enableArtTagUI;
 }
 
 export function targets({wikiData}) {
-    return wikiData.tagData.filter(tag => !tag.isCW);
-}
-
-export function write(tag, {wikiData}) {
-    const { wikiInfo } = wikiData;
-    const { things } = tag;
-
-    // Display things featuring this art tag in reverse chronological order,
-    // sticking the most recent additions near the top!
-    const thingsReversed = things.slice().reverse();
-
-    const entries = thingsReversed.map(item => ({item}));
-
-    const page = {
-        type: 'page',
-        path: ['tag', tag.directory],
-        page: ({
-            generatePreviousNextLinks,
-            getAlbumCover,
-            getGridHTML,
-            getThemeString,
-            getTrackCover,
-            link,
-            strings,
-            to
-        }) => ({
-            title: strings('tagPage.title', {tag: tag.name}),
-            theme: getThemeString(tag.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('tagPage.title', {tag: tag.name})}</h1>
-                    <p class="quick-info">${strings('tagPage.infoLine', {
-                        coverArts: strings.count.coverArts(things.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            entries,
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            linkFn: (thing, opts) => (thing.album
-                                ? link.track(thing, opts)
-                                : link.album(thing, opts))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateTagNav(tag, {
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+  return wikiData.artTagData.filter((tag) => !tag.isContentWarning);
 }
 
-// Utility functions
-
-function generateTagNav(tag, {
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const previousNextLinks = generatePreviousNextLinks(tag, {
-        data: wikiData.tagData.filter(tag => !tag.isCW),
-        linkKey: 'tag'
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            wikiData.wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('tagPage.nav.tag', {
-                    tag: link.tag(tag, {class: 'current'})
-                })
-            },
-            /*
-            previousNextLinks && {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-            */
-        ]
-    };
+export function pathsForTarget(tag) {
+  return [
+    {
+      type: 'page',
+      path: ['tag', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagGalleryPage',
+        args: [tag],
+      },
+    },
+  ];
 }
diff --git a/src/page/track.js b/src/page/track.js
index 0941ee8..e75b695 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,330 +1,21 @@
 // Track page specification.
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    generateAlbumChronologyLinks,
-    generateAlbumNavLinks,
-    generateAlbumSidebar
-} from './album.js';
-
-import * as html from '../util/html.js';
-
-import {
-    OFFICIAL_GROUP_DIRECTORY,
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    bindOpts
-} from '../util/sugar.js';
-
-import {
-    getTrackCover,
-    getAlbumListTag,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
-    return wikiData.trackData;
+  return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-    const { album } = track;
-
-    const tracksThatReference = track.referencedBy;
-    const useDividedReferences = groupData.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY);
-    const ttrFanon = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY)));
-    const ttrOfficial = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY)));
-
-    const tracksReferenced = track.references;
-    const otherReleases = track.otherReleases;
-    const listTag = getAlbumListTag(album);
-
-    let flashesThatFeature;
-    if (wikiInfo.features.flashesAndGames) {
-        flashesThatFeature = sortByDate([track, ...otherReleases]
-            .flatMap(track => track.flashes.map(flash => ({flash, as: track}))));
-    }
-
-    const unbound_generateTrackList = (tracks, {getArtistString, link, strings}) => html.tag('ul',
-        tracks.map(track => {
-            const line = strings('trackList.item.withArtists', {
-                track: link.track(track),
-                by: `<span class="by">${strings('trackList.item.withArtists.by', {
-                    artists: getArtistString(track.artists)
-                })}</span>`
-            });
-            return (track.aka
-                ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>`
-                : `<li>${line}</li>`);
-        })
-    );
-
-    const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
-    const generateCommentary = ({
-        link,
-        strings,
-        transformMultiline
-    }) => transformMultiline([
-        track.commentary,
-        ...otherReleases.map(track =>
-            (track.commentary?.split('\n')
-                .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
-                .map(line => fixWS`
-                    ${line}
-                    ${strings('releaseInfo.artistCommentary.seeOriginalRelease', {
-                        original: link.track(track)
-                    })}
-                `)
-                .join('\n')))
-    ].filter(Boolean).join('\n'));
-
-    const data = {
-        type: 'data',
-        path: ['track', track.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForTrack,
-            serializeLink
-        }) => ({
-            name: track.name,
-            directory: track.directory,
-            dates: {
-                released: track.date,
-                originallyReleased: track.originalDate,
-                coverArtAdded: track.coverArtDate
-            },
-            duration: track.duration,
-            color: track.color,
-            cover: serializeCover(track, getTrackCover),
-            artists: serializeContribs(track.artists),
-            contributors: serializeContribs(track.contributors),
-            coverArtists: serializeContribs(track.coverArtists || []),
-            album: serializeLink(track.album),
-            groups: serializeGroupsForTrack(track),
-            references: track.references.map(serializeLink),
-            referencedBy: track.referencedBy.map(serializeLink),
-            alsoReleasedAs: otherReleases.map(track => ({
-                track: serializeLink(track),
-                album: serializeLink(track.album)
-            }))
-        })
-    };
-
-    const page = {
-        type: 'page',
-        path: ['track', track.directory],
-        page: ({
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            getTrackCover,
-            link,
-            strings,
-            transformInline,
-            transformLyrics,
-            transformMultiline,
-            to
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {getArtistString, link, strings});
-
-            return {
-                title: strings('trackPage.title', {track: track.name}),
-                stylesheet: getAlbumStylesheet(album, {to}),
-                theme: getThemeString(track.color, [
-                    `--album-directory: ${album.directory}`,
-                    `--track-directory: ${track.directory}`
-                ]),
-
-                // disabled for now! shifting banner position per height of page is disorienting
-                /*
-                banner: album.bannerArtists && {
-                    classes: ['dim'],
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory],
-                    alt: strings('misc.alt.albumBanner'),
-                    position: 'bottom'
-                },
-                */
-
-                main: {
-                    content: fixWS`
-                        ${generateCoverLink({
-                            src: getTrackCover(track),
-                            alt: strings('misc.alt.trackCover'),
-                            tags: track.artTags
-                        })}
-                        <h1>${strings('trackPage.title', {track: track.name})}</h1>
-                        <p>
-                            ${[
-                                strings('releaseInfo.by', {
-                                    artists: getArtistString(track.artists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                track.coverArtists && strings('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(track.coverArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.directory !== UNRELEASED_TRACKS_DIRECTORY && strings('releaseInfo.released', {
-                                    date: strings.count.date(track.date)
-                                }),
-                                +track.coverArtDate !== +track.date && strings('releaseInfo.artReleased', {
-                                    date: strings.count.date(track.coverArtDate)
-                                }),
-                                track.duration && strings('releaseInfo.duration', {
-                                    duration: strings.count.duration(track.duration)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        <p>${
-                            (track.urls.length
-                                ? strings('releaseInfo.listenOn', {
-                                    links: strings.list.or(track.urls.map(url => fancifyURL(url, {strings})))
-                                })
-                                : strings('releaseInfo.listenOn.noLinks'))
-                        }</p>
-                        ${otherReleases.length && fixWS`
-                            <p>${strings('releaseInfo.alsoReleasedAs')}</p>
-                            <ul>
-                                ${otherReleases.map(track => fixWS`
-                                    <li>${strings('releaseInfo.alsoReleasedAs.item', {
-                                        track: link.track(track),
-                                        album: link.album(track.album)
-                                    })}</li>
-                                `).join('\n')}
-                            </ul>
-                        `}
-                        ${track.contributors.textContent && fixWS`
-                            <p>
-                                ${strings('releaseInfo.contributors')}
-                                <br>
-                                ${transformInline(track.contributors.textContent)}
-                            </p>
-                        `}
-                        ${track.contributors.length && fixWS`
-                            <p>${strings('releaseInfo.contributors')}</p>
-                            <ul>
-                                ${(track.contributors
-                                    .map(contrib => `<li>${getArtistString([contrib], {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })}</li>`)
-                                    .join('\n'))}
-                            </ul>
-                        `}
-                        ${tracksReferenced.length && fixWS`
-                            <p>${strings('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p>
-                            ${generateTrackList(tracksReferenced)}
-                        `}
-                        ${tracksThatReference.length && fixWS`
-                            <p>${strings('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p>
-                            ${useDividedReferences && fixWS`
-                                <dl>
-                                    ${ttrOfficial.length && fixWS`
-                                        <dt>${strings('trackPage.referenceList.official')}</dt>
-                                        <dd>${generateTrackList(ttrOfficial)}</dd>
-                                    `}
-                                    ${ttrFanon.length && fixWS`
-                                        <dt>${strings('trackPage.referenceList.fandom')}</dt>
-                                        <dd>${generateTrackList(ttrFanon)}</dd>
-                                    `}
-                                </dl>
-                            `}
-                            ${!useDividedReferences && generateTrackList(tracksThatReference)}
-                        `}
-                        ${wikiInfo.features.flashesAndGames && flashesThatFeature.length && fixWS`
-                            <p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>
-                            <ul>
-                                ${flashesThatFeature.map(({ flash, as }) => html.tag('li',
-                                    {class: as !== track && 'rerelease'},
-                                    (as === track
-                                        ? strings('releaseInfo.flashesThatFeature.item', {
-                                            flash: link.flash(flash)
-                                        })
-                                        : strings('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                                            flash: link.flash(flash),
-                                            track: link.track(as)
-                                        })))).join('\n')}
-                            </ul>
-                        `}
-                        ${track.lyrics && fixWS`
-                            <p>${strings('releaseInfo.lyrics')}</p>
-                            <blockquote>
-                                ${transformLyrics(track.lyrics)}
-                            </blockquote>
-                        `}
-                        ${hasCommentary && fixWS`
-                            <p>${strings('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${generateCommentary({link, strings, transformMultiline})}
-                            </blockquote>
-                        `}
-                    `
-                },
-
-                sidebarLeft: generateAlbumSidebar(album, track, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    transformMultiline,
-                    wikiData
-                }),
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.album', album.directory],
-                            title: album.name
-                        },
-                        listTag === 'ol' ? {
-                            html: strings('trackPage.nav.track.withNumber', {
-                                number: album.tracks.indexOf(track) + 1,
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        } : {
-                            html: strings('trackPage.nav.track', {
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, track, {
-                                generatePreviousNextLinks,
-                                strings
-                            })
-                        }
-                    ].filter(Boolean),
-                    content: fixWS`
-                        <div>
-                            ${generateAlbumChronologyLinks(album, track, {generateChronologyLinks})}
-                        </div>
-                    `
-                }
-            };
-        }
-    };
-
-    return [data, page];
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
+
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        args: [track],
+      },
+    },
+  ];
 }
-