« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/misc-templates.js105
-rwxr-xr-xsrc/upd8.js776
-rw-r--r--src/util/transform-content.js447
-rw-r--r--src/write/bind-utilities.js257
4 files changed, 825 insertions, 760 deletions
diff --git a/src/misc-templates.js b/src/misc-templates.js
index a530a3cf..9a1bbf50 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -10,6 +10,8 @@ import {
   unique,
 } from './util/sugar.js';
 
+import {thumb} from './util/urls.js';
+
 import {
   getTotalDuration,
   sortAlbumsTracksChronologically,
@@ -642,6 +644,107 @@ function unbound_getFlashGridHTML({
   });
 }
 
+// Images
+
+function unbound_img({
+  html,
+
+  src,
+  alt,
+  noSrcText = '',
+  thumb: thumbKey,
+  reveal,
+  id,
+  class: className,
+  width,
+  height,
+  link = false,
+  lazy = false,
+  square = false,
+}) {
+  const willSquare = square;
+  const willLink = typeof link === 'string' || link;
+
+  const originalSrc = src;
+  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
+
+  const imgAttributes = {
+    id: link ? '' : id,
+    class: className,
+    alt,
+    width,
+    height,
+  };
+
+  const noSrcHTML =
+    !src &&
+      wrap(
+        html.tag('div',
+          {class: 'image-text-area'},
+          noSrcText));
+
+  const nonlazyHTML =
+    src &&
+      wrap(
+        html.tag('img', {
+          ...imgAttributes,
+          src: thumbSrc,
+        }));
+
+  const lazyHTML =
+    src &&
+    lazy &&
+      wrap(
+        html.tag('img',
+          {
+            ...imgAttributes,
+            class: [className, 'lazy'],
+            'data-original': thumbSrc,
+          }),
+        true);
+
+  if (!src) {
+    return noSrcHTML;
+  } else if (lazy) {
+    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
+  } else {
+    return nonlazyHTML;
+  }
+
+  function wrap(input, hide = false) {
+    let wrapped = input;
+
+    wrapped = html.tag('div', {class: 'image-inner-area'}, wrapped);
+    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
+
+    if (reveal) {
+      wrapped = html.tag('div', {class: 'reveal'}, [
+        wrapped,
+        html.tag('span', {class: 'reveal-text'}, reveal),
+      ]);
+    }
+
+    if (willSquare) {
+      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
+      wrapped = html.tag('div',
+        {class: ['square', hide && !willLink && 'js-hide']},
+        wrapped);
+    }
+
+    if (willLink) {
+      wrapped = html.tag('a',
+        {
+          id,
+          class: ['box', hide && 'js-hide'],
+          href: typeof link === 'string' ? link : originalSrc,
+        },
+        wrapped);
+    }
+
+    return wrapped;
+  }
+}
+
 // Carousel reels
 
 // Layout constants:
@@ -940,6 +1043,8 @@ export {
 
   unbound_getCarouselHTML as getCarouselHTML,
 
+  unbound_img as img,
+
   unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
   unbound_generateNavigationLinks as generateNavigationLinks,
 
diff --git a/src/upd8.js b/src/upd8.js
index fad3c7d8..0573dcd2 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,11 +31,11 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
+import chroma from 'chroma-js';
+
 import * as path from 'path';
 import {fileURLToPath} from 'url';
 
-import chroma from 'chroma-js';
-
 import {
   copyFile,
   mkdir,
@@ -52,17 +52,20 @@ import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import urlSpec from './url-spec.js';
 import * as pageSpecs from './page/index.js';
 
-import find, {bindFind} from './util/find.js';
+import find from './util/find.js';
 import * as html from './util/html.js';
 import {getColors} from './util/colors.js';
 import {findFiles} from './util/io.js';
 import {isMain} from './util/node-utils.js';
+import {replacerSpec} from './util/transform-content.js';
 
 import CacheableObject from './data/things/cacheable-object.js';
 
 import {processLanguageFile} from './data/language.js';
 import {serializeThings} from './data/serialize.js';
 
+import {bindUtilities} from './write/bind-utilities.js';
+
 import {
   filterDuplicateDirectories,
   filterReferenceErrors,
@@ -73,32 +76,12 @@ import {
 } from './data/yaml.js';
 
 import {
-  fancifyFlashURL,
-  fancifyURL,
-  generateAdditionalFilesShortcut,
-  generateAdditionalFilesList,
-  generateChronologyLinks,
-  generateCoverLink,
-  generateInfoGalleryLinks,
-  generateNavigationLinks,
-  generateStickyHeadingContainer,
-  generateTrackListDividedByGroups,
-  getAlbumGridHTML,
-  getAlbumStylesheet,
-  getArtistString,
-  getFlashGridHTML,
   getFooterLocalizationLinks,
-  getGridHTML,
-  getCarouselHTML,
-  getRevealStringFromTags,
   getRevealStringFromWarnings,
-  getThemeString as unbound_getThemeString,
-  iconifyURL,
+  img,
 } from './misc-templates.js';
 
-import unbound_link, {
-  getLinkThemeString as unbound_getLinkThemeString,
-} from './util/link.js';
+import link from './util/link.js';
 
 import {
   color,
@@ -111,14 +94,7 @@ import {
   progressPromiseAll,
 } from './util/cli.js';
 
-import {validateReplacerSpec, transformInline} from './util/replacer.js';
-
-import {
-  getAlbumCover,
-  getArtistAvatar,
-  getFlashCover,
-  getTrackCover,
-} from './util/wiki-data.js';
+import {validateReplacerSpec} from './util/replacer.js';
 
 /*
 import {
@@ -131,14 +107,9 @@ import {
 } from './util/serialize.js';
 */
 
-import {
-  bindOpts,
-  queue,
-  showAggregate,
-  withEntries,
-} from './util/sugar.js';
+import {queue, showAggregate} from './util/sugar.js';
 
-import {generateURLs, thumb} from './util/urls.js';
+import {generateURLs} from './util/urls.js';
 
 // Pensive emoji!
 import { OFFICIAL_GROUP_DIRECTORY } from './util/magic-constants.js';
@@ -199,541 +170,14 @@ let queueSize;
 
 const urls = generateURLs(urlSpec);
 
-function splitLines(text) {
-  return text.split(/\r\n|\r|\n/);
-}
-
-const replacerSpec = {
-  album: {
-    find: 'album',
-    link: 'album',
-  },
-  'album-commentary': {
-    find: 'album',
-    link: 'albumCommentary',
-  },
-  'album-gallery': {
-    find: 'album',
-    link: 'albumGallery',
-  },
-  artist: {
-    find: 'artist',
-    link: 'artist',
-  },
-  'artist-gallery': {
-    find: 'artist',
-    link: 'artistGallery',
-  },
-  'commentary-index': {
-    find: null,
-    link: 'commentaryIndex',
-  },
-  date: {
-    find: null,
-    value: (ref) => new Date(ref),
-    html: (date, {language}) =>
-      html.tag('time',
-        {datetime: date.toString()},
-        language.formatDate(date)),
-  },
-  'flash-index': {
-    find: null,
-    link: 'flashIndex',
-  },
-  flash: {
-    find: 'flash',
-    link: 'flash',
-    transformName(name, node, input) {
-      const nextCharacter = input[node.iEnd];
-      const lastCharacter = name[name.length - 1];
-      if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') {
-        return name.slice(0, -1);
-      } else {
-        return name;
-      }
-    },
-  },
-  group: {
-    find: 'group',
-    link: 'groupInfo',
-  },
-  'group-gallery': {
-    find: 'group',
-    link: 'groupGallery',
-  },
-  home: {
-    find: null,
-    link: 'home',
-  },
-  'listing-index': {
-    find: null,
-    link: 'listingIndex',
-  },
-  listing: {
-    find: 'listing',
-    link: 'listing',
-  },
-  media: {
-    find: null,
-    link: 'media',
-  },
-  'news-index': {
-    find: null,
-    link: 'newsIndex',
-  },
-  'news-entry': {
-    find: 'newsEntry',
-    link: 'newsEntry',
-  },
-  root: {
-    find: null,
-    link: 'root',
-  },
-  site: {
-    find: null,
-    link: 'site',
-  },
-  static: {
-    find: 'staticPage',
-    link: 'staticPage',
-  },
-  string: {
-    find: null,
-    value: (ref) => ref,
-    html: (ref, {language, args}) => language.$(ref, args),
-  },
-  tag: {
-    find: 'artTag',
-    link: 'tag',
-  },
-  track: {
-    find: 'track',
-    link: 'track',
-  },
-};
-
-if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
+if (!validateReplacerSpec(replacerSpec, {find, link})) {
   process.exit();
 }
 
-function parseAttributes(string, {to}) {
-  const attributes = Object.create(null);
-  const skipWhitespace = (i) => {
-    const ws = /\s/;
-    if (ws.test(string[i])) {
-      const match = string.slice(i).match(/[^\s]/);
-      if (match) {
-        return i + match.index;
-      } else {
-        return string.length;
-      }
-    } else {
-      return i;
-    }
-  };
-
-  for (let i = 0; i < string.length; ) {
-    i = skipWhitespace(i);
-    const aStart = i;
-    const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
-    const attribute = string.slice(aStart, aEnd);
-    i = skipWhitespace(aEnd);
-    if (string[i] === '=') {
-      i = skipWhitespace(i + 1);
-      let end, endOffset;
-      if (string[i] === '"' || string[i] === "'") {
-        end = string[i];
-        endOffset = 1;
-        i++;
-      } else {
-        end = '\\s';
-        endOffset = 0;
-      }
-      const vStart = i;
-      const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
-      const value = string.slice(vStart, vEnd);
-      i = vEnd + endOffset;
-      if (attribute === 'src' && value.startsWith('media/')) {
-        attributes[attribute] = to('media.path', value.slice('media/'.length));
-      } else {
-        attributes[attribute] = value;
-      }
-    } else {
-      attributes[attribute] = attribute;
-    }
-  }
-  return Object.fromEntries(
-    Object.entries(attributes).map(([key, val]) => [
-      key,
-      val === 'true'
-        ? true
-        : val === 'false'
-        ? false
-        : val === key
-        ? true
-        : val,
-    ])
-  );
-}
-
-function joinLineBreaks(sourceLines) {
-  const outLines = [];
-
-  let lineSoFar = '';
-  for (let i = 0; i < sourceLines.length; i++) {
-    const line = sourceLines[i];
-    lineSoFar += line;
-    if (!line.endsWith('<br>')) {
-      outLines.push(lineSoFar);
-      lineSoFar = '';
-    }
-  }
-
-  if (lineSoFar) {
-    outLines.push(lineSoFar);
-  }
-
-  return outLines;
-}
-
-function transformMultiline(text, {
-  parseAttributes,
-  transformInline,
-  thumb = null,
-}) {
-  // Heck yes, HTML magics.
-
-  text = transformInline(text.trim());
-
-  const outLines = [];
-
-  const indentString = ' '.repeat(4);
-
-  let levelIndents = [];
-  const openLevel = (indent) => {
-    // opening a sublist is a pain: to be semantically *and* visually
-    // correct, we have to append the <ul> at the end of the existing
-    // previous <li>
-    const previousLine = outLines[outLines.length - 1];
-    if (previousLine?.endsWith('</li>')) {
-      // we will re-close the <li> later
-      outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-    } else {
-      // if the previous line isn't a list item, this is the opening of
-      // the first list level, so no need for indent
-      outLines.push('<ul>');
-    }
-    levelIndents.push(indent);
-  };
-  const closeLevel = () => {
-    levelIndents.pop();
-    if (levelIndents.length) {
-      // closing a sublist, so close the list item containing it too
-      outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
-    } else {
-      // closing the final list level! no need for indent here
-      outLines.push('</ul>');
-    }
-  };
-
-  // okay yes we should support nested formatting, more than one blockquote
-  // layer, etc, but hear me out here: making all that work would basically
-  // be the same as implementing an entire markdown converter, which im not
-  // interested in doing lol. sorry!!!
-  let inBlockquote = false;
-
-  let lines = splitLines(text);
-  lines = joinLineBreaks(lines);
-  for (let line of lines) {
-    const imageLine = line.startsWith('<img');
-    line = line.replace(/<img (.*?)>/g, (match, attributes) =>
-      img({
-        lazy: true,
-        link: true,
-        thumb,
-        ...parseAttributes(attributes),
-      })
-    );
-
-    let indentThisLine = 0;
-    let lineContent = line;
-    let lineTag = 'p';
-
-    const listMatch = line.match(/^( *)- *(.*)$/);
-    if (listMatch) {
-      // is a list item!
-      if (!levelIndents.length) {
-        // first level is always indent = 0, regardless of actual line
-        // content (this is to avoid going to a lesser indent than the
-        // initial level)
-        openLevel(0);
-      } else {
-        // find level corresponding to indent
-        const indent = listMatch[1].length;
-        let i;
-        for (i = levelIndents.length - 1; i >= 0; i--) {
-          if (levelIndents[i] <= indent) break;
-        }
-        // note: i cannot equal -1 because the first indentation level
-        // is always 0, and the minimum indentation is also 0
-        if (levelIndents[i] === indent) {
-          // same indent! return to that level
-          while (levelIndents.length - 1 > i) closeLevel();
-          // (if this is already the current level, the above loop
-          // will do nothing)
-        } else if (levelIndents[i] < indent) {
-          // lesser indent! branch based on index
-          if (i === levelIndents.length - 1) {
-            // top level is lesser: add a new level
-            openLevel(indent);
-          } else {
-            // lower level is lesser: return to that level
-            while (levelIndents.length - 1 > i) closeLevel();
-          }
-        }
-      }
-      // finally, set variables for appending content line
-      indentThisLine = levelIndents.length;
-      lineContent = listMatch[2];
-      lineTag = 'li';
-    } else {
-      // not a list item! close any existing list levels
-      while (levelIndents.length) closeLevel();
-
-      // like i said, no nested shenanigans - quotes only appear outside
-      // of lists. sorry!
-      const quoteMatch = line.match(/^> *(.*)$/);
-      if (quoteMatch) {
-        // is a quote! open a blockquote tag if it doesnt already exist
-        if (!inBlockquote) {
-          inBlockquote = true;
-          outLines.push('<blockquote>');
-        }
-        indentThisLine = 1;
-        lineContent = quoteMatch[1];
-      } else if (inBlockquote) {
-        // not a quote! close a blockquote tag if it exists
-        inBlockquote = false;
-        outLines.push('</blockquote>');
-      }
-
-      // let some escaped symbols display as the normal symbol, since the
-      // point of escaping them is just to avoid having them be treated as
-      // syntax markers!
-      if (lineContent.match(/( *)\\-/)) {
-        lineContent = lineContent.replace('\\-', '-');
-      } else if (lineContent.match(/( *)\\>/)) {
-        lineContent = lineContent.replace('\\>', '>');
-      }
-    }
-
-    if (lineTag === 'p') {
-      // certain inline element tags should still be postioned within a
-      // paragraph; other elements (e.g. headings) should be added as-is
-      const elementMatch = line.match(/^<(.*?)[ >]/);
-      if (
-        elementMatch &&
-        !imageLine &&
-        ![
-          'a',
-          'abbr',
-          'b',
-          'bdo',
-          'br',
-          'cite',
-          'code',
-          'data',
-          'datalist',
-          'del',
-          'dfn',
-          'em',
-          'i',
-          'img',
-          'ins',
-          'kbd',
-          'mark',
-          'output',
-          'picture',
-          'q',
-          'ruby',
-          'samp',
-          'small',
-          'span',
-          'strong',
-          'sub',
-          'sup',
-          'svg',
-          'time',
-          'var',
-          'wbr',
-        ].includes(elementMatch[1])
-      ) {
-        lineTag = '';
-      }
-
-      // for sticky headings!
-      if (elementMatch) {
-        lineContent = lineContent.replace(/<h2/, `<h2 class="content-heading"`)
-      }
-    }
-
-    let pushString = indentString.repeat(indentThisLine);
-    if (lineTag) {
-      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-    } else {
-      pushString += lineContent;
-    }
-    outLines.push(pushString);
-  }
-
-  // after processing all lines...
-
-  // if still in a list, close all levels
-  while (levelIndents.length) closeLevel();
-
-  // if still in a blockquote, close its tag
-  if (inBlockquote) {
-    inBlockquote = false;
-    outLines.push('</blockquote>');
-  }
-
-  return outLines.join('\n');
-}
-
-function transformLyrics(text, {transformInline, transformMultiline}) {
-  // Different from transformMultiline 'cuz it joins multiple lines together
-  // with line 8reaks (<br>); transformMultiline treats each line as its own
-  // complete paragraph (or list, etc).
-
-  // If it looks like old data, then like, oh god.
-  // Use the normal transformMultiline tool.
-  if (text.includes('<br')) {
-    return transformMultiline(text);
-  }
-
-  text = transformInline(text.trim());
-
-  let buildLine = '';
-  const addLine = () => outLines.push(`<p>${buildLine}</p>`);
-  const outLines = [];
-  for (const line of text.split('\n')) {
-    if (line.length) {
-      if (buildLine.length) {
-        buildLine += '<br>';
-      }
-      buildLine += line;
-    } else if (buildLine.length) {
-      addLine();
-      buildLine = '';
-    }
-  }
-  if (buildLine.length) {
-    addLine();
-  }
-  return outLines.join('\n');
-}
-
 function stringifyThings(thingData) {
   return JSON.stringify(serializeThings(thingData));
 }
 
-function img({
-  src,
-  alt,
-  noSrcText = '',
-  thumb: thumbKey,
-  reveal,
-  id,
-  class: className,
-  width,
-  height,
-  link = false,
-  lazy = false,
-  square = false,
-}) {
-  const willSquare = square;
-  const willLink = typeof link === 'string' || link;
-
-  const originalSrc = src;
-  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-  const imgAttributes = {
-    id: link ? '' : id,
-    class: className,
-    alt,
-    width,
-    height,
-  };
-
-  const noSrcHTML =
-    !src &&
-      wrap(
-        html.tag('div',
-          {class: 'image-text-area'},
-          noSrcText));
-
-  const nonlazyHTML =
-    src &&
-      wrap(
-        html.tag('img', {
-          ...imgAttributes,
-          src: thumbSrc,
-        }));
-
-  const lazyHTML =
-    src &&
-    lazy &&
-      wrap(
-        html.tag('img',
-          {
-            ...imgAttributes,
-            class: [className, 'lazy'],
-            'data-original': thumbSrc,
-          }),
-        true);
-
-  if (!src) {
-    return noSrcHTML;
-  } else if (lazy) {
-    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
-  } else {
-    return nonlazyHTML;
-  }
-
-  function wrap(input, hide = false) {
-    let wrapped = input;
-
-    wrapped = html.tag('div', {class: 'image-inner-area'}, wrapped);
-    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
-
-    if (reveal) {
-      wrapped = html.tag('div', {class: 'reveal'}, [
-        wrapped,
-        html.tag('span', {class: 'reveal-text'}, reveal),
-      ]);
-    }
-
-    if (willSquare) {
-      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-      wrapped = html.tag('div',
-        {class: ['square', hide && !willLink && 'js-hide']},
-        wrapped);
-    }
-
-    if (willLink) {
-      wrapped = html.tag('a',
-        {
-          id,
-          class: ['box', hide && 'js-hide'],
-          href: typeof link === 'string' ? link : originalSrc,
-        },
-        wrapped);
-    }
-
-    return wrapped;
-  }
-}
-
 function validateWritePath(path, urlGroup) {
   if (!Array.isArray(path)) {
     return {error: `Expected array, got ${path}`};
@@ -1199,6 +643,7 @@ export function generateDocumentHTML(pageInfo, {
       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,
@@ -1206,6 +651,7 @@ export function generateDocumentHTML(pageInfo, {
           })),
         html.tag('div', {class: ['info-card-art-container', 'reveal']},
           img({
+            html,
             class: 'info-card-art',
             src: '',
             link: true,
@@ -1791,7 +1237,7 @@ async function main() {
   const appendIndexHTML = miscOptions['append-index-html'] ?? false;
   if (appendIndexHTML) {
     logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
-    unbound_link.globalOptions.appendIndexHTML = true;
+    link.globalOptions.appendIndexHTML = true;
   }
 
   const skipThumbs = miscOptions['skip-thumbs'] ?? false;
@@ -2334,202 +1780,12 @@ async function main() {
           );
         };
 
-        // TODO: Is there some nicer way to define these,
-        // may8e without totally re-8inding everything for
-        // each page?
-        const bound = {};
-
-        bound.html = html;
-
-        bound.getColors = bindOpts(getColors, {
-          chroma,
-        });
-
-        bound.getLinkThemeString = bindOpts(unbound_getLinkThemeString, {
-          getColors: bound.getColors,
-        });
-
-        bound.getThemeString = bindOpts(unbound_getThemeString, {
-          getColors: bound.getColors,
-        });
-
-        bound.link = withEntries(unbound_link, (entries) =>
-          entries
-            .map(([key, fn]) => [key, bindOpts(fn, {
-              getLinkThemeString: bound.getLinkThemeString,
-              to,
-            })]));
-
-        bound.parseAttributes = bindOpts(parseAttributes, {
-          to,
-        });
-
-        bound.find = bindFind(wikiData, {mode: 'warn'});
-
-        bound.transformInline = bindOpts(transformInline, {
-          find: bound.find,
-          link: bound.link,
-          replacerSpec,
+        const bound = bindUtilities({
           language,
           to,
           wikiData,
         });
 
-        bound.transformMultiline = bindOpts(transformMultiline, {
-          transformInline: bound.transformInline,
-          parseAttributes: bound.parseAttributes,
-        });
-
-        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.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-          html,
-          language,
-          link: bound.link,
-          wikiData,
-
-          generateNavigationLinks: bound.generateNavigationLinks,
-        });
-
-        bound.generateCoverLink = bindOpts(generateCoverLink, {
-          [bindOpts.bindIndex]: 0,
-          html,
-          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,
-          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,
-          html,
-        })
-
-        bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-          to,
-        });
-
         const pageInfo = page({
           ...bound,
 
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
new file mode 100644
index 00000000..2a7d859b
--- /dev/null
+++ b/src/util/transform-content.js
@@ -0,0 +1,447 @@
+// See also replacer.js, which covers the actual syntax parser and node
+// interpreter. This file works with replacer.js to provide higher-level
+// interfaces for converting various content found in wiki data to HTML for
+// display on the site.
+
+import * as html from './html.js';
+export {transformInline} from './replacer.js';
+
+export const replacerSpec = {
+  album: {
+    find: 'album',
+    link: 'album',
+  },
+  'album-commentary': {
+    find: 'album',
+    link: 'albumCommentary',
+  },
+  'album-gallery': {
+    find: 'album',
+    link: 'albumGallery',
+  },
+  artist: {
+    find: 'artist',
+    link: 'artist',
+  },
+  'artist-gallery': {
+    find: 'artist',
+    link: 'artistGallery',
+  },
+  'commentary-index': {
+    find: null,
+    link: 'commentaryIndex',
+  },
+  date: {
+    find: null,
+    value: (ref) => new Date(ref),
+    html: (date, {language}) =>
+      html.tag('time',
+        {datetime: date.toString()},
+        language.formatDate(date)),
+  },
+  'flash-index': {
+    find: null,
+    link: 'flashIndex',
+  },
+  flash: {
+    find: 'flash',
+    link: 'flash',
+    transformName(name, node, input) {
+      const nextCharacter = input[node.iEnd];
+      const lastCharacter = name[name.length - 1];
+      if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') {
+        return name.slice(0, -1);
+      } else {
+        return name;
+      }
+    },
+  },
+  group: {
+    find: 'group',
+    link: 'groupInfo',
+  },
+  'group-gallery': {
+    find: 'group',
+    link: 'groupGallery',
+  },
+  home: {
+    find: null,
+    link: 'home',
+  },
+  'listing-index': {
+    find: null,
+    link: 'listingIndex',
+  },
+  listing: {
+    find: 'listing',
+    link: 'listing',
+  },
+  media: {
+    find: null,
+    link: 'media',
+  },
+  'news-index': {
+    find: null,
+    link: 'newsIndex',
+  },
+  'news-entry': {
+    find: 'newsEntry',
+    link: 'newsEntry',
+  },
+  root: {
+    find: null,
+    link: 'root',
+  },
+  site: {
+    find: null,
+    link: 'site',
+  },
+  static: {
+    find: 'staticPage',
+    link: 'staticPage',
+  },
+  string: {
+    find: null,
+    value: (ref) => ref,
+    html: (ref, {language, args}) => language.$(ref, args),
+  },
+  tag: {
+    find: 'artTag',
+    link: 'tag',
+  },
+  track: {
+    find: 'track',
+    link: 'track',
+  },
+};
+
+function splitLines(text) {
+  return text.split(/\r\n|\r|\n/);
+}
+
+function joinLineBreaks(sourceLines) {
+  const outLines = [];
+
+  let lineSoFar = '';
+  for (let i = 0; i < sourceLines.length; i++) {
+    const line = sourceLines[i];
+    lineSoFar += line;
+    if (!line.endsWith('<br>')) {
+      outLines.push(lineSoFar);
+      lineSoFar = '';
+    }
+  }
+
+  if (lineSoFar) {
+    outLines.push(lineSoFar);
+  }
+
+  return outLines;
+}
+
+function parseAttributes(string, {to}) {
+  const attributes = Object.create(null);
+  const skipWhitespace = (i) => {
+    const ws = /\s/;
+    if (ws.test(string[i])) {
+      const match = string.slice(i).match(/[^\s]/);
+      if (match) {
+        return i + match.index;
+      } else {
+        return string.length;
+      }
+    } else {
+      return i;
+    }
+  };
+
+  for (let i = 0; i < string.length; ) {
+    i = skipWhitespace(i);
+    const aStart = i;
+    const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
+    const attribute = string.slice(aStart, aEnd);
+    i = skipWhitespace(aEnd);
+    if (string[i] === '=') {
+      i = skipWhitespace(i + 1);
+      let end, endOffset;
+      if (string[i] === '"' || string[i] === "'") {
+        end = string[i];
+        endOffset = 1;
+        i++;
+      } else {
+        end = '\\s';
+        endOffset = 0;
+      }
+      const vStart = i;
+      const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
+      const value = string.slice(vStart, vEnd);
+      i = vEnd + endOffset;
+      if (attribute === 'src' && value.startsWith('media/')) {
+        attributes[attribute] = to('media.path', value.slice('media/'.length));
+      } else {
+        attributes[attribute] = value;
+      }
+    } else {
+      attributes[attribute] = attribute;
+    }
+  }
+  return Object.fromEntries(
+    Object.entries(attributes).map(([key, val]) => [
+      key,
+      val === 'true'
+        ? true
+        : val === 'false'
+        ? false
+        : val === key
+        ? true
+        : val,
+    ])
+  );
+}
+
+function unbound_transformMultiline(text, {
+  img,
+  to,
+  transformInline,
+
+  thumb = null,
+}) {
+  // Heck yes, HTML magics.
+
+  text = transformInline(text.trim());
+
+  const outLines = [];
+
+  const indentString = ' '.repeat(4);
+
+  let levelIndents = [];
+  const openLevel = (indent) => {
+    // opening a sublist is a pain: to be semantically *and* visually
+    // correct, we have to append the <ul> at the end of the existing
+    // previous <li>
+    const previousLine = outLines[outLines.length - 1];
+    if (previousLine?.endsWith('</li>')) {
+      // we will re-close the <li> later
+      outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
+    } else {
+      // if the previous line isn't a list item, this is the opening of
+      // the first list level, so no need for indent
+      outLines.push('<ul>');
+    }
+    levelIndents.push(indent);
+  };
+  const closeLevel = () => {
+    levelIndents.pop();
+    if (levelIndents.length) {
+      // closing a sublist, so close the list item containing it too
+      outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
+    } else {
+      // closing the final list level! no need for indent here
+      outLines.push('</ul>');
+    }
+  };
+
+  // okay yes we should support nested formatting, more than one blockquote
+  // layer, etc, but hear me out here: making all that work would basically
+  // be the same as implementing an entire markdown converter, which im not
+  // interested in doing lol. sorry!!!
+  let inBlockquote = false;
+
+  let lines = splitLines(text);
+  lines = joinLineBreaks(lines);
+  for (let line of lines) {
+    const imageLine = line.startsWith('<img');
+    line = line.replace(/<img (.*?)>/g, (match, attributes) =>
+      img({
+        lazy: true,
+        link: true,
+        thumb,
+        ...parseAttributes(attributes, {to}),
+      })
+    );
+
+    let indentThisLine = 0;
+    let lineContent = line;
+    let lineTag = 'p';
+
+    const listMatch = line.match(/^( *)- *(.*)$/);
+    if (listMatch) {
+      // is a list item!
+      if (!levelIndents.length) {
+        // first level is always indent = 0, regardless of actual line
+        // content (this is to avoid going to a lesser indent than the
+        // initial level)
+        openLevel(0);
+      } else {
+        // find level corresponding to indent
+        const indent = listMatch[1].length;
+        let i;
+        for (i = levelIndents.length - 1; i >= 0; i--) {
+          if (levelIndents[i] <= indent) break;
+        }
+        // note: i cannot equal -1 because the first indentation level
+        // is always 0, and the minimum indentation is also 0
+        if (levelIndents[i] === indent) {
+          // same indent! return to that level
+          while (levelIndents.length - 1 > i) closeLevel();
+          // (if this is already the current level, the above loop
+          // will do nothing)
+        } else if (levelIndents[i] < indent) {
+          // lesser indent! branch based on index
+          if (i === levelIndents.length - 1) {
+            // top level is lesser: add a new level
+            openLevel(indent);
+          } else {
+            // lower level is lesser: return to that level
+            while (levelIndents.length - 1 > i) closeLevel();
+          }
+        }
+      }
+      // finally, set variables for appending content line
+      indentThisLine = levelIndents.length;
+      lineContent = listMatch[2];
+      lineTag = 'li';
+    } else {
+      // not a list item! close any existing list levels
+      while (levelIndents.length) closeLevel();
+
+      // like i said, no nested shenanigans - quotes only appear outside
+      // of lists. sorry!
+      const quoteMatch = line.match(/^> *(.*)$/);
+      if (quoteMatch) {
+        // is a quote! open a blockquote tag if it doesnt already exist
+        if (!inBlockquote) {
+          inBlockquote = true;
+          outLines.push('<blockquote>');
+        }
+        indentThisLine = 1;
+        lineContent = quoteMatch[1];
+      } else if (inBlockquote) {
+        // not a quote! close a blockquote tag if it exists
+        inBlockquote = false;
+        outLines.push('</blockquote>');
+      }
+
+      // let some escaped symbols display as the normal symbol, since the
+      // point of escaping them is just to avoid having them be treated as
+      // syntax markers!
+      if (lineContent.match(/( *)\\-/)) {
+        lineContent = lineContent.replace('\\-', '-');
+      } else if (lineContent.match(/( *)\\>/)) {
+        lineContent = lineContent.replace('\\>', '>');
+      }
+    }
+
+    if (lineTag === 'p') {
+      // certain inline element tags should still be postioned within a
+      // paragraph; other elements (e.g. headings) should be added as-is
+      const elementMatch = line.match(/^<(.*?)[ >]/);
+      if (
+        elementMatch &&
+        !imageLine &&
+        ![
+          'a',
+          'abbr',
+          'b',
+          'bdo',
+          'br',
+          'cite',
+          'code',
+          'data',
+          'datalist',
+          'del',
+          'dfn',
+          'em',
+          'i',
+          'img',
+          'ins',
+          'kbd',
+          'mark',
+          'output',
+          'picture',
+          'q',
+          'ruby',
+          'samp',
+          'small',
+          'span',
+          'strong',
+          'sub',
+          'sup',
+          'svg',
+          'time',
+          'var',
+          'wbr',
+        ].includes(elementMatch[1])
+      ) {
+        lineTag = '';
+      }
+
+      // for sticky headings!
+      if (elementMatch) {
+        lineContent = lineContent.replace(/<h2/, `<h2 class="content-heading"`)
+      }
+    }
+
+    let pushString = indentString.repeat(indentThisLine);
+    if (lineTag) {
+      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
+    } else {
+      pushString += lineContent;
+    }
+    outLines.push(pushString);
+  }
+
+  // after processing all lines...
+
+  // if still in a list, close all levels
+  while (levelIndents.length) closeLevel();
+
+  // if still in a blockquote, close its tag
+  if (inBlockquote) {
+    inBlockquote = false;
+    outLines.push('</blockquote>');
+  }
+
+  return outLines.join('\n');
+}
+
+function unbound_transformLyrics(text, {
+  transformInline,
+  transformMultiline,
+}) {
+  // Different from transformMultiline 'cuz it joins multiple lines together
+  // with line 8reaks (<br>); transformMultiline treats each line as its own
+  // complete paragraph (or list, etc).
+
+  // If it looks like old data, then like, oh god.
+  // Use the normal transformMultiline tool.
+  if (text.includes('<br')) {
+    return transformMultiline(text);
+  }
+
+  text = transformInline(text.trim());
+
+  let buildLine = '';
+  const addLine = () => outLines.push(`<p>${buildLine}</p>`);
+  const outLines = [];
+  for (const line of text.split('\n')) {
+    if (line.length) {
+      if (buildLine.length) {
+        buildLine += '<br>';
+      }
+      buildLine += line;
+    } else if (buildLine.length) {
+      addLine();
+      buildLine = '';
+    }
+  }
+  if (buildLine.length) {
+    addLine();
+  }
+  return outLines.join('\n');
+}
+
+export {
+  unbound_transformLyrics as transformLyrics,
+  unbound_transformMultiline as transformMultiline
+}
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
new file mode 100644
index 00000000..6632ba30
--- /dev/null
+++ b/src/write/bind-utilities.js
@@ -0,0 +1,257 @@
+// 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({
+  language,
+  to,
+  wikiData,
+}) {
+  // TODO: Is there some nicer way to define these,
+  // may8e without totally re-8inding everything for
+  // each page?
+  const bound = {};
+
+  bound.html = html;
+
+  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;
+}