« get me outta code hell

Merge branch 'preview' into track-data-cleanup - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-09-11 15:27:21 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-09-11 15:27:21 -0300
commitf346edce634eff3d38aabc69c40ddf3bf4b70aac (patch)
treea2ffe1ec436cc60f3b0c8f911f3a148a4dd9355c
parentc4f6c41a248ba9ef4f802cc03c20757d417540e4 (diff)
parent44f1442bf28bac7b07ac25c1ea15c6b3a9d1223a (diff)
Merge branch 'preview' into track-data-cleanup
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js2
-rw-r--r--src/content/dependencies/generateCoverGrid.js12
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js2
-rw-r--r--src/content/dependencies/generatePageLayout.js6
-rw-r--r--src/content/dependencies/image.js42
-rw-r--r--src/content/dependencies/linkTemplate.js2
-rw-r--r--src/content/dependencies/linkThing.js2
-rw-r--r--src/data/things/language.js113
-rw-r--r--src/gen-thumbs.js39
-rw-r--r--src/static/client2.js10
-rw-r--r--src/static/site4.css2
-rw-r--r--src/strings-default.json1
-rwxr-xr-xsrc/upd8.js6
-rw-r--r--src/util/html.js57
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/live-dev-server.js2
-rw-r--r--src/write/build-modes/static-build.js2
-rw-r--r--tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs12
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs8
19 files changed, 231 insertions, 91 deletions
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index 2aca6da..d71b0bd 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -82,7 +82,7 @@ export default {
             (data.hasTrackNumbers
               ? language.$('albumSidebar.trackList.group.withRange', {
                   group: sectionName,
-                  range: `${data.firstTrackNumber}&ndash;${data.lastTrackNumber}`
+                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
                 })
               : language.$('albumSidebar.trackList.group', {
                   group: sectionName,
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 9822e1a..5636e4f 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -2,7 +2,7 @@ import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['generateGridActionLinks'],
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation) {
     return {
@@ -20,7 +20,7 @@ export default {
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html}) {
+  generate(relations, slots, {html, language}) {
     return (
       html.tag('div', {class: 'grid-listing'}, [
         stitchArrays({
@@ -42,8 +42,12 @@ export default {
                       ? slots.lazy
                       : false),
                 }),
-                html.tag('span', {[html.onlyIfContent]: true}, name),
-                html.tag('span', {[html.onlyIfContent]: true}, info),
+
+                html.tag('span', {[html.onlyIfContent]: true},
+                  language.sanitize(name)),
+
+                html.tag('span', {[html.onlyIfContent]: true},
+                  language.sanitize(info)),
               ],
             })),
 
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
index b4970b1..5df8356 100644
--- a/src/content/dependencies/generateFooterLocalizationLinks.js
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -38,7 +38,7 @@ export default {
 
     return html.tag('div', {class: 'footer-localization-links'},
       language.$('misc.uiLanguage', {
-        languages: links.join('\n'),
+        languages: language.formatListWithoutSeparator(links),
       }));
   },
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 95a5dbe..5377f80 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -105,7 +105,7 @@ export default {
     color: {validate: v => v.isColor},
 
     styleRules: {
-      validate: v => v.sparseArrayOf(v.isString),
+      validate: v => v.sparseArrayOf(v.isHTML),
       default: [],
     },
 
@@ -183,7 +183,7 @@ export default {
           } else {
             aggregate.call(v.validateProperties({
               path: v.strictArrayOf(v.isString),
-              title: v.isString,
+              title: v.isHTML,
             }), {
               path: object.path,
               title: object.title,
@@ -521,7 +521,7 @@ export default {
         ]),
       slots.bannerPosition === 'bottom' && slots.banner,
       footerHTML,
-    ].filter(Boolean).join('\n');
+    ];
 
     const pageHTML = html.tags([
       `<!DOCTYPE html>`,
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index b5591e6..64fe853 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,4 +1,4 @@
-import {logWarn} from '#cli';
+import {logInfo, logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
@@ -10,6 +10,7 @@ export default {
     'getThumbnailsAvailableForDimensions',
     'html',
     'language',
+    'missingImagePaths',
     'to',
   ],
 
@@ -63,6 +64,7 @@ export default {
     getThumbnailsAvailableForDimensions,
     html,
     language,
+    missingImagePaths,
     to,
   }) {
     let originalSrc;
@@ -75,8 +77,27 @@ export default {
       originalSrc = '';
     }
 
-    const willLink = typeof slots.link === 'string' || slots.link;
-    const customLink = typeof slots.link === 'string';
+    let mediaSrc = null;
+    if (originalSrc.startsWith(to('media.root'))) {
+      mediaSrc =
+        originalSrc
+          .slice(to('media.root').length)
+          .replace(/^\//, '');
+    }
+
+    const isMissingImageFile =
+      missingImagePaths.includes(mediaSrc);
+
+    if (isMissingImageFile) {
+      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
+    }
+
+    const willLink =
+      !isMissingImageFile &&
+      (typeof slots.link === 'string' || slots.link);
+
+    const customLink =
+      typeof slots.link === 'string';
 
     const willReveal =
       slots.reveal &&
@@ -87,13 +108,16 @@ export default {
 
     const idOnImg = willLink ? null : slots.id;
     const idOnLink = willLink ? slots.id : null;
+
     const classOnImg = willLink ? null : slots.class;
     const classOnLink = willLink ? slots.class : null;
 
-    if (!originalSrc) {
+    if (!originalSrc || isMissingImageFile) {
       return prepare(
         html.tag('div', {class: 'image-text-area'},
-          slots.missingSourceContent));
+          (html.isBlank(slots.missingSourceContent)
+            ? language.$(`misc.missingImage`)
+            : slots.missingSourceContent)));
     }
 
     let reveal = null;
@@ -108,14 +132,6 @@ export default {
       ];
     }
 
-    let mediaSrc = null;
-    if (originalSrc.startsWith(to('media.root'))) {
-      mediaSrc =
-        originalSrc
-          .slice(to('media.root').length)
-          .replace(/^\//, '');
-    }
-
     const hasThumbnails =
       mediaSrc &&
       checkIfImagePathHasCachedThumbnails(mediaSrc);
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index ba7c7cd..7206e96 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -16,7 +16,7 @@ export default {
     path: {validate: v => v.validateArrayItems(v.isString)},
     hash: {type: 'string'},
 
-    tooltip: {validate: v => v.isString},
+    tooltip: {type: 'string'},
     attributes: {validate: v => v.isAttributes},
     color: {validate: v => v.isColor},
     content: {type: 'html'},
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
index e3e2608..643bf4b 100644
--- a/src/content/dependencies/linkThing.js
+++ b/src/content/dependencies/linkThing.js
@@ -26,7 +26,7 @@ export default {
     preferShortName: {type: 'boolean', default: false},
 
     tooltip: {
-      validate: v => v.oneOf(v.isBoolean, v.isString),
+      validate: v => v.oneOf(v.isBoolean, v.isHTML),
       default: false,
     },
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index c98495d..a325d6a 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,8 @@
+import {Tag} from '#html';
 import {isLanguageCode} from '#validators';
 
+import CacheableObject from './cacheable-object.js';
+
 import Thing, {
   externalFunction,
   flag,
@@ -142,19 +145,9 @@ export class Language extends Thing {
   }
 
   formatString(key, args = {}) {
-    if (this.strings && !this.strings_htmlEscaped) {
-      throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-    }
-
-    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-  }
-
-  formatStringNoHTMLEscape(key, args = {}) {
-    return this.formatStringHelper(this.strings, key, args);
-  }
+    const strings = this.strings_htmlEscaped;
 
-  formatStringHelper(strings, key, args = {}) {
-    if (!strings) {
+    if (!this.strings) {
       throw new Error(`Strings unavailable`);
     }
 
@@ -162,22 +155,25 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
-    const template = strings[key];
+    const template = this.strings[key];
 
     // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
     // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
     // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-    // for the iterating we're a8out to do.
-    const processedArgs = Object.entries(args).map(([k, v]) => [
-      k.replace(/[A-Z]/g, '_$&').toUpperCase(),
-      v,
-    ]);
+    // for the iterating we're a8out to do. Also strip HTML from arguments
+    // that are literal strings - real HTML content should always be proper
+    // HTML objects (see html.js).
+    const processedArgs =
+      Object.entries(args).map(([k, v]) => [
+        k.replace(/[A-Z]/g, '_$&').toUpperCase(),
+        this.#sanitizeStringArg(v),
+      ]);
 
     // Replacement time! Woot. Reduce comes in handy here!
-    const output = processedArgs.reduce(
-      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
-      template
-    );
+    const output =
+      processedArgs.reduce(
+        (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+        template);
 
     // Post-processing: if any expected arguments *weren't* replaced, that
     // is almost definitely an error.
@@ -185,7 +181,59 @@ export class Language extends Thing {
       throw new Error(`Args in ${key} were missing - output: ${output}`);
     }
 
-    return output;
+    // Last caveat: Wrap the output in an HTML tag so that it doesn't get
+    // treated as unsanitized HTML if *it* gets passed as an argument to
+    // *another* formatString call.
+    return this.#wrapSanitized(output);
+  }
+
+  // Escapes HTML special characters so they're displayed as-are instead of
+  // treated by the browser as a tag. This does *not* have an effect on actual
+  // html.Tag objects, which are treated as sanitized by default (so that they
+  // can be nested inside strings at all).
+  #sanitizeStringArg(arg) {
+    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
+
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    if (typeof arg !== 'string') {
+      return arg.toString();
+    }
+
+    return escapeHTML(arg);
+  }
+
+  // Wraps the output of a formatting function in a no-name-nor-attributes
+  // HTML tag, which will indicate to other calls to formatString that this
+  // content is a string *that may contain HTML* and doesn't need to
+  // sanitized any further. It'll still .toString() to just the string
+  // contents, if needed.
+  #wrapSanitized(output) {
+    return new Tag(null, null, output);
+  }
+
+  // Similar to the above internal methods, but this one is public.
+  // It should be used when embedding content that may not have previously
+  // been sanitized directly into an HTML tag or template's contents.
+  // The templating engine usually handles this on its own, as does passing
+  // a value (sanitized or not) directly as an argument to formatString,
+  // but if you used a custom validation function ({validate: v => v.isHTML}
+  // instead of {type: 'string'} / {type: 'html'}) and are embedding the
+  // contents of a slot directly, it should be manually sanitized with this
+  // function first.
+  sanitize(arg) {
+    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
+
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    return (
+      (typeof arg === 'string'
+        ? new Tag(null, null, escapeHTML(arg))
+        : arg));
   }
 
   formatDate(date) {
@@ -254,19 +302,32 @@ export class Language extends Thing {
   // Conjunction list: A, B, and C
   formatConjunctionList(array) {
     this.assertIntlAvailable('intl_listConjunction');
-    return this.intl_listConjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listConjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listDisjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listUnit.format(
+        array.map(item => this.#sanitizeStringArg(item))));
+  }
+
+  // Lists without separator: A B C
+  formatListWithoutSeparator(array) {
+    return this.#wrapSanitized(
+      array.map(item => this.#sanitizeStringArg(item))
+        .join(' '));
   }
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 4977ade..f59bc62 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -169,28 +169,45 @@ getThumbnailsAvailableForDimensions.all =
     .map(([name, {size}]) => [name, size])
     .sort((a, b) => b[1] - a[1]);
 
-export function checkIfImagePathHasCachedThumbnails(imagePath, cache) {
+function getCacheEntryForMediaPath(mediaPath, cache) {
+  // Gets the cache entry for the provided image path, which should always be
+  // a forward-slashes path (i.e. suitable for display online). Since the cache
+  // file may have forward or back-slashes, this checks both.
+
+  const entryFromMediaPath = cache[mediaPath];
+  if (entryFromMediaPath) return entryFromMediaPath;
+
+  const winPath = mediaPath.split(path.posix.sep).join(path.win32.sep);
+  const entryFromWinPath = cache[winPath];
+  if (entryFromWinPath) return entryFromWinPath;
+
+  return null;
+}
+
+export function checkIfImagePathHasCachedThumbnails(mediaPath, cache) {
   // Generic utility for checking if the thumbnail cache includes any info for
   // the provided image path, so that the other functions don't hard-code the
   // cache format.
 
-  return !!cache[imagePath];
+  return !!getCacheEntryForMediaPath(mediaPath, cache);
 }
 
-export function getDimensionsOfImagePath(imagePath, cache) {
+export function getDimensionsOfImagePath(mediaPath, cache) {
   // This function is really generic. It takes the gen-thumbs image cache and
   // returns the dimensions in that cache, so that other functions don't need
   // to hard-code the cache format.
 
-  if (!cache[imagePath]) {
-    throw new Error(`Expected imagePath to be included in cache, got ${imagePath}`);
+  const cacheEntry = getCacheEntryForMediaPath(mediaPath, cache);
+
+  if (!cacheEntry) {
+    throw new Error(`Expected mediaPath to be included in cache, got ${mediaPath}`);
   }
 
-  const [width, height] = cache[imagePath].slice(1);
+  const [width, height] = cacheEntry.slice(1);
   return [width, height];
 }
 
-export function getThumbnailEqualOrSmaller(preferred, imagePath, cache) {
+export function getThumbnailEqualOrSmaller(preferred, mediaPath, cache) {
   // This function is totally exclusive to page generation. It's a shorthand
   // for accessing dimensions from the thumbnail cache, calculating all the
   // thumbnails available, and selecting the one which is equal to or smaller
@@ -198,12 +215,12 @@ export function getThumbnailEqualOrSmaller(preferred, imagePath, cache) {
   // one which is being thumbnail-ified, this just returns the name of the
   // selected thumbnail size.
 
-  if (!cache[imagePath]) {
-    throw new Error(`Expected imagePath to be included in cache, got ${imagePath}`);
+  if (!getCacheEntryForMediaPath(mediaPath, cache)) {
+    throw new Error(`Expected mediaPath to be included in cache, got ${mediaPath}`);
   }
 
   const {size: preferredSize} = thumbnailSpec[preferred];
-  const [width, height] = getDimensionsOfImagePath(imagePath, cache);
+  const [width, height] = getDimensionsOfImagePath(mediaPath, cache);
   const available = getThumbnailsAvailableForDimensions([width, height]);
   const [selected] = available.find(([name, size]) => size <= preferredSize);
   return selected;
@@ -673,6 +690,8 @@ export async function verifyImagePaths(mediaPath, {urls, wikiData}) {
       console.warn(colors.yellow(` - `) + file);
     }
   }
+
+  return {missing, misplaced};
 }
 
 // Recursively traverses the provided (extant) media path, filtering so only
diff --git a/src/static/client2.js b/src/static/client2.js
index 8ae9876..7897041 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -534,11 +534,17 @@ const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-
     const {parentElement: contentContainer} = stickyContainer;
     const stickySubheadingRow = stickyContainer.querySelector('.content-sticky-subheading-row');
     const stickySubheading = stickySubheadingRow.querySelector('h2');
-    const stickyCoverContainer = stickyContainer.querySelector('.content-sticky-heading-cover-container');
-    const stickyCover = stickyCoverContainer?.querySelector('.content-sticky-heading-cover');
+    let stickyCoverContainer = stickyContainer.querySelector('.content-sticky-heading-cover-container');
+    let stickyCover = stickyCoverContainer?.querySelector('.content-sticky-heading-cover');
     const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading'));
     const contentCover = contentContainer.querySelector('#cover-art-container');
 
+    if (stickyCover.querySelector('.image-text-area')) {
+      stickyCoverContainer.remove();
+      stickyCoverContainer = null;
+      stickyCover = null;
+    }
+
     return {
       contentContainer,
       contentCover,
diff --git a/src/static/site4.css b/src/static/site4.css
index f79c0c2..ab8976b 100644
--- a/src/static/site4.css
+++ b/src/static/site4.css
@@ -558,7 +558,7 @@ a.box img {
   height: auto;
 }
 
-a.box .square .image-container {
+.square .image-container {
   width: 100%;
   height: 100%;
 }
diff --git a/src/strings-default.json b/src/strings-default.json
index 8d7756a..b5e39e9 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -197,6 +197,7 @@
   "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
   "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
   "misc.external.flash.youtube": "{LINK} (on any device)",
+  "misc.missingImage": "(This image file is missing)",
   "misc.missingLinkContent": "(Missing link content)",
   "misc.nav.previous": "Previous",
   "misc.nav.next": "Next",
diff --git a/src/upd8.js b/src/upd8.js
index 7f42327..92e89ea 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -81,7 +81,7 @@ import * as buildModes from './write/build-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 20;
+const CACHEBUST = 21;
 
 let COMMIT;
 try {
@@ -687,7 +687,8 @@ async function main() {
 
   const urls = generateURLs(urlSpec);
 
-  await verifyImagePaths(mediaPath, {urls, wikiData});
+  const {missing: missingImagePaths} =
+    await verifyImagePaths(mediaPath, {urls, wikiData});
 
   const fileSizePreloader = new FileSizePreloader();
 
@@ -807,6 +808,7 @@ async function main() {
 
     defaultLanguage: finalDefaultLanguage,
     languages,
+    missingImagePaths,
     thumbsCache,
     urls,
     urlSpec,
diff --git a/src/util/html.js b/src/util/html.js
index b166855..c7395fb 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -806,24 +806,43 @@ export class Template {
     }
 
     // Null is always an acceptable slot value.
-    if (value !== null) {
-      if ('validate' in description) {
-        description.validate({
-          ...commonValidators,
-          ...validators,
-        })(value);
-      }
+    if (value === null) {
+      return true;
+    }
+
+    if ('validate' in description) {
+      description.validate({
+        ...commonValidators,
+        ...validators,
+      })(value);
+    }
 
-      if ('type' in description) {
-        const {type} = description;
-        if (type === 'html') {
-          if (!isHTML(value)) {
+    if ('type' in description) {
+      switch (description.type) {
+        case 'html': {
+          if (!isHTML(value))
             throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`);
-          }
-        } else {
-          if (typeof value !== type) {
-            throw new TypeError(`Slot expects ${type}, got ${typeof value}`);
-          }
+
+          return true;
+        }
+
+        case 'string': {
+          // Tags and templates are valid in string arguments - they'll be
+          // stringified when exposed to the description's .content() function.
+          if (isTag(value) || isTemplate(value))
+            return true;
+
+          if (typeof value !== 'string')
+            throw new TypeError(`Slot expects string, got ${typeof value}`);
+
+          return true;
+        }
+
+        default: {
+          if (typeof value !== description.type)
+            throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`);
+
+          return true;
         }
       }
     }
@@ -847,6 +866,12 @@ export class Template {
       return providedValue;
     }
 
+    if (description.type === 'string') {
+      if (isTag(providedValue) || isTemplate(providedValue)) {
+        return providedValue.toString();
+      }
+    }
+
     if (providedValue !== null) {
       return providedValue;
     }
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 942cce8..3d4ecc7 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -25,6 +25,7 @@ export function bindUtilities({
   getSizeOfImagePath,
   language,
   languages,
+  missingImagePaths,
   pagePath,
   thumbsCache,
   to,
@@ -43,6 +44,7 @@ export function bindUtilities({
     html,
     language,
     languages,
+    missingImagePaths,
     pagePath,
     thumb,
     to,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 644efdb..3986de3 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -56,6 +56,7 @@ export async function go({
 
   defaultLanguage,
   languages,
+  missingImagePaths,
   srcRootPath,
   thumbsCache,
   urls,
@@ -338,6 +339,7 @@ export async function go({
         getSizeOfImagePath,
         language,
         languages,
+        missingImagePaths,
         pagePath: servePath,
         thumbsCache,
         to,
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 6ef6975..0931699 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -88,6 +88,7 @@ export async function go({
 
   defaultLanguage,
   languages,
+  missingImagePaths,
   srcRootPath,
   thumbsCache,
   urls,
@@ -292,6 +293,7 @@ export async function go({
           getSizeOfImagePath,
           language,
           languages,
+          missingImagePaths,
           pagePath,
           thumbsCache,
           to,
diff --git a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
index fa64183..8171725 100644
--- a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
@@ -6,13 +6,13 @@
  */
 'use strict'
 exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > basic behavior 1`] = `
-previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: 'Previous' }
-next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: 'Next' }
+previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
+next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
 `
 
 exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > disable id 1`] = `
-previous: { tooltip: true, color: false, attributes: { id: false }, content: 'Previous' }
-next: { tooltip: true, color: false, attributes: { id: false }, content: 'Next' }
+previous: { tooltip: true, color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
+next: { tooltip: true, color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
 `
 
 exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > neither link present 1`] = `
@@ -20,9 +20,9 @@ exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLink
 `
 
 exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > next missing 1`] = `
-previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: 'Previous' }
+previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
 `
 
 exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > previous missing 1`] = `
-next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: 'Next' }
+next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
 `
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
index e94ed82..bfd7446 100644
--- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -18,19 +18,19 @@ exports[`test/snapshot/generateTrackReleaseInfo.js TAP generateTrackReleaseInfo
 
 exports[`test/snapshot/generateTrackReleaseInfo.js TAP generateTrackReleaseInfo (snapshot) > cover artist contribs, non-unique 1`] = `
 <p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
-<p>This wiki doesn&apos;t have any listening links for <i>Suspicious Track</i>.</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js TAP generateTrackReleaseInfo (snapshot) > cover artist contribs, unique 1`] = `
 <p>
     By <a href="artist/toby-fox/">Toby Fox</a>.
     <br>
-    Cover art by <span class="nowrap"><a href="artist/alpaca/">Alpaca</a> (🔥)</span>.
+    Cover art by <span class="nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
 </p>
-<p>This wiki doesn&apos;t have any listening links for <i>Suspicious Track</i>.</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js TAP generateTrackReleaseInfo (snapshot) > reduced details 1`] = `
 <p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
-<p>This wiki doesn&apos;t have any listening links for <i>Suspicious Track</i>.</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `