« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/write
diff options
context:
space:
mode:
Diffstat (limited to 'src/write')
-rw-r--r--src/write/bind-utilities.js268
-rw-r--r--src/write/build-modes/index.js2
-rw-r--r--src/write/build-modes/live-dev-server.js379
-rw-r--r--src/write/build-modes/static-build.js546
-rw-r--r--src/write/page-template.js640
-rw-r--r--src/write/validate-writes.js134
6 files changed, 1969 insertions, 0 deletions
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
new file mode 100644
index 00000000..4b037a91
--- /dev/null
+++ b/src/write/bind-utilities.js
@@ -0,0 +1,268 @@
+// Ties lots and lots of functions together in a convenient package accessible
+// to page write functions. This is kept in a separate file from other write
+// areas to keep imports neat and isolated.
+
+import chroma from 'chroma-js';
+
+import {
+  fancifyFlashURL,
+  fancifyURL,
+  getAlbumGridHTML,
+  getAlbumStylesheet,
+  getArtistString,
+  getCarouselHTML,
+  getFlashGridHTML,
+  getGridHTML,
+  getRevealStringFromTags,
+  getRevealStringFromWarnings,
+  getThemeString,
+  generateAdditionalFilesList,
+  generateAdditionalFilesShortcut,
+  generateChronologyLinks,
+  generateCoverLink,
+  generateInfoGalleryLinks,
+  generateTrackListDividedByGroups,
+  generateNavigationLinks,
+  generateStickyHeadingContainer,
+  iconifyURL,
+  img,
+} from '../misc-templates.js';
+
+import {
+  replacerSpec,
+  transformInline,
+  transformLyrics,
+  transformMultiline,
+} from '../util/transform-content.js';
+
+import * as html from '../util/html.js';
+
+import {bindOpts, withEntries} from '../util/sugar.js';
+import {getColors} from '../util/colors.js';
+import {bindFind} from '../util/find.js';
+
+import link, {getLinkThemeString} from '../util/link.js';
+
+import {
+  getAlbumCover,
+  getArtistAvatar,
+  getFlashCover,
+  getTrackCover,
+} from '../util/wiki-data.js';
+
+export function bindUtilities({
+  absoluteTo,
+  getSizeOfAdditionalFile,
+  language,
+  to,
+  urls,
+  wikiData,
+}) {
+  // TODO: Is there some nicer way to define these,
+  // may8e without totally re-8inding everything for
+  // each page?
+  const bound = {};
+
+  Object.assign(bound, {
+    absoluteTo,
+    getSizeOfAdditionalFile,
+    html,
+    language,
+    to,
+    urls,
+    wikiData,
+  })
+
+  bound.img = bindOpts(img, {
+    [bindOpts.bindIndex]: 0,
+    html,
+  });
+
+  bound.getColors = bindOpts(getColors, {
+    chroma,
+  });
+
+  bound.getLinkThemeString = bindOpts(getLinkThemeString, {
+    getColors: bound.getColors,
+  });
+
+  bound.getThemeString = bindOpts(getThemeString, {
+    getColors: bound.getColors,
+  });
+
+  bound.link = withEntries(link, (entries) =>
+    entries
+      .map(([key, fn]) => [key, bindOpts(fn, {
+        getLinkThemeString: bound.getLinkThemeString,
+        to,
+      })]));
+
+  bound.find = bindFind(wikiData, {mode: 'warn'});
+
+  bound.transformInline = bindOpts(transformInline, {
+    find: bound.find,
+    link: bound.link,
+    replacerSpec,
+    language,
+    to,
+    wikiData,
+  });
+
+  bound.transformMultiline = bindOpts(transformMultiline, {
+    img: bound.img,
+    to,
+    transformInline: bound.transformInline,
+  });
+
+  bound.transformLyrics = bindOpts(transformLyrics, {
+    transformInline: bound.transformInline,
+    transformMultiline: bound.transformMultiline,
+  });
+
+  bound.iconifyURL = bindOpts(iconifyURL, {
+    html,
+    language,
+    to,
+  });
+
+  bound.fancifyURL = bindOpts(fancifyURL, {
+    html,
+    language,
+  });
+
+  bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
+    [bindOpts.bindIndex]: 2,
+    html,
+    language,
+
+    fancifyURL: bound.fancifyURL,
+  });
+
+  bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
+    html,
+    language,
+  });
+
+  bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
+    language,
+
+    getRevealStringFromWarnings: bound.getRevealStringFromWarnings,
+  });
+
+  bound.getArtistString = bindOpts(getArtistString, {
+    html,
+    link: bound.link,
+    language,
+
+    iconifyURL: bound.iconifyURL,
+  });
+
+  bound.getAlbumCover = bindOpts(getAlbumCover, {
+    to,
+  });
+
+  bound.getTrackCover = bindOpts(getTrackCover, {
+    to,
+  });
+
+  bound.getFlashCover = bindOpts(getFlashCover, {
+    to,
+  });
+
+  bound.getArtistAvatar = bindOpts(getArtistAvatar, {
+    to,
+  });
+
+  bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
+    html,
+    language,
+  });
+
+  bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
+    html,
+    language,
+  });
+
+  bound.generateNavigationLinks = bindOpts(generateNavigationLinks, {
+    link: bound.link,
+    language,
+  });
+
+  bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
+    [bindOpts.bindIndex]: 0,
+    getRevealStringFromTags: bound.getRevealStringFromTags,
+    html,
+    img: bound.img,
+  });
+
+  bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
+    html,
+    language,
+    link: bound.link,
+    wikiData,
+
+    generateNavigationLinks: bound.generateNavigationLinks,
+  });
+
+  bound.generateCoverLink = bindOpts(generateCoverLink, {
+    [bindOpts.bindIndex]: 0,
+    html,
+    img: bound.img,
+    link: bound.link,
+    language,
+    to,
+    wikiData,
+
+    getRevealStringFromTags: bound.getRevealStringFromTags,
+  });
+
+  bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
+    [bindOpts.bindIndex]: 2,
+    link: bound.link,
+    language,
+  });
+
+  bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
+    html,
+    language,
+    wikiData,
+  });
+
+  bound.getGridHTML = bindOpts(getGridHTML, {
+    [bindOpts.bindIndex]: 0,
+    img: bound.img,
+    html,
+    language,
+
+    getRevealStringFromTags: bound.getRevealStringFromTags,
+  });
+
+  bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
+    [bindOpts.bindIndex]: 0,
+    link: bound.link,
+    language,
+
+    getAlbumCover: bound.getAlbumCover,
+    getGridHTML: bound.getGridHTML,
+  });
+
+  bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
+    [bindOpts.bindIndex]: 0,
+    link: bound.link,
+
+    getFlashCover: bound.getFlashCover,
+    getGridHTML: bound.getGridHTML,
+  });
+
+  bound.getCarouselHTML = bindOpts(getCarouselHTML, {
+    [bindOpts.bindIndex]: 0,
+    img: bound.img,
+    html,
+  })
+
+  bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
+    to,
+  });
+
+  return bound;
+}
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
new file mode 100644
index 00000000..91e39009
--- /dev/null
+++ b/src/write/build-modes/index.js
@@ -0,0 +1,2 @@
+export * as 'live-dev-server' from './live-dev-server.js';
+export * as 'static-build' from './static-build.js';
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
new file mode 100644
index 00000000..b6bf662b
--- /dev/null
+++ b/src/write/build-modes/live-dev-server.js
@@ -0,0 +1,379 @@
+import * as http from 'http';
+import {createReadStream} from 'fs';
+import {stat} from 'fs/promises';
+import * as path from 'path';
+import {pipeline} from 'stream/promises'
+
+import {bindUtilities} from '../bind-utilities.js';
+
+import {serializeThings} from '../../data/serialize.js';
+
+import * as pageSpecs from '../../page/index.js';
+
+import {logInfo, logWarn, progressCallAll} from '../../util/cli.js';
+import {withEntries} from '../../util/sugar.js';
+
+import {
+  getPagePathname,
+  getPageSubdirectoryPrefix,
+  getURLsFrom,
+  getURLsFromRoot,
+} from '../../util/urls.js';
+
+import {
+  generateDocumentHTML,
+  generateGlobalWikiDataJSON,
+  generateRedirectHTML,
+} from '../page-template.js';
+
+export function getCLIOptions() {
+  return {
+    host: {
+      type: 'value',
+    },
+
+    port: {
+      type: 'value',
+      validate(size) {
+        if (parseInt(size) !== parseFloat(size)) return 'an integer';
+        if (parseInt(size) < 1024 || parseInt(size) > 49151) return 'a user/registered port (1024-49151)';
+        return true;
+      },
+    },
+  };
+}
+
+export async function go({
+  cliOptions,
+  _dataPath,
+  mediaPath,
+
+  defaultLanguage,
+  languages,
+  srcRootPath,
+  urls,
+  wikiData,
+
+  cachebust,
+  developersComment,
+  getSizeOfAdditionalFile,
+}) {
+  const host = cliOptions['host'] ?? '0.0.0.0';
+  const port = parseInt(cliOptions['port'] ?? 8002);
+
+  let targetSpecPairs = getPageSpecsWithTargets({wikiData});
+  const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
+    targetSpecPairs.map(({
+      pageSpec,
+      target,
+      targetless,
+    }) => () =>
+      targetless
+        ? pageSpec.writeTargetless({wikiData})
+        : pageSpec.write(target, {wikiData}))).flat();
+
+  logInfo`Will be serving a total of ${pages.length} pages.`;
+
+  const urlToPageMap = Object.fromEntries(pages
+    .filter(page => page.type === 'page' || page.type === 'redirect')
+    .flatMap(page => {
+      let servePath;
+      if (page.type === 'page')
+        servePath = page.path;
+      else if (page.type === 'redirect')
+        servePath = page.fromPath;
+
+      const fullKey = 'localized.' + servePath[0];
+      const urlArgs = servePath.slice(1);
+
+      return Object.values(languages).map(language => {
+        const baseDirectory =
+          language === defaultLanguage ? '' : language.code;
+
+        const pathname = getPagePathname({
+          baseDirectory,
+          fullKey,
+          urlArgs,
+          urls,
+        });
+
+        return [pathname, {
+          baseDirectory,
+          language,
+          page,
+          servePath,
+        }];
+      });
+    }));
+
+  const server = http.createServer(async (request, response) => {
+    const contentTypeHTML = {'Content-Type': 'text/html; charset=utf-8'};
+    const contentTypeJSON = {'Content-Type': 'application/json; charset=utf-8'};
+    const contentTypePlain = {'Content-Type': 'text/plain; charset=utf-8'};
+
+    const requestTime = new Date().toLocaleDateString('en-US', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
+    const requestHead = `${requestTime} - ${request.socket.remoteAddress}`;
+
+    let url;
+    try {
+      url = new URL(request.url, `http://${request.headers.host}`);
+    } catch (error) {
+      response.writeHead(500, contentTypePlain);
+      response.end('Failed to parse request URL\n');
+      return;
+    }
+
+    const {pathname} = url;
+
+    // Specialized routes
+
+    if (pathname === '/data.json') {
+      try {
+        const json = generateGlobalWikiDataJSON({
+          serializeThings,
+          wikiData,
+        });
+        response.writeHead(200, contentTypeJSON);
+        response.end(json);
+        console.log(`${requestHead} [200] /data.json`);
+      } catch (error) {
+        response.writeHead(500, contentTypeJSON);
+        response.end({error: `Internal error serializing wiki JSON`});
+        console.error(`${requestHead} [500] /data.json`);
+        console.error(error);
+      }
+      return;
+    }
+
+    const {
+      area: localFileArea,
+      path: localFilePath
+    } = pathname.match(/^\/(?<area>static|util|media)\/(?<path>.*)/)?.groups ?? {};
+
+    if (localFileArea) {
+      // Not security tested, man, this is a dev server!!
+      const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, '');
+
+      let localDirectory;
+      if (localFileArea === 'static' || localFileArea === 'util') {
+        localDirectory = path.join(srcRootPath, localFileArea);
+      } else if (localFileArea === 'media') {
+        localDirectory = mediaPath;
+      }
+
+      const filePath = path.resolve(localDirectory, safePath.split('/').join(path.sep));
+
+      try {
+        await stat(filePath);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          response.writeHead(404, contentTypePlain);
+          response.end(`No ${localFileArea} file found for: ${safePath}`);
+          console.log(`${requestHead} [404] ${pathname}`);
+          console.log(`ENOENT for stat: ${filePath}`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          console.error(error);
+        }
+        return;
+      }
+
+      const extname = path.extname(safePath).slice(1).toLowerCase();
+
+      const contentType = {
+        // BRB covering all my bases
+        'aac': 'audio/aac',
+        'bmp': 'image/bmp',
+        'css': 'text/css',
+        'csv': 'text/csv',
+        'gif': 'image/gif',
+        'ico': 'image/vnd.microsoft.icon',
+        'jpg': 'image/jpeg',
+        'jpeg:': 'image/jpeg',
+        'js': 'text/javascript',
+        'mjs': 'text/javascript',
+        'mp3': 'audio/mpeg',
+        'mp4': 'video/mp4',
+        'oga': 'audio/ogg',
+        'ogg': 'audio/ogg',
+        'ogv': 'video/ogg',
+        'opus': 'audio/opus',
+        'png': 'image/png',
+        'pdf': 'application/pdf',
+        'svg': 'image/svg+xml',
+        'ttf': 'font/ttf',
+        'txt': 'text/plain',
+        'wav': 'audio/wav',
+        'weba': 'audio/webm',
+        'webm': 'video/webm',
+        'woff': 'font/woff',
+        'woff2': 'font/woff2',
+        'xml': 'application/xml',
+        'zip': 'application/zip',
+      }[extname];
+
+      try {
+        response.writeHead(200, contentType ? {'Content-Type': contentType} : {});
+        await pipeline(
+          createReadStream(filePath),
+          response);
+        console.log(`${requestHead} [200] ${pathname}`);
+      } catch (error) {
+        response.writeHead(500, contentTypePlain);
+        response.end(`Failed during file-to-response pipeline`);
+        console.error(`${requestHead} [500] ${pathname}`);
+        console.error(error);
+      }
+      return;
+    }
+
+    // Other routes determined by page and URL specs
+
+    // URL to page map expects trailing slash but no leading slash.
+    const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/');
+
+    if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
+      response.writeHead(404, contentTypePlain);
+      response.end(`No page found for: ${pathnameKey}\n`);
+      console.log(`${requestHead} [404] ${pathname}`);
+      return;
+    }
+
+    const {
+      baseDirectory,
+      language,
+      page,
+      servePath,
+    } = urlToPageMap[pathnameKey];
+
+    const to = getURLsFrom({
+      urls,
+      baseDirectory,
+      pageSubKey: servePath[0],
+      subdirectoryPrefix: getPageSubdirectoryPrefix({
+        urlArgs: servePath.slice(1),
+      }),
+    });
+
+    const absoluteTo = getURLsFromRoot({
+      baseDirectory,
+      urls,
+    });
+
+    try {
+      const pageSubKey = servePath[0];
+      const urlArgs = servePath.slice(1);
+
+      if (page.type === 'redirect') {
+        response.writeHead(301, contentTypeHTML);
+
+        const target = to('localized.' + page.toPath[0], ...page.toPath.slice(1));
+        const redirectHTML = generateRedirectHTML(page.title, target, {language});
+
+        response.end(redirectHTML);
+
+        console.log(`${requestHead} [301] (redirect) ${pathname}`);
+        return;
+      }
+
+      response.writeHead(200, contentTypeHTML);
+
+      const localizedPathnames = withEntries(languages, entries => entries
+        .filter(([key, language]) => key !== 'default' && !language.hidden)
+        .map(([_key, language]) => [
+          language.code,
+          getPagePathname({
+            baseDirectory:
+              (language === defaultLanguage
+                ? ''
+                : language.code),
+            fullKey: 'localized.' + pageSubKey,
+            urlArgs,
+            urls,
+          }),
+        ]));
+
+      const bound = bindUtilities({
+        absoluteTo,
+        getSizeOfAdditionalFile,
+        language,
+        to,
+        urls,
+        wikiData,
+      });
+
+      const pageInfo = page.page(bound);
+
+      const pageHTML = generateDocumentHTML(pageInfo, {
+        cachebust,
+        defaultLanguage,
+        developersComment,
+        getThemeString: bound.getThemeString,
+        language,
+        languages,
+        localizedPathnames,
+        oEmbedJSONHref: null, // No oEmbed support for live dev server
+        pageSubKey,
+        pathname,
+        urlArgs,
+        to,
+        transformMultiline: bound.transformMultiline,
+        wikiData,
+      });
+
+      console.log(`${requestHead} [200] ${pathname}`);
+      response.end(pageHTML);
+    } catch (error) {
+      response.writeHead(500, contentTypePlain);
+      response.end(`Error generating page, view server log for details\n`);
+      console.error(`${requestHead} [500] ${pathname}`);
+      console.error(error);
+    }
+  });
+
+  const address = `http://${host}:${port}/`;
+
+  server.on('error', error => {
+    if (error.code === 'EADDRINUSE') {
+      logWarn`Port ${port} is already in use - will (continually) retry after 10 seconds.`;
+      logWarn`Press ^C here (control+C) to exit and change ${'--port'} number, or stop the server currently running on port ${port}.`;
+      setTimeout(() => {
+        server.close();
+        server.listen(port, host);
+      }, 10_000);
+    } else {
+      console.error(`Server error detected (code: ${error.code})`);
+      console.error(error);
+    }
+  });
+
+  server.on('listening', () => {
+    logInfo`${'All done!'} Listening at: ${address}`;
+    logInfo`Press ^C here (control+C) to stop the server and exit.`;
+  });
+
+  server.listen(port, host);
+
+  // Just keep going... forever!!!
+  await new Promise(() => {});
+
+  return true;
+}
+
+function getPageSpecsWithTargets({
+  wikiData,
+}) {
+  return Object.values(pageSpecs)
+    .filter(pageSpec => pageSpec.condition?.({wikiData}) ?? true)
+    .flatMap(pageSpec => [
+      ...pageSpec.targets
+        ? pageSpec.targets({wikiData})
+            .map(target => ({pageSpec, target}))
+        : [],
+      Object.hasOwn(pageSpec, 'writeTargetless') &&
+        {pageSpec, targetless: true},
+    ])
+    .filter(Boolean);
+}
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
new file mode 100644
index 00000000..90fc38ae
--- /dev/null
+++ b/src/write/build-modes/static-build.js
@@ -0,0 +1,546 @@
+import * as path from 'path';
+
+import {bindUtilities} from '../bind-utilities.js';
+import {validateWrites} from '../validate-writes.js';
+
+import {
+  generateDocumentHTML,
+  generateGlobalWikiDataJSON,
+  generateOEmbedJSON,
+  generateRedirectHTML,
+} from '../page-template.js';
+
+import {serializeThings} from '../../data/serialize.js';
+
+import * as pageSpecs from '../../page/index.js';
+
+import link from '../../util/link.js';
+import {empty, queue, withEntries} from '../../util/sugar.js';
+
+import {
+  logError,
+  logInfo,
+  logWarn,
+  progressCallAll,
+  progressPromiseAll,
+} from '../../util/cli.js';
+
+import {
+  getPagePathname,
+  getPagePaths,
+  getPageSubdirectoryPrefix,
+  getURLsFrom,
+  getURLsFromRoot,
+} from '../../util/urls.js';
+
+const pageFlags = Object.keys(pageSpecs);
+
+export function getCLIOptions() {
+  return {
+    // This is the output directory. It's the one you'll upload online with
+    // rsync or whatever when you're pushing an upd8, and also the one
+    // you'd archive if you wanted to make a 8ackup of the whole dang
+    // site. Just keep in mind that the gener8ted result will contain a
+    // couple symlinked directories, so if you're uploading, you're pro8a8ly
+    // gonna want to resolve those yourself.
+    'out-path': {
+      type: 'value',
+    },
+
+    // Working without a dev server and just using file:// URLs in your we8
+    // 8rowser? This will automatically append index.html to links across
+    // the site. Not recommended for production, since it isn't guaranteed
+    // 100% error-free (and index.html-style links are less pretty anyway).
+    'append-index-html': {
+      type: 'flag',
+    },
+
+    // Only want to 8uild one language during testing? This can chop down
+    // 8uild times a pretty 8ig chunk! Just pass a single language code.
+    'lang': {
+      type: 'value',
+    },
+
+    // NOT for neatly ena8ling or disa8ling specific features of the site!
+    // This is only in charge of what general groups of files to write.
+    // They're here to make development quicker when you're only working
+    // on some particular area(s) of the site rather than making changes
+    // across all of them.
+    ...Object.fromEntries(pageFlags.map((key) => [key, {type: 'flag'}])),
+  };
+}
+
+export async function go({
+  cliOptions,
+  _dataPath,
+  mediaPath,
+  queueSize,
+
+  defaultLanguage,
+  languages,
+  srcRootPath,
+  urls,
+  urlSpec,
+  wikiData,
+
+  cachebust,
+  developersComment,
+  getSizeOfAdditionalFile,
+}) {
+  const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT;
+  const appendIndexHTML = cliOptions['append-index-html'] ?? false;
+  const writeOneLanguage = cliOptions['lang'] ?? null;
+
+  if (!outputPath) {
+    logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`;
+    return false;
+  }
+
+  if (appendIndexHTML) {
+    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
+    link.globalOptions.appendIndexHTML = true;
+  }
+
+  if (writeOneLanguage && !(writeOneLanguage in languages)) {
+    logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
+    return false;
+  } else if (writeOneLanguage) {
+    logInfo`Writing only language ${writeOneLanguage} this run.`;
+  } else {
+    logInfo`Writing all languages.`;
+  }
+
+  const selectedPageFlags = Object.keys(cliOptions)
+    .filter(key => pageFlags.includes(key));
+
+  const writeAll = empty(selectedPageFlags) || selectedPageFlags.includes('all');
+  logInfo`Writing site pages: ${writeAll ? 'all' : selectedPageFlags.join(', ')}`;
+
+  await writeSymlinks({
+    srcRootPath,
+    mediaPath,
+    outputPath,
+    urls,
+  });
+
+  await writeFavicon({
+    mediaPath,
+    outputPath,
+  });
+
+  await writeSharedFilesAndPages({
+    language: defaultLanguage,
+    outputPath,
+    urls,
+    wikiData,
+    wikiDataJSON: generateGlobalWikiDataJSON({
+      serializeThings,
+      wikiData,
+    })
+  });
+
+  const buildSteps = writeAll
+    ? Object.entries(pageSpecs)
+    : Object.entries(pageSpecs)
+        .filter(([flag]) => selectedPageFlags.includes(flag));
+
+  let writes;
+  {
+    let error = false;
+
+    const buildStepsWithTargets = buildSteps
+      .map(([flag, pageSpec]) => {
+        // Condition not met: skip this build step altogether.
+        if (pageSpec.condition && !pageSpec.condition({wikiData})) {
+          return null;
+        }
+
+        // May still call writeTargetless if present.
+        if (!pageSpec.targets) {
+          return {flag, pageSpec, targets: []};
+        }
+
+        if (!pageSpec.write) {
+          logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
+          error = true;
+          return null;
+        }
+
+        const targets = pageSpec.targets({wikiData});
+        if (!Array.isArray(targets)) {
+          logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`;
+          error = true;
+          return null;
+        }
+
+        return {flag, pageSpec, targets};
+      })
+      .filter(Boolean);
+
+    if (error) {
+      return false;
+    }
+
+    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
+      const writesFns = targets.map(target => () => {
+        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
+        const valid = validateWrites(writes, {
+          functionName: flag + '.write',
+          urlSpec,
+        });
+        error ||=! valid;
+        return valid ? writes : [];
+      });
+
+      if (pageSpec.writeTargetless) {
+        writesFns.push(() => {
+          const writes = pageSpec.writeTargetless({wikiData});
+          const valid = validateWrites(writes, {
+            functionName: flag + '.writeTargetless',
+            urlSpec,
+          });
+          error ||=! valid;
+          return valid ? writes : [];
+        });
+      }
+
+      return writesFns;
+    })).flat();
+
+    if (error) {
+      return false;
+    }
+  }
+
+  const pageWrites = writes.filter(({type}) => type === 'page');
+  const dataWrites = writes.filter(({type}) => type === 'data');
+  const redirectWrites = writes.filter(({type}) => type === 'redirect');
+
+  if (writes.length) {
+    logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
+  } else {
+    logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
+    return false;
+  }
+
+  /*
+  await progressPromiseAll(`Writing data files shared across languages.`, queue(
+    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
+  ));
+  */
+
+  const perLanguageFn = async (language, i, entries) => {
+    const baseDirectory =
+      language === defaultLanguage ? '' : language.code;
+
+    console.log(`\x1b[34;1m${`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m`);
+
+    await progressPromiseAll(`Writing ${language.code}`, queue([
+      ...pageWrites.map(page => () => {
+        const pageSubKey = page.path[0];
+        const urlArgs = page.path.slice(1);
+
+        const localizedPathnames = withEntries(languages, entries => entries
+          .filter(([key, language]) => key !== 'default' && !language.hidden)
+          .map(([_key, language]) => [
+            language.code,
+            getPagePathname({
+              baseDirectory:
+                (language === defaultLanguage
+                  ? ''
+                  : language.code),
+              fullKey: 'localized.' + pageSubKey,
+              urlArgs,
+              urls,
+            }),
+          ]));
+
+        const paths = getPagePaths({
+          outputPath,
+          urls,
+
+          baseDirectory,
+          fullKey: 'localized.' + pageSubKey,
+          urlArgs,
+        });
+
+        const to = getURLsFrom({
+          urls,
+          baseDirectory,
+          pageSubKey,
+          subdirectoryPrefix: getPageSubdirectoryPrefix({
+            urlArgs: page.path.slice(1),
+          }),
+        });
+
+        const absoluteTo = getURLsFromRoot({
+          baseDirectory,
+          urls,
+        });
+
+        const bound = bindUtilities({
+          absoluteTo,
+          getSizeOfAdditionalFile,
+          language,
+          to,
+          urls,
+          wikiData,
+        });
+
+        const pageInfo = page.page(bound);
+
+        const oEmbedJSON = generateOEmbedJSON(pageInfo, {
+          language,
+          wikiData,
+        });
+
+        const oEmbedJSONHref =
+          oEmbedJSON &&
+          wikiData.wikiInfo.canonicalBase &&
+          wikiData.wikiInfo.canonicalBase +
+            urls
+              .from('shared.root')
+              .to('shared.path', paths.pathname + 'oembed.json');
+
+        const pageHTML = generateDocumentHTML(pageInfo, {
+          cachebust,
+          defaultLanguage,
+          developersComment,
+          getThemeString: bound.getThemeString,
+          language,
+          languages,
+          localizedPathnames,
+          oEmbedJSONHref,
+          pageSubKey,
+          pathname: paths.pathname,
+          to,
+          transformMultiline: bound.transformMultiline,
+          urlArgs,
+          wikiData,
+        });
+
+        return writePage({
+          html: pageHTML,
+          oEmbedJSON,
+          paths,
+        });
+      }),
+      ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
+        const title = titleFn({
+          language,
+        });
+
+        const from = getPagePaths({
+          outputPath,
+          urls,
+
+          baseDirectory,
+          fullKey: 'localized.' + fromPath[0],
+          urlArgs: fromPath.slice(1),
+        });
+
+        const to = getURLsFrom({
+          urls,
+          baseDirectory,
+          pageSubKey: fromPath[0],
+          subdirectoryPrefix: getPageSubdirectoryPrefix({
+            urlArgs: fromPath.slice(1),
+          }),
+        });
+
+        const target = to('localized.' + toPath[0], ...toPath.slice(1));
+        const html = generateRedirectHTML(title, target, {language});
+        return writePage({html, paths: from});
+      }),
+    ], queueSize));
+  };
+
+  await wrapLanguages(perLanguageFn, {
+    languages,
+    writeOneLanguage,
+  });
+
+  // The single most important step.
+  logInfo`Written!`;
+  return true;
+}
+
+// Wrapper function for running a function once for all languages.
+async function wrapLanguages(fn, {
+  languages,
+  writeOneLanguage = null,
+}) {
+  const k = writeOneLanguage;
+  const languagesToRun = k ? {[k]: languages[k]} : languages;
+
+  const entries = Object.entries(languagesToRun).filter(
+    ([key]) => key !== 'default'
+  );
+
+  for (let i = 0; i < entries.length; i++) {
+    const [_key, language] = entries[i];
+
+    await fn(language, i, entries);
+  }
+}
+
+import {
+  copyFile,
+  mkdir,
+  stat,
+  symlink,
+  writeFile,
+  unlink,
+} from 'fs/promises';
+
+async function writePage({
+  html,
+  oEmbedJSON = '',
+  paths,
+}) {
+  await mkdir(paths.output.directory, {recursive: true});
+
+  await Promise.all([
+    writeFile(paths.output.documentHTML, html),
+
+    oEmbedJSON &&
+      writeFile(paths.output.oEmbedJSON, oEmbedJSON),
+  ].filter(Boolean));
+}
+
+function writeSymlinks({
+  srcRootPath,
+  mediaPath,
+  outputPath,
+  urls,
+}) {
+  return progressPromiseAll('Writing site symlinks.', [
+    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
+    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
+    link(mediaPath, 'media.root'),
+  ]);
+
+  async function link(directory, urlKey) {
+    const pathname = urls.from('shared.root').toDevice(urlKey);
+    const file = path.join(outputPath, pathname);
+
+    try {
+      await unlink(file);
+    } catch (error) {
+      if (error.code !== 'ENOENT') {
+        throw error;
+      }
+    }
+
+    try {
+      await symlink(path.resolve(directory), file);
+    } catch (error) {
+      if (error.code === 'EPERM') {
+        await symlink(path.resolve(directory), file, 'junction');
+      }
+    }
+  }
+}
+
+async function writeFavicon({
+  mediaPath,
+  outputPath,
+}) {
+  const faviconFile = 'favicon.ico';
+
+  try {
+    await stat(path.join(mediaPath, faviconFile));
+  } catch (error) {
+    return;
+  }
+
+  try {
+    await copyFile(
+      path.join(mediaPath, faviconFile),
+      path.join(outputPath, faviconFile));
+  } catch (error) {
+    logWarn`Failed to copy favicon! ${error.message}`;
+    return;
+  }
+
+  logInfo`Copied favicon to site root.`;
+}
+
+async function writeSharedFilesAndPages({
+  language,
+  outputPath,
+  urls,
+  wikiData,
+  wikiDataJSON,
+}) {
+  const {groupData, wikiInfo} = wikiData;
+
+  return progressPromiseAll(`Writing files & pages shared across languages.`, [
+    groupData?.some((group) => group.directory === 'fandom') &&
+      redirect(
+        'Fandom - Gallery',
+        'albums/fandom',
+        'localized.groupGallery',
+        'fandom'
+      ),
+
+    groupData?.some((group) => group.directory === 'official') &&
+      redirect(
+        'Official - Gallery',
+        'albums/official',
+        'localized.groupGallery',
+        'official'
+      ),
+
+    wikiInfo.enableListings &&
+      redirect(
+        'Album Commentary',
+        'list/all-commentary',
+        'localized.commentaryIndex',
+        ''
+      ),
+
+    wikiDataJSON &&
+      writeFile(
+        path.join(outputPath, 'data.json'),
+        wikiDataJSON),
+  ].filter(Boolean));
+
+  async function redirect(title, from, urlKey, directory) {
+    const target = path.relative(
+      from,
+      urls.from('shared.root').to(urlKey, directory)
+    );
+    const content = generateRedirectHTML(title, target, {language});
+    await mkdir(path.join(outputPath, from), {recursive: true});
+    await writeFile(path.join(outputPath, from, 'index.html'), content);
+  }
+}
diff --git a/src/write/page-template.js b/src/write/page-template.js
new file mode 100644
index 00000000..88d81c23
--- /dev/null
+++ b/src/write/page-template.js
@@ -0,0 +1,640 @@
+import chroma from 'chroma-js';
+
+import * as html from '../util/html.js';
+import {logWarn} from '../util/cli.js';
+import {getColors} from '../util/colors.js';
+
+import {
+  getFooterLocalizationLinks,
+  getRevealStringFromWarnings,
+  img,
+} from '../misc-templates.js';
+
+export function generateDevelopersCommentHTML({
+  buildTime,
+  commit,
+  wikiData,
+}) {
+  const {name, canonicalBase} = wikiData.wikiInfo;
+  return `<!--\n` + [
+    canonicalBase
+      ? `hsmusic.wiki - ${name}, ${canonicalBase}`
+      : `hsmusic.wiki - ${name}`,
+    'Code copyright 2019-2023 Quasar Nebula et al (MIT License)',
+    ...canonicalBase === 'https://hsmusic.wiki/' ? [
+      'Data avidly compiled and localization brought to you',
+      'by our awesome team and community of wiki contributors',
+      '***',
+      'Want to contribute? Join our Discord or leave feedback!',
+      '- https://hsmusic.wiki/discord/',
+      '- https://hsmusic.wiki/feedback/',
+      '- https://github.com/hsmusic/',
+    ] : [
+      'https://github.com/hsmusic/',
+    ],
+    '***',
+    buildTime &&
+      `Site built: ${buildTime.toLocaleString('en-US', {
+        dateStyle: 'long',
+        timeStyle: 'long',
+      })}`,
+    commit &&
+      `Latest code commit: ${commit}`,
+  ]
+    .filter(Boolean)
+    .map(line => `    ` + line)
+    .join('\n') + `\n-->`;
+}
+
+export function generateDocumentHTML(pageInfo, {
+  cachebust,
+  defaultLanguage,
+  developersComment,
+  getThemeString,
+  language,
+  languages,
+  localizedPathnames,
+  oEmbedJSONHref,
+  pageSubKey,
+  pathname,
+  to,
+  transformMultiline,
+  urlArgs,
+  wikiData,
+}) {
+  const {wikiInfo} = wikiData;
+
+  let {
+    title = '',
+    meta = {},
+    theme = '',
+    stylesheet = '',
+
+    showWikiNameInTitle = true,
+    themeColor = '',
+
+    // missing properties are auto-filled, see below!
+    body = {},
+    banner = {},
+    main = {},
+    sidebarLeft = {},
+    sidebarRight = {},
+    nav = {},
+    secondaryNav = {},
+    footer = {},
+    socialEmbed = {},
+  } = pageInfo;
+
+  body.style ??= '';
+
+  theme = theme || getThemeString(wikiInfo.color);
+
+  banner ||= {};
+  banner.classes ??= [];
+  banner.src ??= '';
+  banner.position ??= '';
+  banner.dimensions ??= [0, 0];
+
+  main.classes ??= [];
+  main.content ??= '';
+
+  sidebarLeft ??= {};
+  sidebarRight ??= {};
+
+  for (const sidebar of [sidebarLeft, sidebarRight]) {
+    sidebar.classes ??= [];
+    sidebar.content ??= '';
+    sidebar.collapse ??= true;
+  }
+
+  nav.classes ??= [];
+  nav.content ??= '';
+  nav.bottomRowContent ??= '';
+  nav.links ??= [];
+  nav.linkContainerClasses ??= [];
+
+  secondaryNav ??= {};
+  secondaryNav.content ??= '';
+  secondaryNav.content ??= '';
+
+  footer.classes ??= [];
+  footer.content ??= wikiInfo.footerContent
+    ? transformMultiline(wikiInfo.footerContent)
+    : '';
+
+  const colors = themeColor
+    ? getColors(themeColor, {chroma})
+    : null;
+
+  const canonical = wikiInfo.canonicalBase
+    ? wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname)
+    : '';
+
+  const localizedCanonical = wikiInfo.canonicalBase
+    ? Object.entries(localizedPathnames).map(([code, pathname]) => ({
+        lang: code,
+        href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname),
+      }))
+    : [];
+
+  const collapseSidebars =
+    sidebarLeft.collapse !== false && sidebarRight.collapse !== false;
+
+  const mainHTML =
+    main.content &&
+      html.tag('main',
+        {
+          id: 'content',
+          class: main.classes,
+        },
+        main.content);
+
+  const footerHTML =
+    html.tag('footer',
+      {
+        [html.onlyIfContent]: true,
+        id: 'footer',
+        class: footer.classes,
+      },
+      [
+        html.tag('div',
+          {
+            [html.onlyIfContent]: true,
+            class: 'footer-content',
+          },
+          footer.content),
+
+        getFooterLocalizationLinks(pathname, {
+          defaultLanguage,
+          html,
+          language,
+          languages,
+          pageSubKey,
+          to,
+          urlArgs,
+        }),
+      ]);
+
+  const generateSidebarHTML = (id, {
+    content,
+    multiple,
+    classes,
+    collapse = true,
+    wide = false,
+
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'none' - sidebar not sticky at all, stays at top of page
+    stickyMode = 'last',
+  }) =>
+    content
+      ? html.tag('div',
+          {
+            id,
+            class: [
+              'sidebar-column',
+              'sidebar',
+              wide && 'wide',
+              !collapse && 'no-hide',
+              stickyMode !== 'none' && 'sticky-' + stickyMode,
+              ...classes,
+            ],
+          },
+          content)
+      : multiple
+      ? html.tag('div',
+          {
+            id,
+            class: [
+              'sidebar-column',
+              'sidebar-multiple',
+              wide && 'wide',
+              !collapse && 'no-hide',
+              stickyMode !== 'none' && 'sticky-' + stickyMode,
+            ],
+          },
+          multiple
+            .map((infoOrContent) =>
+              (typeof infoOrContent === 'object' && !Array.isArray(infoOrContent))
+                ? infoOrContent
+                : {content: infoOrContent})
+            .filter(({content}) => content)
+            .map(({
+              content,
+              classes: classes2 = [],
+            }) =>
+              html.tag('div',
+                {
+                  class: ['sidebar', ...classes, ...classes2],
+                },
+                html.fragment(content))))
+      : '';
+
+  const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
+  const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
+
+  if (nav.simple) {
+    nav.linkContainerClasses = ['nav-links-hierarchy'];
+    nav.links = [{toHome: true}, {toCurrentPage: true}];
+  }
+
+  const links = (nav.links || []).filter(Boolean);
+
+  const navLinkParts = [];
+  for (let i = 0; i < links.length; i++) {
+    let cur = links[i];
+
+    let {title: linkTitle} = cur;
+
+    if (cur.toHome) {
+      linkTitle ??= wikiInfo.nameShort;
+    } else if (cur.toCurrentPage) {
+      linkTitle ??= title;
+    }
+
+    let partContent;
+
+    if (typeof cur.html === 'string') {
+      partContent = cur.html;
+    } else {
+      const attributes = {
+        class: (cur.toCurrentPage || i === links.length - 1) && 'current',
+        href: cur.toCurrentPage
+          ? ''
+          : cur.toHome
+          ? to('localized.home')
+          : cur.path
+          ? to(...cur.path)
+          : cur.href
+          ? (() => {
+              logWarn`Using legacy href format nav link in ${pathname}`;
+              return cur.href;
+            })()
+          : null,
+      };
+      if (attributes.href === null) {
+        throw new Error(
+          `Expected some href specifier for link to ${linkTitle} (${JSON.stringify(
+            cur
+          )})`
+        );
+      }
+      partContent = html.tag('a', attributes, linkTitle);
+    }
+
+    if (!partContent) continue;
+
+    const part = html.tag('span',
+      {class: cur.divider === false && 'no-divider'},
+      partContent);
+
+    navLinkParts.push(part);
+  }
+
+  const navHTML = html.tag('nav',
+    {
+      [html.onlyIfContent]: true,
+      id: 'header',
+      class: [
+        ...nav.classes,
+        links.length && 'nav-has-main-links',
+        nav.content && 'nav-has-content',
+        nav.bottomRowContent && 'nav-has-bottom-row',
+      ],
+    },
+    [
+      links.length &&
+        html.tag(
+          'div',
+          {class: ['nav-main-links', ...nav.linkContainerClasses]},
+          navLinkParts
+        ),
+      nav.bottomRowContent &&
+        html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
+      nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
+    ]);
+
+  const secondaryNavHTML = html.tag('nav',
+    {
+      [html.onlyIfContent]: true,
+      id: 'secondary-nav',
+      class: secondaryNav.classes,
+    },
+    secondaryNav.content);
+
+  const bannerSrc = banner.src
+    ? banner.src
+    : banner.path
+    ? to(...banner.path)
+    : null;
+
+  const bannerHTML =
+    banner.position &&
+    bannerSrc &&
+    html.tag('div',
+      {
+        id: 'banner',
+        class: banner.classes,
+      },
+      html.tag('img', {
+        src: bannerSrc,
+        alt: banner.alt,
+        width: banner.dimensions[0] || 1100,
+        height: banner.dimensions[1] || 200,
+      }));
+
+  const layoutHTML = [
+    navHTML,
+    banner.position === 'top' && bannerHTML,
+    secondaryNavHTML,
+    html.tag('div',
+      {
+        class: [
+          'layout-columns',
+          !collapseSidebars && 'vertical-when-thin',
+          (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
+          (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
+          !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
+          sidebarLeftHTML && 'has-sidebar-left',
+          sidebarRightHTML && 'has-sidebar-right',
+        ],
+      },
+      [
+        sidebarLeftHTML,
+        mainHTML,
+        sidebarRightHTML,
+      ]),
+    banner.position === 'bottom' && bannerHTML,
+    footerHTML,
+  ].filter(Boolean).join('\n');
+
+  const infoCardHTML = html.tag('div', {id: 'info-card-container'},
+    html.tag('div', {id: 'info-card-decor'},
+      html.tag('div', {id: 'info-card'}, [
+        html.tag('div', {class: ['info-card-art-container', 'no-reveal']},
+          img({
+            html,
+            class: 'info-card-art',
+            src: '',
+            link: true,
+            square: true,
+          })),
+        html.tag('div', {class: ['info-card-art-container', 'reveal']},
+          img({
+            html,
+            class: 'info-card-art',
+            src: '',
+            link: true,
+            square: true,
+            reveal: getRevealStringFromWarnings(
+              html.tag('span', {class: 'info-card-art-warnings'}),
+              {html, language}),
+          })),
+        html.tag('h1', {class: 'info-card-name'},
+          html.tag('a')),
+        html.tag('p', {class: 'info-card-album'},
+          language.$('releaseInfo.from', {
+            album: html.tag('a'),
+          })),
+        html.tag('p', {class: 'info-card-artists'},
+          language.$('releaseInfo.by', {
+            artists: html.tag('span'),
+          })),
+        html.tag('p', {class: 'info-card-cover-artists'},
+          language.$('releaseInfo.coverArtBy', {
+            artists: html.tag('span'),
+          })),
+      ])));
+
+  const socialEmbedHTML = [
+    socialEmbed.title &&
+      html.tag('meta', {property: 'og:title', content: socialEmbed.title}),
+
+    socialEmbed.description &&
+      html.tag('meta', {
+        property: 'og:description',
+        content: socialEmbed.description,
+      }),
+
+    socialEmbed.image &&
+      html.tag('meta', {property: 'og:image', content: socialEmbed.image}),
+
+    ...html.fragment(
+      colors && [
+        html.tag('meta', {
+          name: 'theme-color',
+          content: colors.dark,
+          media: '(prefers-color-scheme: dark)',
+        }),
+
+        html.tag('meta', {
+          name: 'theme-color',
+          content: colors.light,
+          media: '(prefers-color-scheme: light)',
+        }),
+
+        html.tag('meta', {
+          name: 'theme-color',
+          content: colors.primary,
+        }),
+      ]),
+
+    oEmbedJSONHref &&
+      html.tag('link', {
+        type: 'application/json+oembed',
+        href: oEmbedJSONHref,
+      }),
+  ].filter(Boolean).join('\n');
+
+  return `<!DOCTYPE html>\n` + html.tag('html',
+    {
+      lang: language.intlCode,
+      'data-language-code': language.code,
+      'data-url-key': 'localized.' + pageSubKey,
+      ...Object.fromEntries(
+        urlArgs.map((v, i) => [['data-url-value' + i], v])
+      ),
+      'data-rebase-localized': to('localized.root'),
+      'data-rebase-shared': to('shared.root'),
+      'data-rebase-media': to('media.root'),
+      'data-rebase-data': to('data.root'),
+    },
+    [
+      developersComment,
+
+      html.tag('head', [
+        html.tag('title',
+          showWikiNameInTitle
+            ? language.formatString('misc.pageTitle.withWikiName', {
+                title,
+                wikiName: wikiInfo.nameShort,
+              })
+            : language.formatString('misc.pageTitle', {title})),
+
+        html.tag('meta', {charset: 'utf-8'}),
+        html.tag('meta', {
+          name: 'viewport',
+          content: 'width=device-width, initial-scale=1',
+        }),
+
+        ...(
+          Object.entries(meta)
+            .filter(([key, value]) => value)
+            .map(([key, value]) => html.tag('meta', {[key]: value}))),
+
+        canonical &&
+          html.tag('link', {
+            rel: 'canonical',
+            href: canonical,
+          }),
+
+        ...(
+          localizedCanonical
+            .map(({lang, href}) => html.tag('link', {
+              rel: 'alternate',
+              hreflang: lang,
+              href,
+            }))),
+
+        socialEmbedHTML,
+
+        html.tag('link', {
+          rel: 'stylesheet',
+          href: to('shared.staticFile', `site2.css?${cachebust}`),
+        }),
+
+        html.tag('style',
+          {[html.onlyIfContent]: true},
+          [
+            theme,
+            stylesheet,
+          ]),
+
+        html.tag('script', {
+          src: to('shared.staticFile', `lazy-loading.js?${cachebust}`),
+        }),
+      ]),
+
+      html.tag('body',
+        {style: body.style || ''},
+        [
+          html.tag('div', {id: 'page-container'}, [
+            mainHTML &&
+              html.tag('div', {id: 'skippers'},
+                [
+                  ['#content', language.$('misc.skippers.skipToContent')],
+                  sidebarLeftHTML &&
+                    [
+                      '#sidebar-left',
+                      sidebarRightHTML
+                        ? language.$('misc.skippers.skipToSidebar.left')
+                        : language.$('misc.skippers.skipToSidebar'),
+                    ],
+                  sidebarRightHTML &&
+                    [
+                      '#sidebar-right',
+                      sidebarLeftHTML
+                        ? language.$('misc.skippers.skipToSidebar.right')
+                        : language.$('misc.skippers.skipToSidebar'),
+                    ],
+                  footerHTML &&
+                    ['#footer', language.$('misc.skippers.skipToFooter')],
+                ]
+                  .filter(Boolean)
+                  .map(([href, title]) =>
+                    html.tag('span', {class: 'skipper'},
+                      html.tag('a', {href}, title)))),
+            layoutHTML,
+          ]),
+
+          infoCardHTML,
+
+          html.tag('script', {
+            type: 'module',
+            src: to('shared.staticFile', `client.js?${cachebust}`),
+          }),
+        ]),
+    ]);
+}
+
+export function generateOEmbedJSON(pageInfo, {language, wikiData}) {
+  const {socialEmbed} = pageInfo;
+  const {wikiInfo} = wikiData;
+  const {canonicalBase, nameShort} = wikiInfo;
+
+  if (!socialEmbed) return '';
+
+  const entries = [
+    socialEmbed.heading && [
+      'author_name',
+      language.$('misc.socialEmbed.heading', {
+        wikiName: nameShort,
+        heading: socialEmbed.heading,
+      }),
+    ],
+    socialEmbed.headingLink &&
+      canonicalBase && [
+        'author_url',
+        canonicalBase.replace(/\/$/, '') +
+          '/' +
+          socialEmbed.headingLink.replace(/^\//, ''),
+      ],
+  ].filter(Boolean);
+
+  if (!entries.length) return '';
+
+  return JSON.stringify(Object.fromEntries(entries));
+}
+
+export function generateRedirectHTML(title, target, {
+  language,
+}) {
+  return `<!DOCTYPE html>\n` + html.tag('html', [
+    html.tag('head', [
+      html.tag('title', language.$('redirectPage.title', {title})),
+      html.tag('meta', {charset: 'utf-8'}),
+
+      html.tag('meta', {
+        'http-equiv': 'refresh',
+        content: `0;url=${target}`,
+      }),
+
+      // TODO: Is this OK for localized pages?
+      html.tag('link', {
+        rel: 'canonical',
+        href: target,
+      }),
+    ]),
+
+    html.tag('body',
+      html.tag('main', [
+        html.tag('h1',
+          language.$('redirectPage.title', {title})),
+        html.tag('p',
+          language.$('redirectPage.infoLine', {
+            target: html.tag('a', {href: target}, target),
+          })),
+      ])),
+  ]);
+}
+
+export function generateGlobalWikiDataJSON({
+  serializeThings,
+  wikiData,
+}) {
+  return '{\n' +
+    ([
+      `"albumData": ${stringifyThings(wikiData.albumData)},`,
+      wikiData.wikiInfo.enableFlashesAndGames &&
+        `"flashData": ${stringifyThings(wikiData.flashData)},`,
+      `"artistData": ${stringifyThings(wikiData.artistData)}`,
+    ]
+      .filter(Boolean)
+      .map(line => '  ' + line)
+      .join('\n')) +
+    '\n}';
+
+  function stringifyThings(thingData) {
+    return JSON.stringify(serializeThings(thingData));
+  }
+}
diff --git a/src/write/validate-writes.js b/src/write/validate-writes.js
new file mode 100644
index 00000000..5d61d0e7
--- /dev/null
+++ b/src/write/validate-writes.js
@@ -0,0 +1,134 @@
+import {logError} from '../util/cli.js';
+
+function validateWritePath(path, urlGroup) {
+  if (!Array.isArray(path)) {
+    return {error: `Expected array, got ${path}`};
+  }
+
+  const {paths} = urlGroup;
+
+  const definedKeys = Object.keys(paths);
+  const specifiedKey = path[0];
+
+  if (!definedKeys.includes(specifiedKey)) {
+    return {error: `Specified key ${specifiedKey} isn't defined`};
+  }
+
+  const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
+  const specifiedArgs = path.length - 1;
+
+  if (specifiedArgs !== expectedArgs) {
+    return {
+      error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`,
+    };
+  }
+
+  return {success: true};
+}
+
+function validateWriteObject(obj, {
+  urlSpec,
+}) {
+  if (typeof obj !== 'object') {
+    return {error: `Expected object, got ${typeof obj}`};
+  }
+
+  if (typeof obj.type !== 'string') {
+    return {error: `Expected type to be string, got ${obj.type}`};
+  }
+
+  switch (obj.type) {
+    case 'legacy': {
+      if (typeof obj.write !== 'function') {
+        return {error: `Expected write to be string, got ${obj.write}`};
+      }
+
+      break;
+    }
+
+    case 'page': {
+      const path = validateWritePath(obj.path, urlSpec.localized);
+      if (path.error) {
+        return {error: `Path validation failed: ${path.error}`};
+      }
+
+      if (typeof obj.page !== 'function') {
+        return {error: `Expected page to be function, got ${obj.content}`};
+      }
+
+      break;
+    }
+
+    case 'data': {
+      const path = validateWritePath(obj.path, urlSpec.data);
+      if (path.error) {
+        return {error: `Path validation failed: ${path.error}`};
+      }
+
+      if (typeof obj.data !== 'function') {
+        return {error: `Expected data to be function, got ${obj.data}`};
+      }
+
+      break;
+    }
+
+    case 'redirect': {
+      const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
+      if (fromPath.error) {
+        return {
+          error: `Path (fromPath) validation failed: ${fromPath.error}`,
+        };
+      }
+
+      const toPath = validateWritePath(obj.toPath, urlSpec.localized);
+      if (toPath.error) {
+        return {error: `Path (toPath) validation failed: ${toPath.error}`};
+      }
+
+      if (typeof obj.title !== 'function') {
+        return {error: `Expected title to be function, got ${obj.title}`};
+      }
+
+      break;
+    }
+
+    default: {
+      return {error: `Unknown type: ${obj.type}`};
+    }
+  }
+
+  return {success: true};
+}
+
+export function validateWrites(writes, {
+  functionName,
+  urlSpec,
+}) {
+  // 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`${functionName} didn't return an array!`;
+    return false;
+  }
+
+  if (!(
+    writes.every((obj) => typeof obj === 'object') &&
+    writes.every((obj) => {
+      const result = validateWriteObject(obj, {
+        urlSpec,
+      });
+      if (result.error) {
+        logError`Validating write object failed: ${result.error}`;
+        return false;
+      } else {
+        return true;
+      }
+    })
+  )) {
+    logError`${functionName} returned invalid entries!`;
+    return false;
+  }
+
+  return true;
+}