« 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/content/dependencies/transformContent.js246
-rwxr-xr-xsrc/upd8.js6
-rw-r--r--src/util/replacer.js284
-rw-r--r--src/util/transform-content.js452
-rw-r--r--src/write/bind-utilities.js29
-rw-r--r--src/write/build-modes/live-dev-server.js17
-rw-r--r--tap-snapshots/test/snapshot/transformContent.js.test.cjs46
-rw-r--r--test/snapshot/transformContent.js60
8 files changed, 462 insertions, 678 deletions
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 03bd7bc..2c0a8e4 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -2,7 +2,115 @@ import {marked} from 'marked';
 
 import {bindFind} from '../../util/find.js';
 import {parseInput} from '../../util/replacer.js';
-import {replacerSpec} from '../../util/transform-content.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, {html, 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',
+  },
+};
 
 const linkThingRelationMap = {
   album: 'linkAlbum',
@@ -43,9 +151,10 @@ export default {
     ...Object.values(linkThingRelationMap),
     ...Object.values(linkValueRelationMap),
     ...Object.values(linkIndexRelationMap),
+    'image',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'language', 'to', 'wikiData'],
 
   sprawl(wikiData, content) {
     const find = bindFind(wikiData);
@@ -140,14 +249,16 @@ export default {
       nodes:
         sprawl.nodes
           .map(node => {
-            // Replace link nodes with a stub. It'll be replaced (by position)
-            // with an item from relations.
-            if (node.type === 'link') {
-              return {type: 'link'};
+            switch (node.type) {
+              // Replace link nodes with a stub. It'll be replaced (by position)
+              // with an item from relations.
+              case 'link':
+                return {type: 'link'};
+
+              // Other nodes will get processed in generate.
+              default:
+                return node;
             }
-
-            // Other nodes will get processed in generate.
-            return node;
           }),
     };
   },
@@ -181,6 +292,12 @@ export default {
               return relationOrPlaceholder(node, linkIndexRelationMap[key]);
             }
           }),
+
+      images:
+        nodes
+          .filter(({type}) => type === 'image')
+          .filter(({inline}) => !inline)
+          .map(() => relation('image')),
     };
   },
 
@@ -200,63 +317,96 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
+  generate(data, relations, slots, {html, language, to}) {
     let linkIndex = 0;
+    let imageIndex = 0;
 
     // This array contains only straight text and link nodes, which are directly
     // representable in html (so no further processing is needed on the level of
     // individual nodes).
     const contentFromNodes =
       data.nodes.map(node => {
-        if (node.type === 'text') {
-          return {type: 'text', data: node.data};
-        }
+        switch (node.type) {
+          case 'text':
+            return {type: 'text', data: node.data};
+
+          case 'image': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height} = node;
+
+            if (node.inline) {
+              return {
+                type: 'image',
+                data:
+                  html.tag('img', {src, width, height}),
+              };
+            }
 
-        if (node.type === 'link') {
-          const linkNode = relations.links[linkIndex++];
-          if (linkNode.type === 'text') {
-            return {type: 'text', data: linkNode.data};
+            const image = relations.images[imageIndex++];
+
+            return {
+              type: 'image',
+              data:
+                image.slots({
+                  src,
+                  link: true,
+                  width: width ?? null,
+                  height: height ?? null,
+                }),
+            };
           }
 
-          const {link, label, hash, toIndex} = linkNode;
-
-          return {
-            type: 'link',
-            data:
-              (toIndex
-                ? link.slots({content: label, hash})
-                : link.slots({
-                    content: label, hash,
-                    preferShortName: slots.preferShortLinkNames,
-                  })),
-          };
-        }
+          case 'link': {
+            const linkNode = relations.links[linkIndex++];
+            if (linkNode.type === 'text') {
+              return {type: 'text', data: linkNode.data};
+            }
 
-        if (node.type === 'tag') {
-          const {replacerKey, replacerValue} = node.data;
+            const {link, label, hash, toIndex} = linkNode;
+
+            return {
+              type: 'link',
+              data:
+                (toIndex
+                  ? link.slots({content: label, hash})
+                  : link.slots({
+                      content: label, hash,
+                      preferShortName: slots.preferShortLinkNames,
+                    })),
+            };
+          }
 
-          const spec = replacerSpec[replacerKey];
+          case 'tag': {
+            const {replacerKey, replacerValue} = node.data;
 
-          if (!spec) {
-            return getPlaceholder(node, data.content);
-          }
+            const spec = replacerSpec[replacerKey];
 
-          const {value: valueFn, html: htmlFn} = spec;
+            if (!spec) {
+              return getPlaceholder(node, data.content);
+            }
 
-          const value =
-            (valueFn
-              ? valueFn(replacerValue)
-              : replacerValue);
+            const {value: valueFn, html: htmlFn} = spec;
 
-          const contents =
-            (htmlFn
-              ? htmlFn(value, {html, language})
-              : value);
+            const value =
+              (valueFn
+                ? valueFn(replacerValue)
+                : replacerValue);
 
-          return {type: 'text', data: contents};
-        }
+            const contents =
+              (htmlFn
+                ? htmlFn(value, {html, language})
+                : value);
+
+            return {type: 'text', data: contents};
+          }
 
-        return getPlaceholder(node, data.content);
+          default:
+            return getPlaceholder(node, data.content);
+        }
       });
 
     // In single-link mode, return the link node exactly as is - exposing
diff --git a/src/upd8.js b/src/upd8.js
index 366dc21..11ede7b 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -63,9 +63,7 @@ import find from './util/find.js';
 import {findFiles} from './util/io.js';
 import link from './util/link.js';
 import {isMain, traverse} from './util/node-utils.js';
-import {validateReplacerSpec} from './util/replacer.js';
 import {empty, showAggregate, withEntries} from './util/sugar.js';
-import {replacerSpec} from './util/transform-content.js';
 import {generateURLs} from './util/urls.js';
 import {sortByName} from './util/wiki-data.js';
 
@@ -100,10 +98,6 @@ const BUILD_TIME = new Date();
 
 const DEFAULT_STRINGS_FILE = 'strings-default.json';
 
-if (!validateReplacerSpec(replacerSpec, {find, link})) {
-  process.exit();
-}
-
 async function main() {
   Error.stackTraceLimit = Infinity;
 
diff --git a/src/util/replacer.js b/src/util/replacer.js
index 50a9000..7240940 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -1,23 +1,6 @@
 import {logError, logWarn} from './cli.js';
 import {escapeRegex} from './sugar.js';
 
-export function validateReplacerSpec(replacerSpec, {find, link}) {
-  let success = true;
-
-  for (const [key, {link: linkKey, find: findKey, html}] of Object.entries(replacerSpec)) {
-    if (!html && !link[linkKey]) {
-      logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
-      success = false;
-    }
-    if (findKey && !find[findKey]) {
-      logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
-      success = false;
-    }
-  }
-
-  return success;
-}
-
 // Syntax literals.
 const tagBeginning = '[[';
 const tagEnding = ']]';
@@ -292,13 +275,157 @@ function parseNodes(input, i, stopAt, textOnly) {
   return nodes;
 }
 
+function parseAttributes(string) {
+  const attributes = Object.create(null);
+
+  const skipWhitespace = i => {
+    if (!/\s/.test(string[i])) {
+      return i;
+    }
+
+    const match = string.slice(i).match(/[^\s]/);
+    if (match) {
+      return i + match.index;
+    }
+
+    return string.length;
+  };
+
+  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;
+      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,
+        ])));
+}
+
+export function postprocessImages(inputNodes) {
+  const outputNodes = [];
+
+  let atStartOfLine = true;
+
+  const lastNode = inputNodes[inputNodes.length - 1];
+
+  for (const node of inputNodes) {
+    if (node.type === 'tag') {
+      atStartOfLine = false;
+    }
+
+    if (node.type === 'text') {
+      const imageRegexp = /<img (.*?)>/g;
+
+      let match = null, parseFrom = 0;
+      while (match = imageRegexp.exec(node.data)) {
+        const previousText = node.data.slice(parseFrom, match.index);
+        outputNodes.push({type: 'text', data: previousText});
+        parseFrom = match.index + match[0].length;
+
+        const imageNode = {type: 'image'};
+        const attributes = parseAttributes(match[1]);
+
+        imageNode.src = attributes.src;
+
+        if (previousText.endsWith('\n')) {
+          atStartOfLine = true;
+        }
+
+        imageNode.inline = (() => {
+          // If we've already determined we're in the middle of a line,
+          // we're inline. (Of course!)
+          if (!atStartOfLine) {
+            return true;
+          }
+
+          // If there's more text to go in this text node, and what's
+          // remaining doesn't start with a line break, we're inline.
+          if (
+            parseFrom !== node.data.length &&
+            node.data[parseFrom] !== '\n'
+          ) {
+            return true;
+          }
+
+          // If we're at the end of this text node, but this text node
+          // isn't the last node overall, we're inline.
+          if (
+            parseFrom === node.data.length &&
+            node !== lastNode
+          ) {
+            return true;
+          }
+
+          // If no other condition matches, this image is on its own line.
+          return false;
+        })();
+
+        if (attributes.width) imageNode.width = parseInt(attributes.width);
+        if (attributes.height) imageNode.height = parseInt(attributes.height);
+
+        outputNodes.push(imageNode);
+
+        // No longer at the start of a line after an image - there will at
+        // least be a text node with only '\n' before the next image that's
+        // on its own line.
+        atStartOfLine = false;
+      }
+
+      if (parseFrom !== node.data.length) {
+        outputNodes.push({
+          type: 'text',
+          data: node.data.slice(parseFrom),
+        });
+      }
+
+      continue;
+    }
+
+    outputNodes.push(node);
+  }
+
+  return outputNodes;
+}
+
 export function parseInput(input) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${input}`);
   }
 
   try {
-    return parseNodes(input, 0);
+    return postprocessImages(parseNodes(input, 0));
   } catch (errorNode) {
     if (errorNode.type !== 'error') {
       throw errorNode;
@@ -334,124 +461,3 @@ export function parseInput(input) {
     ].join('\n'));
   }
 }
-
-function evaluateTag(node, opts) {
-  const {find, input, language, link, replacerSpec, to} = opts;
-
-  const source = input.slice(node.i, node.iEnd);
-
-  const replacerKeyImplied = !node.data.replacerKey;
-  const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
-
-  if (!replacerSpec[replacerKey]) {
-    logWarn`The link ${source} has an invalid replacer key!`;
-    return source;
-  }
-
-  const {
-    find: findKey,
-    link: linkKey,
-    value: valueFn,
-    html: htmlFn,
-    transformName,
-  } = replacerSpec[replacerKey];
-
-  const replacerValue = transformNodes(node.data.replacerValue, opts);
-
-  const value = valueFn
-    ? valueFn(replacerValue)
-    : findKey
-    ? find[findKey](
-        replacerKeyImplied ? replacerValue : replacerKey + `:` + replacerValue
-      )
-    : {
-        directory: replacerValue,
-        name: null,
-      };
-
-  if (!value) {
-    logWarn`The link ${source} does not match anything!`;
-    return source;
-  }
-
-  const enteredLabel = node.data.label && transformNode(node.data.label, opts);
-
-  const label =
-    enteredLabel ||
-    (transformName && transformName(value.name, node, input)) ||
-    null;
-
-  const hash = node.data.hash && transformNode(node.data.hash, opts);
-
-  const args =
-    node.data.args &&
-    Object.fromEntries(
-      node.data.args.map(({key, value}) => [
-        transformNode(key, opts),
-        transformNodes(value, opts),
-      ])
-    );
-
-  const fn = htmlFn ? htmlFn : link[linkKey];
-
-  try {
-    return fn(value, {text: label, hash, args, language, to});
-  } catch (error) {
-    logError`The link ${source} failed to be processed: ${error}`;
-    return source;
-  }
-}
-
-function transformNode(node, opts) {
-  if (!node) {
-    throw new Error('Expected a node!');
-  }
-
-  if (Array.isArray(node)) {
-    throw new Error('Got an array - use transformNodes here!');
-  }
-
-  switch (node.type) {
-    case 'text':
-      return node.data;
-    case 'tag':
-      return evaluateTag(node, opts);
-    default:
-      throw new Error(`Unknown node type ${node.type}`);
-  }
-}
-
-function transformNodes(nodes, opts) {
-  if (!nodes || !Array.isArray(nodes)) {
-    throw new Error(`Expected an array of nodes! Got: ${nodes}`);
-  }
-
-  return nodes.map((node) => transformNode(node, opts)).join('');
-}
-
-export function transformInline(input, {
-  replacerSpec,
-  find,
-  language,
-  link,
-  to,
-  wikiData,
-}) {
-  if (!replacerSpec) throw new Error('Expected replacerSpec');
-  if (!find) throw new Error('Expected find');
-  if (!language) throw new Error('Expected language');
-  if (!link) throw new Error('Expected link');
-  if (!to) throw new Error('Expected to');
-  if (!wikiData) throw new Error('Expected wikiData');
-
-  const nodes = parseInput(input);
-  return transformNodes(nodes, {
-    input,
-    find,
-    link,
-    replacerSpec,
-    language,
-    to,
-    wikiData,
-  });
-}
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
deleted file mode 100644
index 454cb37..0000000
--- a/src/util/transform-content.js
+++ /dev/null
@@ -1,452 +0,0 @@
-// 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.
-
-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, {html, 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 && elementMatch[1] === 'h2') {
-        lineContent = lineContent.replace(/<h2(.*?)>/g, (match, attributes) => {
-          const parsedAttributes = parseAttributes(attributes, {to});
-          return `<h2 ${html.attributes({
-            ...parsedAttributes,
-            class: [...parsedAttributes.class?.split(' ') ?? [], '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
index d605335..2ddc2b3 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -4,13 +4,6 @@
 
 import chroma from 'chroma-js';
 
-import {
-  replacerSpec,
-  transformInline,
-  // transformLyrics,
-  // transformMultiline,
-} from '../util/transform-content.js';
-
 import * as html from '../util/html.js';
 
 import {bindOpts} from '../util/sugar.js';
@@ -57,28 +50,6 @@ export function bindUtilities({
 
   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.generateNavigationLinks = bindOpts(generateNavigationLinks, {
     link: bound.link,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index d7c33d8..78c3928 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -56,6 +56,11 @@ export function getCLIOptions() {
         return true;
       },
     },
+
+    'quiet-responses': {
+      help: `Disables outputting [200] and [404] responses in the server log`,
+      type: 'flag',
+    },
   };
 }
 
@@ -86,6 +91,7 @@ export async function go({
 
   const host = cliOptions['host'] ?? defaultHost;
   const port = parseInt(cliOptions['port'] ?? defaultPort);
+  const quietResponses = cliOptions['quiet-responses'] ?? false;
 
   const contentDependenciesWatcher = await watchContentDependencies();
   const {contentDependencies: allContentDependencies} = contentDependenciesWatcher;
@@ -167,7 +173,7 @@ export async function go({
         });
         response.writeHead(200, contentTypeJSON);
         response.end(json);
-        console.log(`${requestHead} [200] /data.json`);
+        if (!quietResponses) console.log(`${requestHead} [200] /data.json`);
       } catch (error) {
         response.writeHead(500, contentTypeJSON);
         response.end({error: `Internal error serializing wiki JSON`});
@@ -263,7 +269,7 @@ export async function go({
         await pipeline(
           createReadStream(filePath),
           response);
-        console.log(`${requestHead} [200] ${pathname}`);
+        if (!quietResponses) console.log(`${requestHead} [200] ${pathname}`);
       } catch (error) {
         response.writeHead(500, contentTypePlain);
         response.end(`Failed during file-to-response pipeline`);
@@ -281,7 +287,7 @@ export async function go({
     if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
       response.writeHead(404, contentTypePlain);
       response.end(`No page found for: ${pathnameKey}\n`);
-      console.log(`${requestHead} [404] ${pathname}`);
+      if (!quietResponses) console.log(`${requestHead} [404] ${pathname}`);
       return;
     }
 
@@ -462,7 +468,7 @@ export async function go({
 
       const pageHTML = topLevelResult.toString();
 
-      console.log(`${requestHead} [200] ${pathname}`);
+      if (!quietResponses) console.log(`${requestHead} [200] ${pathname}`);
       response.writeHead(200, contentTypeHTML);
       response.end(pageHTML);
     } catch (error) {
@@ -492,6 +498,9 @@ export async function go({
   server.on('listening', () => {
     logInfo`${'All done!'} Listening at: ${address}`;
     logInfo`Press ^C here (control+C) to stop the server and exit.`;
+    if (quietResponses) {
+      logInfo`Suppressing [200] and [404] response logging.`;
+    }
   });
 
   server.listen(port, host);
diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
new file mode 100644
index 0000000..a59f8b5
--- /dev/null
+++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
@@ -0,0 +1,46 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > inline images 1`] = `
+<p><img src="snooping.png"> as USUAL...</p>
+<p>What do you know? <img src="cowabunga.png" width="24" height="32"></p>
+<p><a href="to-localized.album/cool-album" style="--primary-color: #123456; --dim-color: #000000">I&#39;m on the left.</a><img src="im-on-the-right.jpg"></p>
+<p><img src="im-on-the-left.jpg"><a href="to-localized.album/cool-album" style="--primary-color: #123456; --dim-color: #000000">I&#39;m on the right.</a></p>
+<p>Media time! <img src="to-media.path/misc/interesting.png"> Oh yeah!</p>
+<p><img src="must.png"><img src="stick.png"><img src="together.png"></p>
+<p>And... all done! <img src="end-of-source.png"></p>
+
+`
+
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > links to a thing 1`] = `
+<p>This is <a href="to-localized.album/cool-album" style="--primary-color: #123456; --dim-color: #000000">my favorite album</a>.</p>
+<p>That&#39;s right, <a href="to-localized.album/cool-album" style="--primary-color: #123456; --dim-color: #000000">Cool Album</a>!</p>
+
+`
+
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #1 1`] = `
+<p><a class="box image-link" href="spark.png"><div class="image-container"><div class="image-inner-area"><img src="spark.png"></div></div></a></p>
+
+`
+
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #2 1`] = `
+<p>Rad.</p>
+<p><a class="box image-link" href="spark.png"><div class="image-container"><div class="image-inner-area"><img src="spark.png"></div></div></a></p>
+
+`
+
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #3 1`] = `
+<p><a class="box image-link" href="spark.png"><div class="image-container"><div class="image-inner-area"><img src="spark.png"></div></div></a></p>
+<p>Baller.</p>
+
+`
+
+exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > two text paragraphs 1`] = `
+<p>Hello, world!</p>
+<p>Wow, this is very cool.</p>
+
+`
diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js
new file mode 100644
index 0000000..f55ca4f
--- /dev/null
+++ b/test/snapshot/transformContent.js
@@ -0,0 +1,60 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const extraDependencies = {
+    wikiData: {
+      albumData: [
+        {directory: 'cool-album', name: 'Cool Album', color: '#123456'},
+      ],
+    },
+
+    getSizeOfImageFile: () => 0,
+
+    to: (key, ...args) => `to-${key}/${args.join('/')}`,
+  };
+
+  const quickSnapshot = (message, content, slots) =>
+    evaluate.snapshot(message, {
+      name: 'transformContent',
+      args: [content],
+      extraDependencies,
+      slots,
+    });
+
+  // TODO: Snapshots for different transformContent modes
+
+  quickSnapshot(
+    'two text paragraphs',
+      `Hello, world!\n` +
+      `Wow, this is very cool.`);
+
+  quickSnapshot(
+    'links to a thing',
+      `This is [[album:cool-album|my favorite album]].\n` +
+      `That's right, [[album:cool-album]]!`);
+
+  quickSnapshot(
+    'inline images',
+      `<img src="snooping.png"> as USUAL...\n` +
+      `What do you know? <img src="cowabunga.png" width="24" height="32">\n` +
+      `[[album:cool-album|I'm on the left.]]<img src="im-on-the-right.jpg">\n` +
+      `<img src="im-on-the-left.jpg">[[album:cool-album|I'm on the right.]]\n` +
+      `Media time! <img src="media/misc/interesting.png"> Oh yeah!\n` +
+      `<img src="must.png"><img src="stick.png"><img src="together.png">\n` +
+      `And... all done! <img src="end-of-source.png">`);
+
+  quickSnapshot(
+    'non-inline image #1',
+      `<img src="spark.png">`);
+
+  quickSnapshot(
+    'non-inline image #2',
+      `Rad.\n<img src="spark.png">`);
+
+  quickSnapshot(
+    'non-inline image #3',
+      `<img src="spark.png">\nBaller.`);
+});