« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/page/album.js381
-rw-r--r--src/page/index.js5
-rwxr-xr-xsrc/upd8.js576
-rw-r--r--src/util/serialize.js71
-rw-r--r--src/util/wiki-data.js31
5 files changed, 578 insertions, 486 deletions
diff --git a/src/page/album.js b/src/page/album.js
new file mode 100644
index 00000000..f8597790
--- /dev/null
+++ b/src/page/album.js
@@ -0,0 +1,381 @@
+// Album page specification.
+//
+// Also exports utility functions:
+//   - generateAlbumSidebar
+//   - generateAlbumNavLinks
+//   - generateAlbumChronologyLinks
+
+import fixWS from 'fix-whitespace';
+
+import {
+    getAlbumCover,
+    getAlbumListTag,
+    getTotalDuration
+} from '../util/wiki-data.js';
+
+import {
+    getLinkThemeString,
+    getThemeString
+} from '../util/colors.js';
+
+import * as html from '../util/html.js';
+
+export function targets({wikiData}) {
+    return wikiData.albumData;
+}
+
+export function write(album, {wikiData}) {
+    const { wikiInfo } = wikiData;
+
+    const trackToListItem = (track, {getArtistString, 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>`;
+    };
+
+    const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
+    const albumDuration = getTotalDuration(album.tracks);
+
+    const listTag = getAlbumListTag(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
+            }))
+        })
+    };
+
+    const page = {
+        type: 'page',
+        path: ['album', album.directory],
+        page: ({
+            chronologyLinks,
+            fancifyURL,
+            generateCoverLink,
+            getAlbumStylesheet,
+            getArtistString,
+            link,
+            strings,
+            transformMultiline
+        }) => ({
+            title: strings('albumPage.title', {album: album.name}),
+            stylesheet: getAlbumStylesheet(album),
+            theme: getThemeString(album.color, [
+                `--album-directory: ${album.directory}`
+            ]),
+
+            banner: album.bannerArtists && {
+                dimensions: album.bannerDimensions,
+                path: ['media.albumBanner', album.directory],
+                alt: strings('misc.alt.albumBanner'),
+                position: 'top'
+            },
+
+            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(t => trackToListItem(t, {getArtistString, link, strings})).join('\n')}
+                                </${listTag}></dd>
+                            `).join('\n')}
+                        </dl>
+                    ` : fixWS`
+                        <${listTag}>
+                            ${album.tracks.map(t => trackToListItem(t, {getArtistString, link, strings})).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>
+                    `}
+                `
+            },
+
+            sidebarLeft: generateAlbumSidebar(album, null, {
+                fancifyURL,
+                link,
+                strings,
+                transformMultiline,
+                wikiData
+            }),
+
+            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, {link, strings})
+                    }
+                ],
+                content: html.tag('div', generateAlbumChronologyLinks(album, null, {chronologyLinks}))
+            }
+        })
+    };
+
+    return [page, data];
+}
+
+export function generateAlbumSidebar(album, currentTrack, {
+    fancifyURL,
+    link,
+    strings,
+    transformMultiline,
+    wikiData
+}) {
+    const listTag = getAlbumListTag(album);
+
+    const trackGroups = album.trackGroups || [{
+        name: strings('albumSidebar.trackList.fallbackGroupName'),
+        color: album.color,
+        startIndex: 0,
+        tracks: album.tracks
+    }];
+
+    const trackToListItem = track => html.tag('li',
+        {class: track === currentTrack && 'current'},
+        strings('albumSidebar.trackList.item', {
+            track: link.track(track)
+        }));
+
+    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')}
+    `;
+
+    const { groups } = album;
+
+    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)
+                })
+            }</p>`}
+        `}
+    `);
+
+    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
+        };
+    }
+}
+
+export function generateAlbumNavLinks(album, currentTrack, {link, strings}) {
+    if (album.tracks.length <= 1) {
+        return '';
+    }
+
+    const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, {
+        link, strings,
+        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>`;
+
+    return (previousNextLinks
+        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
+        : `<span class="js-hide-until-data">(${randomLink})</span>`);
+}
+
+export function generateAlbumChronologyLinks(album, currentTrack, {chronologyLinks}) {
+    return [
+        currentTrack && chronologyLinks(currentTrack, {
+            contribKey: 'artists',
+            getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor],
+            headingString: 'misc.chronology.heading.track'
+        }),
+        chronologyLinks(currentTrack || album, {
+            contribKey: 'coverArtists',
+            getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist],
+            headingString: 'misc.chronology.heading.coverArt'
+        })
+    ].filter(Boolean).join('\n');
+}
diff --git a/src/page/index.js b/src/page/index.js
new file mode 100644
index 00000000..ca782389
--- /dev/null
+++ b/src/page/index.js
@@ -0,0 +1,5 @@
+// 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.
+
+export * as album from './album.js';
diff --git a/src/upd8.js b/src/upd8.js
index 94fab3b8..621cae57 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -109,8 +109,10 @@ import {
     unlink
 } from 'fs/promises';
 
-import find from './util/find.js';
 import genThumbs from './gen-thumbs.js';
+import * as pageSpecs from './page/index.js';
+
+import find from './util/find.js';
 import * as html from './util/html.js';
 import unbound_link from './util/link.js';
 
@@ -142,16 +144,30 @@ import {
 import {
     chunkByConditions,
     chunkByProperties,
+    getAlbumCover,
+    getAlbumListTag,
     getAllTracks,
     getArtistCommentary,
     getArtistNumContributions,
+    getFlashCover,
     getKebabCase,
+    getTotalDuration,
+    getTrackCover,
     sortByArtDate,
     sortByDate,
     sortByName
 } from './util/wiki-data.js';
 
 import {
+    serializeContribs,
+    serializeCover,
+    serializeGroupsForAlbum,
+    serializeGroupsForTrack,
+    serializeImagePaths,
+    serializeLink
+} from './util/serialize.js';
+
+import {
     bindOpts,
     call,
     filterEmptyLines,
@@ -1488,10 +1504,6 @@ function getDurationInSeconds(string) {
     }
 }
 
-function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
-}
-
 const stringifyIndent = 0;
 
 const toRefs = (label, objectOrArray) => {
@@ -1650,71 +1662,6 @@ function img({
     }
 }
 
-function serializeImagePaths(original) {
-    return {
-        original,
-        medium: thumb.medium(original),
-        small: thumb.small(original)
-    };
-}
-
-function serializeLink(thing) {
-    const ret = {};
-    ret.name = thing.name;
-    ret.directory = thing.directory;
-    if (thing.color) ret.color = thing.color;
-    return ret;
-}
-
-function serializeContribs(contribs) {
-    return contribs.map(({ who, what }) => {
-        const ret = {};
-        ret.artist = serializeLink(who);
-        if (what) ret.contribution = what;
-        return ret;
-    });
-}
-
-function serializeCover(thing, pathFunction) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
-
-    const { artTags } = thing;
-
-    const cwTags = artTags.filter(tag => tag.isCW);
-    const linkTags = artTags.filter(tag => !tag.isCW);
-
-    return {
-        paths: serializeImagePaths(coverPath),
-        tags: linkTags.map(serializeLink),
-        warnings: cwTags.map(tag => tag.name)
-    };
-}
-
-function serializeGroupsForAlbum(album) {
-    return album.groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1] || null;
-        const previous = group.albums[index - 1] || null;
-        return {group, index, next, previous};
-    }).map(({group, index, next, previous}) => ({
-        link: serializeLink(group),
-        descriptionShort: group.descriptionShort,
-        albumIndex: index,
-        nextAlbum: next && serializeLink(next),
-        previousAlbum: previous && serializeLink(previous),
-        urls: group.urls
-    }));
-}
-
-function serializeGroupsForTrack(track) {
-    return track.album.groups.map(group => ({
-        link: serializeLink(group),
-        urls: group.urls,
-    }));
-}
-
 function validateWritePath(path, urlGroup) {
     if (!Array.isArray(path)) {
         return {error: `Expected array, got ${path}`};
@@ -2686,218 +2633,6 @@ function writeIndexAndTrackPagesForAlbum(album) {
 }
 */
 
-function writeAlbumPages({wikiData}) {
-    return wikiData.albumData.map(album => writeAlbumPage(album, {wikiData}));
-}
-
-function writeAlbumPage(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const trackToListItem = (track, {getArtistString, 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>`;
-    };
-
-    const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
-    const albumDuration = getTotalDuration(album.tracks);
-
-    const listTag = getAlbumListTag(album);
-
-    const data = {
-        type: 'data',
-        path: ['album', album.directory],
-        data: () => ({
-            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
-            }))
-        })
-    };
-
-    const page = {
-        type: 'page',
-        path: ['album', album.directory],
-        page: ({
-            generateCoverLink,
-            getAlbumStylesheet,
-            getArtistString,
-            link,
-            strings,
-            transformMultiline
-        }) => ({
-            title: strings('albumPage.title', {album: album.name}),
-            stylesheet: getAlbumStylesheet(album),
-            theme: getThemeString(album.color, [
-                `--album-directory: ${album.directory}`
-            ]),
-
-            banner: album.bannerArtists && {
-                dimensions: album.bannerDimensions,
-                path: ['media.albumBanner', album.directory],
-                alt: strings('misc.alt.albumBanner'),
-                position: 'top'
-            },
-
-            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, strings})))
-                        })
-                    }</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(t => trackToListItem(t, {getArtistString, link, strings})).join('\n')}
-                                </${listTag}></dd>
-                            `).join('\n')}
-                        </dl>
-                    ` : fixWS`
-                        <${listTag}>
-                            ${album.tracks.map(t => trackToListItem(t, {getArtistString, link, strings})).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>
-                    `}
-                `
-            },
-
-            sidebarLeft: generateSidebarForAlbum(album, {
-                link,
-                strings,
-                transformMultiline,
-                wikiData
-            }),
-
-            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, {link, strings})
-                    }
-                ],
-                content: fixWS`
-                    <div>
-                        ${generateAlbumChronologyLinks(album, null, {link, strings})}
-                    </div>
-                `
-            }
-        })
-    };
-
-    return [page, data];
-}
-
 function getAlbumStylesheet(album, {to}) {
     return [
         album.wallpaperArtists && fixWS`
@@ -5068,14 +4803,6 @@ function getTagDirectory({name}) {
     return getKebabCase(name);
 }
 
-function getAlbumListTag(album) {
-    if (album.directory === UNRELEASED_TRACKS_DIRECTORY) {
-        return 'ul';
-    } else {
-        return 'ol';
-    }
-}
-
 function fancifyURL(url, {strings, album = false} = {}) {
     const domain = new URL(url).hostname;
     return fixWS`<a href="${url}" class="nowrap">${
@@ -5196,156 +4923,6 @@ function chronologyLinks(currentThing, {
     }).filter(Boolean).join('\n');
 }
 
-function generateAlbumNavLinks(album, currentTrack, {link, strings}) {
-    if (album.tracks.length <= 1) {
-        return '';
-    }
-
-    const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, {
-        link, strings,
-        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>`;
-
-    return (previousNextLinks
-        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-        : `<span class="js-hide-until-data">(${randomLink})</span>`);
-}
-
-function generateAlbumChronologyLinks(album, currentTrack, {link, strings}) {
-    return [
-        currentTrack && chronologyLinks(currentTrack, {
-            contribKey: 'artists',
-            getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor],
-            headingString: 'misc.chronology.heading.track',
-            strings,
-            link,
-            wikiData
-        }),
-        chronologyLinks(currentTrack || album, {
-            contribKey: 'coverArtists',
-            getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist],
-            headingString: 'misc.chronology.heading.coverArt',
-            link,
-            strings,
-            wikiData
-        })
-    ].filter(Boolean).join('\n');
-}
-
-function generateSidebarForAlbum(album, {
-    currentTrack = null,
-    link,
-    strings,
-    transformMultiline,
-    wikiData
-}) {
-    const listTag = getAlbumListTag(album);
-
-    const trackGroups = album.trackGroups || [{
-        name: strings('albumSidebar.trackList.fallbackGroupName'),
-        color: album.color,
-        startIndex: 0,
-        tracks: album.tracks
-    }];
-
-    const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}>${
-        strings('albumSidebar.trackList.item', {
-            track: link.track(track)
-        })
-    }</li>`;
-
-    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')}
-    `;
-
-    const { groups } = album;
-
-    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, {strings})))
-            })
-        }</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)
-                })
-            }</p>`}
-        `}
-    `);
-
-    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
-        };
-    }
-}
-
 function generateSidebarForGroup(currentGroup, isGallery, {link, strings, wikiData}) {
     const { groupCategoryData, wikiInfo } = wikiData;
 
@@ -5606,28 +5183,6 @@ function linkAnythingMan(anythingMan, {link, wikiData, ...opts}) {
     )
 }
 
-function getAlbumCover(album, {to}) {
-    return to('media.albumCover', album.directory);
-}
-
-function getTrackCover(track, {to}) {
-    // Some al8ums don't have any track art at all, and in those, every track
-    // just inherits the al8um's own cover art.
-    if (track.coverArtists === null) {
-        return getAlbumCover(track.album, {to});
-    } else {
-        return to('media.trackCover', track.album.directory, track.directory);
-    }
-}
-
-function getFlashCover(flash, {to}) {
-    return to('media.flashArt', flash.directory);
-}
-
-function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
-}
-
 function classes(...args) {
     const values = args.filter(Boolean);
     return `class="${values.join(' ')}"`;
@@ -6316,6 +5871,7 @@ async function main() {
     await writeSymlinks();
     await writeSharedFilesAndPages({strings: defaultStrings, wikiData});
 
+    /*
     const buildDictionary = {
         misc: writeMiscellaneousPages,
         news: writeNewsPages,
@@ -6329,12 +5885,14 @@ async function main() {
         artist: writeArtistPages,
         flash: writeFlashPages
     };
+    */
+
+    const buildDictionary = pageSpecs;
 
     const buildSteps = (writeAll
         ? Object.values(buildDictionary)
         : (Object.entries(buildDictionary)
-            .filter(([ flag ]) => writeFlags[flag])
-            .map(([ flag, fn ]) => fn)));
+            .filter(([ flag ]) => writeFlags[flag])));
 
     // *NB: While what's 8elow is 8asically still true in principle, the
     //      format is QUITE DIFFERENT than what's descri8ed here! There
@@ -6351,18 +5909,27 @@ async function main() {
     {
         let error = false;
 
-        writes = buildSteps.flatMap(fn => {
-            const fns = fn({wikiData}) || [];
+        const targets = buildSteps.flatMap(([ flag, pageSpec ]) => {
+            const targets = pageSpec.targets({wikiData});
+            return targets.map(target => ({flag, pageSpec, target}));
+        });
+
+        const writeArrays = await progressPromiseAll(`Processing build data to be shared across langauges.`, queue(
+            targets.map(({ flag, pageSpec, target }) => () => {
+                const writes = pageSpec.write(target, {wikiData}) || [];
+
+                // Do a quick valid8tion! If one of the writeThingPages functions go
+                // wrong, this will stall out early and tell us which did.
+
+                if (!Array.isArray(writes)) {
+                    logError`${flag + '.write'} didn't return an array!`;
+                    error = true;
+                    return [];
+                }
 
-            // Do a quick valid8tion! If one of the writeThingPages functions go
-            // wrong, this will stall out early and tell us which did.
-            if (!Array.isArray(fns)) {
-                logError`${fn.name} didn't return an array!`;
-                error = true;
-            } else if (fns.every(entry => Array.isArray(entry))) {
                 if (!(
-                    fns.every(entry => entry.every(obj => typeof obj === 'object')) &&
-                    fns.every(entry => entry.every(obj => {
+                    writes.every(obj => typeof obj === 'object') &&
+                    writes.every(obj => {
                         const result = validateWriteObject(obj);
                         if (result.error) {
                             logError`Validating write object failed: ${result.error}`;
@@ -6370,24 +5937,23 @@ async function main() {
                         } else {
                             return true;
                         }
-                    }))
+                    })
                 )) {
-                    logError`${fn.name} uses updated format, but entries are invalid!`;
+                    logError`${flag + '.write'} uses updated format, but entries are invalid!`;
                     error = true;
+                    return [];
                 }
 
-                return fns.flatMap(writes => writes);
-            } else if (fns.some(fn => typeof fn !== 'function')) {
-                logError`${fn.name} didn't return all functions or all arrays!`;
-                error = true;
-            }
-
-            return fns;
-        });
+                return writes;
+            }),
+            queueSize
+        ));
 
         if (error) {
             return;
         }
+
+        writes = writeArrays.flatMap(writes => writes);
     }
 
     const pageWrites = writes.filter(({ type }) => type === 'page');
@@ -6395,8 +5961,36 @@ async function main() {
     const redirectWrites = writes.filter(({ type }) => type === 'redirect');
 
     await progressPromiseAll(`Writing data files shared across languages.`, queue(
-        // TODO: This only supports one <>-style argument.
-        dataWrites.map(({path, data}) => () => writeData(path[0], path[1], data())),
+        dataWrites.map(({path, data}) => () => {
+            const bound = {};
+
+            bound.serializeLink = bindOpts(serializeLink, {});
+
+            bound.serializeContribs = bindOpts(serializeContribs, {});
+
+            bound.serializeImagePaths = bindOpts(serializeImagePaths, {
+                thumb
+            });
+
+            bound.serializeCover = bindOpts(serializeCover, {
+                [bindOpts.bindIndex]: 2,
+                serializeImagePaths: bound.serializeImagePaths,
+                urls
+            });
+
+            bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
+                serializeLink
+            });
+
+            bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
+                serializeLink
+            });
+
+            // TODO: This only supports one <>-style argument.
+            return writeData(path[0], path[1], data({
+                ...bound
+            }));
+        }),
         queueSize
     ));
 
@@ -6453,6 +6047,10 @@ async function main() {
                     to
                 });
 
+                bound.fancifyURL = bindOpts(fancifyURL, {
+                    strings
+                });
+
                 bound.getArtistString = bindOpts(getArtistString, {
                     iconifyURL: bound.iconifyURL,
                     link: bound.link,
@@ -6471,6 +6069,12 @@ async function main() {
                     to
                 });
 
+                bound.chronologyLinks = bindOpts(chronologyLinks, {
+                    link: bound.link,
+                    strings,
+                    wikiData
+                });
+
                 bound.generateCoverLink = bindOpts(generateCoverLink, {
                     [bindOpts.bindIndex]: 0,
                     link: bound.link,
diff --git a/src/util/serialize.js b/src/util/serialize.js
new file mode 100644
index 00000000..7b0f890f
--- /dev/null
+++ b/src/util/serialize.js
@@ -0,0 +1,71 @@
+export function serializeLink(thing) {
+    const ret = {};
+    ret.name = thing.name;
+    ret.directory = thing.directory;
+    if (thing.color) ret.color = thing.color;
+    return ret;
+}
+
+export function serializeContribs(contribs) {
+    return contribs.map(({ who, what }) => {
+        const ret = {};
+        ret.artist = serializeLink(who);
+        if (what) ret.contribution = what;
+        return ret;
+    });
+}
+
+export function serializeImagePaths(original, {thumb}) {
+    return {
+        original,
+        medium: thumb.medium(original),
+        small: thumb.small(original)
+    };
+}
+
+export function serializeCover(thing, pathFunction, {
+    serializeImagePaths,
+    urls
+}) {
+    const coverPath = pathFunction(thing, {
+        to: urls.from('media.root').to
+    });
+
+    const { artTags } = thing;
+
+    const cwTags = artTags.filter(tag => tag.isCW);
+    const linkTags = artTags.filter(tag => !tag.isCW);
+
+    return {
+        paths: serializeImagePaths(coverPath),
+        tags: linkTags.map(serializeLink),
+        warnings: cwTags.map(tag => tag.name)
+    };
+}
+
+export function serializeGroupsForAlbum(album, {
+    serializeLink
+}) {
+    return album.groups.map(group => {
+        const index = group.albums.indexOf(album);
+        const next = group.albums[index + 1] || null;
+        const previous = group.albums[index - 1] || null;
+        return {group, index, next, previous};
+    }).map(({group, index, next, previous}) => ({
+        link: serializeLink(group),
+        descriptionShort: group.descriptionShort,
+        albumIndex: index,
+        nextAlbum: next && serializeLink(next),
+        previousAlbum: previous && serializeLink(previous),
+        urls: group.urls
+    }));
+}
+
+export function serializeGroupsForTrack(track, {
+    serializeLink
+}) {
+    return track.album.groups.map(group => ({
+        link: serializeLink(group),
+        urls: group.urls,
+    }));
+}
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index e76722bd..13b86090 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -89,6 +89,15 @@ export function sortByArtDate(data) {
 
 // Specific data utilities
 
+export function getAlbumCover(album, {to}) {
+    return to('media.albumCover', album.directory);
+}
+
+export function getAlbumListTag(album) {
+    // TODO: This is hard-coded! No. 8ad.
+    return (album.directory === 'unreleased-tracks' ? 'ul' : 'ol');
+}
+
 // This gets all the track o8jects defined in every al8um, and sorts them 8y
 // date released. Generally, albumData will pro8a8ly already 8e sorted 8efore
 // you pass it to this function, 8ut individual tracks can have their own
@@ -124,3 +133,25 @@ export function getArtistCommentary(artist, {justEverythingMan}) {
             .replace(/<\/?b>/g, '')
             .includes('<i>' + artist.name + ':</i>')));
 }
+
+export function getFlashCover(flash, {to}) {
+    return to('media.flashArt', flash.directory);
+}
+
+export function getFlashLink(flash) {
+    return `https://homestuck.com/story/${flash.page}`;
+}
+
+export function getTotalDuration(tracks) {
+    return tracks.reduce((duration, track) => duration + track.duration, 0);
+}
+
+export function getTrackCover(track, {to}) {
+    // Some al8ums don't have any track art at all, and in those, every track
+    // just inherits the al8um's own cover art.
+    if (track.coverArtists === null) {
+        return getAlbumCover(track.album, {to});
+    } else {
+        return to('media.trackCover', track.album.directory, track.directory);
+    }
+}