« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/external-links.js1024
-rw-r--r--src/util/html.js2017
-rw-r--r--src/util/node-utils.js102
-rw-r--r--src/util/replacer.js852
4 files changed, 0 insertions, 3995 deletions
diff --git a/src/util/external-links.js b/src/util/external-links.js
deleted file mode 100644
index 43c09265..00000000
--- a/src/util/external-links.js
+++ /dev/null
@@ -1,1024 +0,0 @@
-import {empty, stitchArrays, withEntries} from '#sugar';
-
-import {
-  anyOf,
-  is,
-  isBoolean,
-  isObject,
-  isStringNonEmpty,
-  looseArrayOf,
-  optional,
-  validateAllPropertyValues,
-  validateArrayItems,
-  validateInstanceOf,
-  validateProperties,
-} from '#validators';
-
-export const externalLinkStyles = [
-  'platform',
-  'handle',
-  'icon-id',
-];
-
-export const isExternalLinkStyle = is(...externalLinkStyles);
-
-export const externalLinkContexts = [
-  'album',
-  'albumOneTrack',
-  'albumMultipleTracks',
-  'albumNoTracks',
-  'artist',
-  'flash',
-  'generic',
-  'group',
-  'track',
-];
-
-export const isExternalLinkContext =
-  anyOf(
-    is(...externalLinkContexts),
-    looseArrayOf(is(...externalLinkContexts)));
-
-// This might need to be adjusted for YAML importing...
-const isRegExp =
-  validateInstanceOf(RegExp);
-
-export const isExternalLinkTransformCommand =
-  is(...[
-    'decode-uri',
-    'find-replace',
-  ]);
-
-export const isExternalLinkTransformSpec =
-  anyOf(
-    isExternalLinkTransformCommand,
-    validateProperties({
-      [validateProperties.allowOtherKeys]: true,
-      command: isExternalLinkTransformCommand,
-    }));
-
-export const isExternalLinkExtractSpec =
-  validateProperties({
-    prefix: optional(isStringNonEmpty),
-    transform: optional(validateArrayItems(isExternalLinkTransformSpec)),
-    url: optional(isRegExp),
-    domain: optional(isRegExp),
-    pathname: optional(isRegExp),
-    query: optional(isRegExp),
-  });
-
-export const isExternalLinkSpec =
-  validateArrayItems(
-    validateProperties({
-      match: validateProperties({
-        // TODO: Don't allow providing both of these, and require providing one
-        domain: optional(isStringNonEmpty),
-        domains: optional(validateArrayItems(isStringNonEmpty)),
-
-        // TODO: Don't allow providing both of these
-        pathname: optional(isRegExp),
-        pathnames: optional(validateArrayItems(isRegExp)),
-
-        // TODO: Don't allow providing both of these
-        query: optional(isRegExp),
-        queries: optional(validateArrayItems(isRegExp)),
-
-        context: optional(isExternalLinkContext),
-      }),
-
-      platform: isStringNonEmpty,
-
-      handle: optional(isExternalLinkExtractSpec),
-
-      detail:
-        optional(anyOf(
-          isStringNonEmpty,
-          validateProperties({
-            [validateProperties.validateOtherKeys]:
-              isExternalLinkExtractSpec,
-
-            substring: isStringNonEmpty,
-          }))),
-
-      unusualDomain: optional(isBoolean),
-
-      icon: optional(isStringNonEmpty),
-    }));
-
-export const fallbackDescriptor = {
-  platform: 'external',
-  icon: 'globe',
-};
-
-// TODO: Define all this stuff in data as YAML!
-export const externalLinkSpec = [
-  // Special handling for album links
-
-  {
-    match: {
-      context: 'album',
-      domain: 'youtube.com',
-      pathname: /^playlist/,
-    },
-
-    platform: 'youtube',
-    detail: 'playlist',
-
-    icon: 'youtube',
-  },
-
-  {
-    match: {
-      context: 'albumMultipleTracks',
-      domain: 'youtube.com',
-      pathname: /^watch/,
-    },
-
-    platform: 'youtube',
-    detail: 'fullAlbum',
-
-    icon: 'youtube',
-  },
-
-  {
-    match: {
-      context: 'albumMultipleTracks',
-      domain: 'youtu.be',
-    },
-
-    platform: 'youtube',
-    detail: 'fullAlbum',
-
-    icon: 'youtube',
-  },
-
-  // Special handling for flash links
-
-  {
-    match: {
-      context: 'flash',
-      domain: 'bgreco.net',
-    },
-
-    platform: 'bgreco',
-    detail: 'flash',
-
-    icon: 'globe',
-  },
-
-  // This takes precedence over the secretPage match below.
-  {
-    match: {
-      context: 'flash',
-      domain: 'homestuck.com',
-    },
-
-    platform: 'homestuck',
-
-    detail: {
-      substring: 'page',
-      page: {pathname: /^story\/([0-9]+)\/?$/,},
-    },
-
-    icon: 'globe',
-  },
-
-  {
-    match: {
-      context: 'flash',
-      domain: 'homestuck.com',
-      pathname: /^story\/.+\/?$/,
-    },
-
-    platform: 'homestuck',
-    detail: 'secretPage',
-
-    icon: 'globe',
-  },
-
-  {
-    match: {
-      context: 'flash',
-      domains: ['youtube.com', 'youtu.be'],
-    },
-
-    platform: 'youtube',
-    detail: 'flash',
-
-    icon: 'youtube',
-  },
-
-  // Generic domains, sorted alphabetically (by string)
-
-  {
-    match: {
-      domains: [
-        'music.amazon.co.jp',
-        'music.amazon.com',
-      ],
-    },
-
-    platform: 'amazonMusic',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'music.apple.com'},
-    platform: 'appleMusic',
-    icon: 'appleMusic',
-  },
-
-  {
-    match: {domain: 'artstation.com'},
-
-    platform: 'artstation',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'artstation',
-  },
-
-  {
-    match: {domain: '.artstation.com'},
-
-    platform: 'artstation',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'artstation',
-  },
-
-  {
-    match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
-
-    platform: 'bandcamp',
-    handle: {domain: /.+/},
-    unusualDomain: true,
-
-    icon: 'bandcamp',
-  },
-
-  {
-    match: {domain: '.bandcamp.com'},
-
-    platform: 'bandcamp',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'bandcamp',
-  },
-
-  {
-    match: {domain: 'bsky.app'},
-
-    platform: 'bluesky',
-    handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/},
-
-    icon: 'bluesky',
-  },
-
-  {
-    match: {domain: '.carrd.co'},
-
-    platform: 'carrd',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'carrd',
-  },
-
-  {
-    match: {domain: 'cohost.org'},
-
-    platform: 'cohost',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'cohost',
-  },
-
-  {
-    match: {domain: 'music.deconreconstruction.com'},
-    platform: 'deconreconstruction.music',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'deconreconstruction.com'},
-    platform: 'deconreconstruction',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: '.deviantart.com'},
-
-    platform: 'deviantart',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'deviantart',
-  },
-
-  {
-    match: {domain: 'deviantart.com'},
-
-    platform: 'deviantart',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'deviantart',
-  },
-
-  {
-    match: {domain: 'deviantart.com'},
-    platform: 'deviantart',
-    icon: 'deviantart',
-  },
-
-  {
-    match: {domain: 'facebook.com'},
-
-    platform: 'facebook',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'facebook',
-  },
-
-  {
-    match: {domain: 'facebook.com'},
-
-    platform: 'facebook',
-    handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/},
-
-    icon: 'facebook',
-  },
-
-  {
-    match: {domain: 'facebook.com'},
-    platform: 'facebook',
-    icon: 'facebook',
-  },
-
-  {
-    match: {domain: 'm.nintendo.com'},
-
-    platform: 'nintendoMusic',
-
-    icon: 'nintendoMusic',
-  },
-
-  {
-    match: {domain: 'mspaintadventures.fandom.com'},
-
-    platform: 'fandom.mspaintadventures',
-
-    detail: {
-      substring: 'page',
-      page: {
-        pathname: /^wiki\/(.+)\/?$/,
-        transform: [
-          {command: 'decode-uri'},
-          {command: 'find-replace', find: /_/g, replace: ' '},
-        ],
-      },
-    },
-
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'mspaintadventures.fandom.com'},
-
-    platform: 'fandom.mspaintadventures',
-
-    icon: 'globe',
-  },
-
-  {
-    match: {domains: ['fandom.com', '.fandom.com']},
-    platform: 'fandom',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'gamebanana.com'},
-    platform: 'gamebanana',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'homestuck.com'},
-    platform: 'homestuck',
-    icon: 'globe',
-  },
-
-  {
-    match: {
-      domain: 'hsmusic.wiki',
-      pathname: /^media\/misc\/archive/,
-    },
-
-    platform: 'hsmusic.archive',
-
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'hsmusic.wiki'},
-    platform: 'hsmusic',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'instagram.com'},
-
-    platform: 'instagram',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'instagram',
-  },
-
-  {
-    match: {domain: 'instagram.com'},
-    platform: 'instagram',
-    icon: 'instagram',
-  },
-
-  // The Wayback Machine is a separate entry.
-  {
-    match: {domain: 'archive.org'},
-    platform: 'internetArchive',
-    icon: 'internetArchive',
-  },
-
-  {
-    match: {domain: '.itch.io'},
-
-    platform: 'itch',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'itch',
-  },
-
-  {
-    match: {domain: 'itch.io'},
-
-    platform: 'itch',
-    handle: {pathname: /^profile\/([^/]+)\/?$/},
-
-    icon: 'itch',
-  },
-
-  {
-    match: {domain: 'ko-fi.com'},
-
-    platform: 'kofi',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'kofi',
-  },
-
-  {
-    match: {domain: 'linktr.ee'},
-
-    platform: 'linktree',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'linktree',
-  },
-
-  {
-    match: {domains: [
-      'mastodon.social',
-      'shrike.club',
-      'types.pl',
-    ]},
-
-    platform: 'mastodon',
-    handle: {domain: /.+/},
-    unusualDomain: true,
-
-    icon: 'mastodon',
-  },
-
-  {
-    match: {domains: ['mspfa.com', '.mspfa.com']},
-    platform: 'mspfa',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: '.neocities.org'},
-
-    platform: 'neocities',
-    handle: {domain: /.+/},
-
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: '.newgrounds.com'},
-
-    platform: 'newgrounds',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'newgrounds',
-  },
-
-  {
-    match: {domain: 'newgrounds.com'},
-    platform: 'newgrounds',
-    icon: 'newgrounds',
-  },
-
-  {
-    match: {domain: 'patreon.com'},
-
-    platform: 'patreon',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'patreon',
-  },
-
-  {
-    match: {domain: 'patreon.com'},
-    platform: 'patreon',
-    icon: 'patreon',
-  },
-
-  {
-    match: {domain: 'poetryfoundation.org'},
-    platform: 'poetryFoundation',
-    icon: 'globe',
-  },
-
-  {
-    match: {domain: 'soundcloud.com'},
-
-    platform: 'soundcloud',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'soundcloud',
-  },
-
-  {
-    match: {domain: 'soundcloud.com'},
-    platform: 'soundcloud',
-    icon: 'soundcloud',
-  },
-
-  {
-    match: {domains: ['spotify.com', 'open.spotify.com']},
-    platform: 'spotify',
-    icon: 'spotify',
-  },
-
-  {
-    match: {domains: ['store.steampowered.com', 'steamcommunity.com']},
-    platform: 'steam',
-    icon: 'steam',
-  },
-
-  {
-    match: {domain: 'tiktok.com'},
-
-    platform: 'tiktok',
-    handle: {pathname: /^@?([^/]+)\/?$/},
-
-    icon: 'tiktok',
-  },
-
-  {
-    match: {domain: 'toyhou.se'},
-
-    platform: 'toyhouse',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'toyhouse',
-  },
-
-  {
-    match: {domain: '.tumblr.com'},
-
-    platform: 'tumblr',
-    handle: {domain: /^[^.]+/},
-
-    icon: 'tumblr',
-  },
-
-  {
-    match: {domain: 'tumblr.com'},
-
-    platform: 'tumblr',
-    handle: {pathname: /^([^/]+)\/?$/},
-
-    icon: 'tumblr',
-  },
-
-  {
-    match: {domain: 'tumblr.com'},
-    platform: 'tumblr',
-    icon: 'tumblr',
-  },
-
-  {
-    match: {domain: 'twitch.tv'},
-
-    platform: 'twitch',
-    handle: {pathname: /^(.+)\/?/},
-
-    icon: 'twitch',
-  },
-
-  {
-    match: {domain: 'twitter.com'},
-
-    platform: 'twitter',
-    handle: {pathname: /^@?([^/]+)\/?$/},
-
-    icon: 'twitter',
-  },
-
-  {
-    match: {domain: 'twitter.com'},
-    platform: 'twitter',
-    icon: 'twitter',
-  },
-
-  {
-    match: {domain: 'web.archive.org'},
-    platform: 'waybackMachine',
-    icon: 'internetArchive',
-  },
-
-  {
-    match: {domains: ['wikipedia.org', '.wikipedia.org']},
-    platform: 'wikipedia',
-    icon: 'misc',
-  },
-
-  {
-    match: {domain: 'youtube.com'},
-
-    platform: 'youtube',
-    handle: {pathname: /^@([^/]+)\/?$/},
-
-    icon: 'youtube',
-  },
-
-  {
-    match: {domains: ['youtube.com', 'youtu.be']},
-    platform: 'youtube',
-    icon: 'youtube',
-  },
-];
-
-function urlParts(url) {
-  const {
-    hostname: domain,
-    pathname,
-    search: query,
-  } = new URL(url);
-
-  return {domain, pathname, query};
-}
-
-function createEmptyResults() {
-  return Object.fromEntries(externalLinkStyles.map(style => [style, null]));
-}
-
-export function getMatchingDescriptorsForExternalLink(url, descriptors, {
-  context = 'generic',
-} = {}) {
-  const {domain, pathname, query} = urlParts(url);
-
-  const compareDomain = string => {
-    // A dot at the start of the descriptor's domain indicates
-    // we're looking to match a subdomain.
-    if (string.startsWith('.')) matchSubdomain: {
-      // "www" is never an acceptable subdomain for this purpose.
-      // Sorry to people whose usernames are www!!
-      if (domain.startsWith('www.')) {
-        return false;
-      }
-
-      return domain.endsWith(string);
-    }
-
-    // No dot means we're looking for an exact/full domain match.
-    // But let "www" pass here too, implicitly.
-    return domain === string || domain === 'www.' + string;
-  };
-
-  const comparePathname = regex => regex.test(pathname.slice(1));
-  const compareQuery = regex => regex.test(query.slice(1));
-
-  const compareExtractSpec = extract =>
-    extractPartFromExternalLink(url, extract, {mode: 'test'});
-
-  const contextArray =
-    (Array.isArray(context)
-      ? context
-      : [context]).filter(Boolean);
-
-  const matchingDescriptors =
-    descriptors
-      .filter(({match}) =>
-        (match.domain
-          ? compareDomain(match.domain)
-       : match.domains
-          ? match.domains.some(compareDomain)
-          : false))
-
-      .filter(({match}) =>
-        (Array.isArray(match.context)
-          ? match.context.some(c => contextArray.includes(c))
-       : match.context
-          ? contextArray.includes(match.context)
-          : true))
-
-      .filter(({match}) =>
-        (match.pathname
-          ? comparePathname(match.pathname)
-       : match.pathnames
-          ? match.pathnames.some(comparePathname)
-          : true))
-
-      .filter(({match}) =>
-        (match.query
-          ? compareQuery(match.query)
-       : match.queries
-          ? match.quieries.some(compareQuery)
-          : true))
-
-      .filter(({handle}) =>
-        (handle
-          ? compareExtractSpec(handle)
-          : true))
-
-      .filter(({detail}) =>
-        (typeof detail === 'object'
-          ? Object.entries(detail)
-              .filter(([key]) => key !== 'substring')
-              .map(([_key, value]) => value)
-              .every(compareExtractSpec)
-          : true));
-
-  return [...matchingDescriptors, fallbackDescriptor];
-}
-
-export function extractPartFromExternalLink(url, extract, {
-  // Set to 'test' to just see if this would extract anything.
-  // This disables running custom transformations.
-  mode = 'extract',
-} = {}) {
-  const {domain, pathname, query} = urlParts(url);
-
-  let regexen = [];
-  let tests = [];
-  let transform = [];
-  let prefix = '';
-
-  if (extract instanceof RegExp) {
-    regexen.push(extract);
-    tests.push(url);
-  } else {
-    for (const [key, value] of Object.entries(extract)) {
-      switch (key) {
-        case 'prefix':
-          prefix = value;
-          continue;
-
-        case 'transform':
-          for (const entry of value) {
-            const command =
-              (typeof entry === 'string'
-                ? command
-                : entry.command);
-
-            const options =
-              (typeof entry === 'string'
-                ? {}
-                : entry);
-
-            switch (command) {
-              case 'decode-uri':
-                transform.push(value =>
-                  decodeURIComponent(value));
-                break;
-
-              case 'find-replace':
-                transform.push(value =>
-                  value.replace(options.find, options.replace));
-                break;
-            }
-          }
-          continue;
-
-        case 'url':
-          tests.push(url);
-          break;
-
-        case 'domain':
-          tests.push(domain);
-          break;
-
-        case 'pathname':
-          tests.push(pathname.slice(1));
-          break;
-
-        case 'query':
-          tests.push(query.slice(1));
-          break;
-
-        default:
-          tests.push('');
-          break;
-      }
-
-      regexen.push(value);
-    }
-  }
-
-  let value;
-  for (const {regex, test} of stitchArrays({
-    regex: regexen,
-    test: tests,
-  })) {
-    const match = test.match(regex);
-    if (match) {
-      value = match[1] ?? match[0];
-      break;
-    }
-  }
-
-  if (mode === 'test') {
-    return !!value;
-  }
-
-  if (!value) {
-    return null;
-  }
-
-  if (prefix) {
-    value = prefix + value;
-  }
-
-  for (const fn of transform) {
-    value = fn(value);
-  }
-
-  return value;
-}
-
-export function extractAllCustomPartsFromExternalLink(url, custom) {
-  const customParts = {};
-
-  // All or nothing: if one part doesn't match, all results are scrapped.
-  for (const [key, value] of Object.entries(custom)) {
-    customParts[key] = extractPartFromExternalLink(url, value);
-    if (!customParts[key]) return null;
-  }
-
-  return customParts;
-}
-
-export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
-  const prefix = 'misc.external';
-
-  function getDetail() {
-    if (!descriptor.detail) {
-      return null;
-    }
-
-    if (typeof descriptor.detail === 'string') {
-      return language.$(prefix, descriptor.platform, descriptor.detail);
-    } else {
-      const {substring, ...rest} = descriptor.detail;
-
-      const opts =
-        withEntries(rest, entries => entries
-          .map(([key, value]) => [
-            key,
-            extractPartFromExternalLink(url, value),
-          ]));
-
-      return language.$(prefix, descriptor.platform, substring, opts);
-    }
-  }
-
-  switch (style) {
-    case 'platform': {
-      const platform = language.$(prefix, descriptor.platform);
-      const domain = urlParts(url).domain;
-
-      if (descriptor === fallbackDescriptor) {
-        // The fallback descriptor has a "platform" which is just
-        // the word "External". This isn't really useful when you're
-        // looking for platform info!
-        if (domain) {
-          return language.sanitize(domain.replace(/^www\./, ''));
-        } else {
-          return platform;
-        }
-      } else if (descriptor.detail) {
-        return getDetail();
-      } else if (descriptor.unusualDomain && domain) {
-        return language.$(prefix, 'withDomain', {platform, domain});
-      } else {
-        return platform;
-      }
-    }
-
-    case 'handle': {
-      if (descriptor.handle) {
-        return extractPartFromExternalLink(url, descriptor.handle);
-      } else {
-        return null;
-      }
-    }
-
-    case 'icon-id': {
-      if (descriptor.icon) {
-        return descriptor.icon;
-      } else {
-        return null;
-      }
-    }
-  }
-}
-
-export function couldDescriptorSupportStyle(descriptor, style) {
-  if (style === 'platform') {
-    return true;
-  }
-
-  if (style === 'handle') {
-    return !!descriptor.handle;
-  }
-
-  if (style === 'icon-id') {
-    return !!descriptor.icon;
-  }
-}
-
-export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, {
-  language,
-  context = 'generic',
-}) {
-  const matchingDescriptors =
-    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
-
-  const styleFilteredDescriptors =
-    matchingDescriptors.filter(descriptor =>
-      couldDescriptorSupportStyle(descriptor, style));
-
-  for (const descriptor of styleFilteredDescriptors) {
-    const descriptorResult =
-      getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
-
-    if (descriptorResult) {
-      return descriptorResult;
-    }
-  }
-
-  return null;
-}
-
-export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
-  return (
-    Object.fromEntries(
-      externalLinkStyles.map(style =>
-        getExternalLinkStringOfStyleFromDescriptor(
-          url,
-          style,
-          descriptor, {language}))));
-}
-
-export function getExternalLinkStringsFromDescriptors(url, descriptors, {
-  language,
-  context = 'generic',
-}) {
-  const results = createEmptyResults();
-  const remainingKeys = new Set(Object.keys(results));
-
-  const matchingDescriptors =
-    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
-
-  for (const descriptor of matchingDescriptors) {
-    const descriptorResults =
-      getExternalLinkStringsFromDescriptor(url, descriptor, {language});
-
-    const descriptorKeys =
-      new Set(
-        Object.entries(descriptorResults)
-          .filter(entry => entry[1])
-          .map(entry => entry[0]));
-
-    for (const key of remainingKeys) {
-      if (descriptorKeys.has(key)) {
-        results[key] = descriptorResults[key];
-        remainingKeys.delete(key);
-      }
-    }
-
-    if (empty(remainingKeys)) {
-      return results;
-    }
-  }
-
-  return results;
-}
diff --git a/src/util/html.js b/src/util/html.js
deleted file mode 100644
index 0fe424df..00000000
--- a/src/util/html.js
+++ /dev/null
@@ -1,2017 +0,0 @@
-// Some really, really simple functions for formatting HTML content.
-
-import {inspect} from 'node:util';
-
-import {withAggregate} from '#aggregate';
-import {colors} from '#cli';
-import {empty, typeAppearance, unique} from '#sugar';
-import * as commonValidators from '#validators';
-
-const {
-  anyOf,
-  is,
-  isArray,
-  isBoolean,
-  isNumber,
-  isString,
-  isSymbol,
-  looseArrayOf,
-  validateAllPropertyValues,
-  validateArrayItems,
-  validateInstanceOf,
-} = commonValidators;
-
-// COMPREHENSIVE!
-// https://html.spec.whatwg.org/multipage/syntax.html#void-elements
-export const selfClosingTags = [
-  'area',
-  'base',
-  'br',
-  'col',
-  'embed',
-  'hr',
-  'img',
-  'input',
-  'link',
-  'meta',
-  'source',
-  'track',
-  'wbr',
-];
-
-// Not so comprehensive!!
-export const attributeSpec = {
-  'class': {
-    arraylike: true,
-    join: ' ',
-    unique: true,
-  },
-
-  'style': {
-    arraylike: true,
-    join: '; ',
-  },
-};
-
-// Pass to tag() as an attributes key to make tag() return a 8lank tag if the
-// provided content is empty. Useful for when you'll only 8e showing an element
-// according to the presence of content that would 8elong there.
-export const onlyIfContent = Symbol();
-
-// Pass to tag() as an attributes key to make tag() return a blank tag if
-// this tag doesn't get shown beside any siblings! (I.e, siblings who don't
-// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank,
-// tags with [html.onlyIfSiblings] never make the difference in counting as
-// content for [html.onlyIfContent]. Useful for <summary> and such.
-export const onlyIfSiblings = Symbol();
-
-// Pass to tag() as an attributes key to make children be joined together by the
-// provided string. This is handy, for example, for joining lines by <br> tags,
-// or putting some other divider between each child. Note this will only have an
-// effect if the tag content is passed as an array of children and not a single
-// string.
-export const joinChildren = Symbol();
-
-// Pass to tag() as an attributes key to prevent additional whitespace from
-// being added to the inner start and end of the tag's content - basically,
-// ensuring that the start of the content begins immediately after the ">"
-// ending the opening tag, and ends immediately before the "<" at the start of
-// the closing tag. This has effect when a single child spans multiple lines,
-// or when there are multiple children.
-export const noEdgeWhitespace = Symbol();
-
-// Pass as a value on an object-shaped set of attributes to indicate that it's
-// always, absolutely, no matter what, a valid attribute addition. It will be
-// completely exempt from validation, which may provide a significant speed
-// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES.
-// Basically, don't use this unless you're 1) providing a constant set of
-// attributes, and 2) writing a very basic building block which loads of other
-// content will build off of!
-export const blessAttributes = Symbol();
-
-// Don't pass this directly, use html.metatag('blockwrap') instead.
-// Causes *following* content (past the metatag) to be placed inside a span
-// which is styled 'inline-block', which ensures that the words inside the
-// metatag all stay together, line-breaking only if needed, and following
-// text is displayed immediately after the last character of the last line of
-// the metatag (provided there's room on that line for the following word or
-// character).
-export const blockwrap = Symbol();
-
-// Don't pass this directly, use html.metatag('chunkwrap') instead.
-// Causes *contained* content to be split by the metatag's "split" attribute,
-// and each chunk to be considered its own unit for word wrapping. All these
-// units are *not* wrapped in any containing element, so only the chunks are
-// considered wrappable units, not the entire element!
-export const chunkwrap = Symbol();
-
-// Don't pass this directly, use html.metatag('imaginary-sibling') instead.
-// A tag without any content, which is completely ignored when serializing,
-// but makes siblings with [onlyIfSiblings] feel less shy and show up on
-// their own, even without a non-blank (and non-onlyIfSiblings) sibling.
-export const imaginarySibling = Symbol();
-
-// Recursive helper function for isBlank, which basically flattens an array
-// and returns as soon as it finds any content - a non-blank case - and doesn't
-// traverse templates of its own accord. If it doesn't find directly non-blank
-// content nor any templates, it returns true; if it saw templates, but no
-// other content, then those templates are returned in a flat array, to be
-// traversed externally.
-function isBlankArrayHelper(content) {
-  // First look for string items. These are the easiest to
-  // test blankness.
-
-  const nonStringContent = [];
-
-  for (const item of content) {
-    if (typeof item === 'string') {
-      if (item.length > 0) {
-        return false;
-      }
-    } else {
-      nonStringContent.push(item);
-    }
-  }
-
-  // Analyze the content more closely. Put arrays (and
-  // content of tags marked onlyIfContent) into one array,
-  // and templates into another. And if there's anything
-  // else, that's a non-blank condition we'll detect now.
-  // We'll flat-out skip items marked onlyIfSiblings,
-  // since they could never count as content alone
-  // (some other item will have to count).
-
-  const arrayContent = [];
-  const templateContent = [];
-
-  for (const item of nonStringContent) {
-    if (item instanceof Tag) {
-      if (item.onlyIfSiblings) {
-        continue;
-      } else if (item.onlyIfContent || item.contentOnly) {
-        arrayContent.push(item.content);
-      } else {
-        return false;
-      }
-    } else if (Array.isArray(item)) {
-      arrayContent.push(item);
-    } else if (item instanceof Template) {
-      templateContent.push(item);
-    } else {
-      return false;
-    }
-  }
-
-  // Iterate over arrays and tag content recursively.
-  // The result will always be true/false (blank or not),
-  // or an array of templates. Defer accessing templates
-  // until later - we'll check on them from the outside
-  // end only if nothing else matches.
-
-  for (const item of arrayContent) {
-    const result = isBlankArrayHelper(item);
-    if (result === false) {
-      return false;
-    } else if (Array.isArray(result)) {
-      templateContent.push(...result);
-    }
-  }
-
-  // Return templates, if there are any. We don't actually
-  // handle the base case of evaluating these templates
-  // inside this recursive function - the topmost caller
-  // will handle that.
-
-  if (!empty(templateContent)) {
-    return templateContent;
-  }
-
-  // If there weren't any templates found (as direct or
-  // indirect descendants), then we're good to go!
-  // This content is definitely blank.
-
-  return true;
-}
-
-// Checks if the content provided would be represented as nothing if included
-// on a page. This can be used on its own, and is the underlying "interface"
-// layer for specific classes' `blank` getters, so its definition and usage
-// tend to be recursive.
-//
-// Note that this shouldn't be used to infer anything about non-content values
-// (e.g. attributes) - it's only suited for actual page content.
-export function isBlank(content) {
-  if (typeof content === 'string') {
-    return content.length === 0;
-  }
-
-  if (content instanceof Tag || content instanceof Template) {
-    return content.blank;
-  }
-
-  if (Array.isArray(content)) {
-    const result = isBlankArrayHelper(content);
-
-    // If the result is true or false, the helper came to
-    // a conclusive decision on its own.
-    if (typeof result === 'boolean') {
-      return result;
-    }
-
-    // Otherwise, it couldn't immediately find any content,
-    // but did come across templates that prospectively
-    // could include content. These need to be checked too.
-    // Check each of the templates one at a time.
-    for (const template of result) {
-      const content = template.content;
-
-      if (content instanceof Tag && content.onlyIfSiblings) {
-        continue;
-      }
-
-      if (isBlank(content)) {
-        continue;
-      }
-
-      return false;
-    }
-
-    // If none of the templates included content either,
-    // then there really isn't any content to find in this
-    // tree at all. It's blank!
-    return true;
-  }
-
-  return false;
-}
-
-export const validators = {
-  isBlank(value) {
-    if (!isBlank(value)) {
-      throw new TypeError(`Expected blank content`);
-    }
-
-    return true;
-  },
-
-  isTag(value) {
-    return isTag(value);
-  },
-
-  isTemplate(value) {
-    return isTemplate(value);
-  },
-
-  isHTML(value) {
-    return isHTML(value);
-  },
-
-  isAttributes(value) {
-    return isAttributesAdditionSinglet(value);
-  },
-};
-
-export function blank() {
-  return [];
-}
-
-export function blankAttributes() {
-  return new Attributes();
-}
-
-export function tag(tagName, ...args) {
-  const lastArg = args.at(-1);
-
-  const lastArgIsAttributes =
-    typeof lastArg === 'object' && lastArg !== null &&
-    !Array.isArray(lastArg) &&
-    !(lastArg instanceof Tag) &&
-    !(lastArg instanceof Template);
-
-  const content =
-    (lastArgIsAttributes
-      ? null
-      : args.at(-1));
-
-  const attributes =
-    (lastArgIsAttributes
-      ? args
-      : args.slice(0, -1));
-
-  return new Tag(tagName, attributes, content);
-}
-
-export function tags(content, ...attributes) {
-  return new Tag(null, attributes, content);
-}
-
-export function metatag(identifier, ...args) {
-  let content;
-  let opts = {};
-
-  if (
-    typeof args[0] === 'object' &&
-    !(Array.isArray(args[0]) ||
-      args[0] instanceof Tag ||
-      args[0] instanceof Template)
-  ) {
-    opts = args[0];
-    content = args[1];
-  } else {
-    content = args[0];
-  }
-
-  switch (identifier) {
-    case 'blockwrap':
-      return new Tag(null, {[blockwrap]: true}, content);
-
-    case 'chunkwrap':
-      return new Tag(null, {[chunkwrap]: true, ...opts}, content);
-
-    case 'imaginary-sibling':
-      return new Tag(null, {[imaginarySibling]: true}, content);
-
-    default:
-      throw new Error(`Unknown metatag "${identifier}"`);
-  }
-}
-
-export function normalize(content) {
-  return Tag.normalize(content);
-}
-
-export class Tag {
-  #tagName = '';
-  #content = null;
-  #attributes = null;
-
-  #traceError = null;
-
-  constructor(tagName, attributes, content) {
-    this.tagName = tagName;
-    this.attributes = attributes;
-    this.content = content;
-
-    this.#traceError = new Error();
-  }
-
-  clone() {
-    return Reflect.construct(this.constructor, [
-      this.tagName,
-      this.attributes,
-      this.content,
-    ]);
-  }
-
-  set tagName(value) {
-    if (value === undefined || value === null) {
-      this.tagName = '';
-      return;
-    }
-
-    if (typeof value !== 'string') {
-      throw new Error(`Expected tagName to be a string`);
-    }
-
-    if (selfClosingTags.includes(value) && this.content.length) {
-      throw new Error(`Tag <${value}> is self-closing but this tag has content`);
-    }
-
-    this.#tagName = value;
-  }
-
-  get tagName() {
-    return this.#tagName;
-  }
-
-  set attributes(attributes) {
-    if (attributes instanceof Attributes) {
-      this.#attributes = attributes;
-    } else {
-      this.#attributes = new Attributes(attributes);
-    }
-  }
-
-  get attributes() {
-    if (this.#attributes === null) {
-      this.attributes = {};
-    }
-
-    return this.#attributes;
-  }
-
-  set content(value) {
-    const contentful =
-      value !== null &&
-      value !== undefined &&
-      value &&
-      (Array.isArray(value)
-        ? !empty(value.filter(Boolean))
-        : true);
-
-    if (this.selfClosing && contentful) {
-      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
-    }
-
-    if (this.imaginarySibling && contentful) {
-      throw new Error(`html.metatag('imaginary-sibling') can't have content`);
-    }
-
-    const contentArray =
-      (Array.isArray(value)
-        ? value.flat(Infinity).filter(Boolean)
-     : value
-        ? [value]
-        : []);
-
-    if (this.chunkwrap) {
-      if (contentArray.some(content => content?.blockwrap)) {
-        throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`);
-      }
-    }
-
-    this.#content = contentArray;
-    this.#content.toString = () => this.#stringifyContent();
-  }
-
-  get content() {
-    if (this.#content === null) {
-      this.#content = [];
-    }
-
-    return this.#content;
-  }
-
-  get selfClosing() {
-    if (this.tagName) {
-      return selfClosingTags.includes(this.tagName);
-    } else {
-      return false;
-    }
-  }
-
-  get blank() {
-    // Tags don't have a reference to their parent, so this only evinces
-    // something about this tag's own content or attributes. It does *not*
-    // account for [html.onlyIfSiblings]!
-
-    if (this.imaginarySibling) {
-      return true;
-    }
-
-    if (this.onlyIfContent && isBlank(this.content)) {
-      return true;
-    }
-
-    if (this.contentOnly && isBlank(this.content)) {
-      return true;
-    }
-
-    return false;
-  }
-
-  get contentOnly() {
-    if (this.tagName !== '') return false;
-    if (this.chunkwrap) return true;
-    if (!this.attributes.blank) return false;
-    if (this.blockwrap) return false;
-    return true;
-  }
-
-  #setAttributeFlag(attribute, value) {
-    if (value) {
-      this.attributes.set(attribute, true);
-    } else {
-      this.attributes.remove(attribute);
-    }
-  }
-
-  #getAttributeFlag(attribute) {
-    return !!this.attributes.get(attribute);
-  }
-
-  #setAttributeString(attribute, value) {
-    // Note: This function accepts and records the empty string ('')
-    // distinctly from null/undefined.
-
-    if (value === undefined || value === null) {
-      this.attributes.remove(attribute);
-      return undefined;
-    } else {
-      this.attributes.set(attribute, String(value));
-    }
-  }
-
-  #getAttributeString(attribute) {
-    const value = this.attributes.get(attribute);
-
-    if (value === undefined || value === null) {
-      return undefined;
-    } else {
-      return String(value);
-    }
-  }
-
-  set onlyIfContent(value) {
-    this.#setAttributeFlag(onlyIfContent, value);
-  }
-
-  get onlyIfContent() {
-    return this.#getAttributeFlag(onlyIfContent);
-  }
-
-  set onlyIfSiblings(value) {
-    this.#setAttributeFlag(onlyIfSiblings, value);
-  }
-
-  get onlyIfSiblings() {
-    return this.#getAttributeFlag(onlyIfSiblings);
-  }
-
-  set joinChildren(value) {
-    this.#setAttributeString(joinChildren, value);
-  }
-
-  get joinChildren() {
-    // A chunkwrap - which serves as the top layer of a smush() when
-    // stringifying that chunkwrap - is only meant to be an invisible
-    // layer, so its own children are never specially joined.
-    if (this.chunkwrap) {
-      return '';
-    }
-
-    return this.#getAttributeString(joinChildren);
-  }
-
-  set noEdgeWhitespace(value) {
-    this.#setAttributeFlag(noEdgeWhitespace, value);
-  }
-
-  get noEdgeWhitespace() {
-    return this.#getAttributeFlag(noEdgeWhitespace);
-  }
-
-  set blockwrap(value) {
-    this.#setAttributeFlag(blockwrap, value);
-  }
-
-  get blockwrap() {
-    return this.#getAttributeFlag(blockwrap);
-  }
-
-  set chunkwrap(value) {
-    this.#setAttributeFlag(chunkwrap, value);
-
-    try {
-      this.content = this.content;
-    } catch (error) {
-      this.#setAttributeFlag(chunkwrap, false);
-      throw error;
-    }
-  }
-
-  get chunkwrap() {
-    return this.#getAttributeFlag(chunkwrap);
-  }
-
-  set imaginarySibling(value) {
-    this.#setAttributeFlag(imaginarySibling, value);
-
-    try {
-      this.content = this.content;
-    } catch (error) {
-      this.#setAttributeFlag(imaginarySibling, false);
-    }
-  }
-
-  get imaginarySibling() {
-    return this.#getAttributeFlag(imaginarySibling);
-  }
-
-  toString() {
-    if (this.onlyIfContent && isBlank(this.content)) {
-      return '';
-    }
-
-    const attributesString = this.attributes.toString();
-    const contentString = this.content.toString();
-
-    if (!this.tagName) {
-      return contentString;
-    }
-
-    const openTag = (attributesString
-      ? `<${this.tagName} ${attributesString}>`
-      : `<${this.tagName}>`);
-
-    if (this.selfClosing) {
-      return openTag;
-    }
-
-    const closeTag = `</${this.tagName}>`;
-
-    if (!this.content.length) {
-      return openTag + closeTag;
-    }
-
-    if (!contentString.includes('\n')) {
-      return openTag + contentString + closeTag;
-    }
-
-    const parts = [
-      openTag,
-      contentString
-        .split('\n')
-        .map((line, i) =>
-          (i === 0 && this.noEdgeWhitespace
-            ? line
-            : '    ' + line))
-        .join('\n'),
-      closeTag,
-    ];
-
-    return parts.join(
-      (this.noEdgeWhitespace
-        ? ''
-        : '\n'));
-  }
-
-  #getContentJoiner() {
-    if (this.joinChildren === undefined) {
-      return '\n';
-    }
-
-    if (this.joinChildren === '') {
-      return '';
-    }
-
-    return `\n${this.joinChildren}\n`;
-  }
-
-  #stringifyContent() {
-    if (this.selfClosing) {
-      return '';
-    }
-
-    const joiner = this.#getContentJoiner();
-
-    let content = '';
-    let blockwrapClosers = '';
-
-    let seenSiblingIndependentContent = false;
-
-    const chunkwrapSplitter =
-      (this.chunkwrap
-        ? this.#getAttributeString('split')
-        : null);
-
-    let seenChunkwrapSplitter =
-      (this.chunkwrap
-        ? false
-        : null);
-
-    let contentItems;
-
-    determineContentItems: {
-      if (this.chunkwrap) {
-        contentItems = smush(this).content;
-        break determineContentItems;
-      }
-
-      contentItems = this.content;
-    }
-
-    for (const [index, item] of contentItems.entries()) {
-      const nonTemplateItem =
-        Template.resolve(item);
-
-      if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) {
-        seenSiblingIndependentContent = true;
-        continue;
-      }
-
-      let itemContent;
-      try {
-        itemContent = nonTemplateItem.toString();
-      } catch (caughtError) {
-        const indexPart = colors.yellow(`child #${index + 1}`);
-
-        const error =
-          new Error(
-            `Error in ${indexPart} ` +
-            `of ${inspect(this, {compact: true})}`,
-            {cause: caughtError});
-
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
-
-        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
-          /content-function\.js/,
-          /util\/html\.js/,
-        ];
-
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
-
-        throw error;
-      }
-
-      if (!itemContent) {
-        continue;
-      }
-
-      if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) {
-        seenSiblingIndependentContent = true;
-      }
-
-      const chunkwrapChunks =
-        (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? itemContent.split(chunkwrapSplitter)
-          : null);
-
-      const itemIncludesChunkwrapSplit =
-        (chunkwrapChunks
-          ? chunkwrapChunks.length > 1
-          : null);
-
-      if (content) {
-        if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
-          // The first time we see a chunkwrap splitter, backtrack and wrap
-          // the content *so far* in a chunk. This will be treated just like
-          // any other open chunkwrap, and closed after the first chunk of
-          // this item! (That means the existing content is part of the same
-          // chunk as the first chunk included in this content, which makes
-          // sense, because that first chink is really just more text that
-          // precedes the first split.)
-          content = `<span class="chunkwrap">` + content;
-        }
-
-        content += joiner;
-      } else if (itemIncludesChunkwrapSplit) {
-        // We've encountered a chunkwrap split before any other content.
-        // This means there's no content to wrap, no existing chunkwrap
-        // to close, and no reason to add a joiner, but we *do* need to
-        // enter a chunkwrap wrapper *now*, so the first chunk of this
-        // item will be properly wrapped.
-        content = `<span class="chunkwrap">`;
-      }
-
-      if (itemIncludesChunkwrapSplit) {
-        seenChunkwrapSplitter = true;
-      }
-
-      // Blockwraps only apply if they actually contain some content whose
-      // words should be kept together, so it's okay to put them beneath the
-      // itemContent check. They also never apply at the very start of content,
-      // because at that point there aren't any preceding words from which the
-      // blockwrap would differentiate its content.
-      if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) {
-        content += `<span class="blockwrap">`;
-        blockwrapClosers += `</span>`;
-      }
-
-      appendItemContent: {
-        if (itemIncludesChunkwrapSplit) {
-          for (const [index, chunk] of chunkwrapChunks.entries()) {
-            if (index === 0) {
-              // The first chunk isn't actually a chunk all on its own, it's
-              // text that should be appended to the previous chunk. We will
-              // close this chunk as the first appended content as we process
-              // the next chunk.
-              content += chunk;
-            } else {
-              const whitespace = chunk.match(/^\s+/) ?? '';
-              content += chunkwrapSplitter;
-              content += '</span>';
-              content += whitespace;
-              content += '<span class="chunkwrap">';
-              content += chunk.slice(whitespace.length);
-            }
-          }
-
-          break appendItemContent;
-        }
-
-        content += itemContent;
-      }
-    }
-
-    // If we've only seen sibling-dependent content (or just no content),
-    // then the content in total is blank.
-    if (!seenSiblingIndependentContent) {
-      return '';
-    }
-
-    if (chunkwrapSplitter) {
-      if (seenChunkwrapSplitter) {
-        content += '</span>';
-      } else {
-        // Since chunkwraps take responsibility for wrapping *away* from the
-        // parent element, we generally always want there to be at least one
-        // chunk that gets wrapped as a single unit. So if no chunkwrap has
-        // been seen at all, just wrap everything in one now.
-        content = `<span class="chunkwrap">${content}</span>`;
-      }
-    }
-
-    content += blockwrapClosers;
-
-    return content;
-  }
-
-  static normalize(content) {
-    // Normalizes contents that are valid from an `isHTML` perspective so
-    // that it's always a pure, single Tag object.
-
-    if (content instanceof Template) {
-      return Tag.normalize(Template.resolve(content));
-    }
-
-    if (content instanceof Tag) {
-      return content;
-    }
-
-    return new Tag(null, null, content);
-  }
-
-  smush() {
-    if (!this.contentOnly) {
-      return tags([this]);
-    }
-
-    const joiner = this.#getContentJoiner();
-
-    const result = [];
-    const attributes = {};
-
-    // Don't use built-in item joining, since we'll be handling it here -
-    // we need to account for descendants having custom joiners too, and
-    // simply using *this* tag's joiner would overwrite those descendants'
-    // differing joiners.
-    attributes[joinChildren] = '';
-
-    let workingText = '';
-
-    for (const item of this.content) {
-      const smushed = smush(item);
-      const smushedItems = smushed.content.slice();
-
-      if (empty(smushedItems)) {
-        continue;
-      }
-
-      if (typeof smushedItems[0] === 'string') {
-        if (workingText) {
-          workingText += joiner;
-        }
-
-        workingText += smushedItems.shift();
-      }
-
-      if (empty(smushedItems)) {
-        continue;
-      }
-
-      if (workingText) {
-        result.push(workingText + joiner);
-      } else if (!empty(result)) {
-        result.push(joiner);
-      }
-
-      if (typeof smushedItems.at(-1) === 'string') {
-        // The last smushed item already had its joiner processed from its own
-        // parent - this isn't an appropriate place for us to insert our own
-        // joiner.
-        workingText = smushedItems.pop();
-      } else {
-        workingText = '';
-      }
-
-      result.push(...smushedItems);
-    }
-
-    if (workingText) {
-      result.push(workingText);
-    }
-
-    return new Tag(null, attributes, result);
-  }
-
-  [inspect.custom](depth, opts) {
-    const lines = [];
-
-    const niceAttributes = ['id', 'class'];
-    const attributes = blankAttributes();
-
-    for (const attribute of niceAttributes) {
-      if (this.attributes.has(attribute)) {
-        const value = this.attributes.get(attribute);
-
-        if (!value) continue;
-        if (Array.isArray(value) && empty(value)) continue;
-
-        let string;
-        let suffix = '';
-
-        if (Array.isArray(value)) {
-          string = value[0].toString();
-          if (value.length > 1) {
-            suffix = ` (+${value.length - 1})`;
-          }
-        } else {
-          string = value.toString();
-        }
-
-        const trim =
-          (string.length > 15
-            ? `${string.slice(0, 12)}...`
-            : string);
-
-        attributes.set(attribute, trim + suffix);
-      }
-    }
-
-    const attributesPart =
-      (attributes.blank
-        ? ``
-        : ` ${attributes.toString({color: true})}`);
-
-    const tagNamePart =
-      (this.tagName
-        ? colors.bright(colors.blue(this.tagName))
-        : ``);
-
-    const tagPart =
-      (this.tagName
-        ? [
-            `<`,
-            tagNamePart,
-            attributesPart,
-            (empty(this.content) ? ` />` : `>`),
-          ].join(``)
-        : ``);
-
-    const accentText =
-      (this.tagName
-        ? (empty(this.content)
-            ? ``
-            : `(${this.content.length} items)`)
-        : (empty(this.content)
-            ? `(no name)`
-            : `(no name, ${this.content.length} items)`));
-
-    const accentPart =
-      (accentText
-        ? `${colors.dim(accentText)}`
-        : ``);
-
-    const headingParts = [
-      `Tag`,
-      tagPart,
-      accentPart,
-    ];
-
-    const heading = headingParts.filter(Boolean).join(` `);
-
-    lines.push(heading);
-
-    if (!opts.compact && (depth === null || depth >= 0)) {
-      const nextDepth =
-        (depth === null
-          ? null
-          : depth - 1);
-
-      for (const child of this.content) {
-        const childLines = [];
-
-        if (typeof child === 'string') {
-          const childFlat = child.replace(/\n/g, String.raw`\n`);
-          const childTrim =
-            (childFlat.length >= 40
-              ? childFlat.slice(0, 37) + '...'
-              : childFlat);
-
-          childLines.push(
-            `  Text: ${opts.stylize(`"${childTrim}"`, 'string')}`);
-        } else {
-          childLines.push(...
-            inspect(child, {depth: nextDepth})
-              .split('\n')
-              .map(line => `  ${line}`));
-        }
-
-        lines.push(...childLines);
-      }
-    }
-
-    return lines.join('\n');
-  }
-}
-
-export function attributes(attributes) {
-  return new Attributes(attributes);
-}
-
-export function parseAttributes(string) {
-  return Attributes.parse(string);
-}
-
-export class Attributes {
-  #attributes = Object.create(null);
-
-  constructor(attributes) {
-    this.attributes = attributes;
-  }
-
-  clone() {
-    return new Attributes(this);
-  }
-
-  set attributes(value) {
-    this.#attributes = Object.create(null);
-
-    if (value === undefined || value === null) {
-      return;
-    }
-
-    this.add(value);
-  }
-
-  get attributes() {
-    return this.#attributes;
-  }
-
-  get blank() {
-    const keepAnyAttributes =
-      Object.entries(this.attributes).some(([attribute, value]) =>
-        this.#keepAttributeValue(attribute, value));
-
-    return !keepAnyAttributes;
-  }
-
-  set(attribute, value) {
-    if (value instanceof Template) {
-      value = Template.resolve(value);
-    }
-
-    if (Array.isArray(value)) {
-      value = value.flat(Infinity);
-    }
-
-    if (value === null || value === undefined) {
-      this.remove(attribute);
-    } else {
-      this.#attributes[attribute] = value;
-    }
-
-    return value;
-  }
-
-  add(...args) {
-    switch (args.length) {
-      case 1:
-        isAttributesAdditionSinglet(args[0]);
-        return this.#addMultipleAttributes(args[0]);
-
-      case 2:
-        isAttributesAdditionPair(args);
-        return this.#addOneAttribute(args[0], args[1]);
-
-      default:
-        throw new Error(
-          `Expected array or object, or attribute and value`);
-    }
-  }
-
-  with(...args) {
-    const clone = this.clone();
-    clone.add(...args);
-    return clone;
-  }
-
-  #addMultipleAttributes(attributes) {
-    const flatInputAttributes =
-      [attributes].flat(Infinity).filter(Boolean);
-
-    const attributeSets =
-      flatInputAttributes.map(attributes => this.#getAttributeSet(attributes));
-
-    const resultList = [];
-
-    for (const set of attributeSets) {
-      const setResults = {};
-
-      for (const key of Reflect.ownKeys(set)) {
-        if (key === blessAttributes) continue;
-
-        const value = set[key];
-        setResults[key] = this.#addOneAttribute(key, value);
-      }
-
-      resultList.push(setResults);
-    }
-
-    return resultList;
-  }
-
-  #getAttributeSet(attributes) {
-    if (attributes instanceof Attributes) {
-      return attributes.attributes;
-    }
-
-    if (attributes instanceof Template) {
-      const resolved = Template.resolve(attributes);
-      isAttributesAdditionSinglet(resolved);
-      return resolved;
-    }
-
-    if (typeof attributes === 'object') {
-      return attributes;
-    }
-
-    throw new Error(
-      `Expected Attributes, Template, or object, ` +
-      `got ${typeAppearance(attributes)}`);
-  }
-
-  #addOneAttribute(attribute, value) {
-    if (value === null || value === undefined) {
-      return;
-    }
-
-    if (value instanceof Template) {
-      return this.#addOneAttribute(attribute, Template.resolve(value));
-    }
-
-    if (Array.isArray(value)) {
-      value = value.flat(Infinity);
-    }
-
-    if (!this.has(attribute)) {
-      return this.set(attribute, value);
-    }
-
-    const descriptor = attributeSpec[attribute];
-    const existingValue = this.get(attribute);
-
-    let newValue = value;
-
-    if (descriptor?.arraylike) {
-      const valueArray =
-        (Array.isArray(value)
-          ? value
-          : [value]);
-
-      const existingValueArray =
-        (Array.isArray(existingValue)
-          ? existingValue
-          : [existingValue]);
-
-      newValue = existingValueArray.concat(valueArray);
-
-      if (descriptor.unique) {
-        newValue = unique(newValue);
-      }
-
-      if (newValue.length === 1) {
-        newValue = newValue[0];
-      }
-    }
-
-    return this.set(attribute, newValue);
-  }
-
-  get(attribute) {
-    return this.#attributes[attribute];
-  }
-
-  has(attribute, pattern) {
-    if (typeof pattern === 'undefined') {
-      return attribute in this.#attributes;
-    } else if (this.has(attribute)) {
-      const value = this.get(attribute);
-      if (Array.isArray(value)) {
-        return value.includes(pattern);
-      } else {
-        return value === pattern;
-      }
-    }
-  }
-
-  remove(attribute) {
-    return delete this.#attributes[attribute];
-  }
-
-  push(attribute, ...values) {
-    const oldValue = this.get(attribute);
-    const newValue =
-      (Array.isArray(oldValue)
-        ? oldValue.concat(values)
-     : oldValue
-        ? [oldValue, ...values]
-        : values);
-    this.set(attribute, newValue);
-    return newValue;
-  }
-
-  toString({color = false} = {}) {
-    const attributeKeyValues =
-      Object.entries(this.attributes)
-        .map(([key, value]) =>
-          (this.#keepAttributeValue(key, value)
-            ? [key, this.#transformAttributeValue(key, value), true]
-            : [key, undefined, false]))
-        .filter(([_key, _value, keep]) => keep)
-        .map(([key, value]) => [key, value]);
-
-    const attributeParts =
-      attributeKeyValues
-        .map(([key, value]) => {
-          const keyPart = key;
-          const escapedValue = this.#escapeAttributeValue(value);
-          const valuePart =
-            (color
-              ? colors.green(`"${escapedValue}"`)
-              : `"${escapedValue}"`);
-
-          return (
-            (typeof value === 'boolean'
-              ? `${keyPart}`
-              : `${keyPart}=${valuePart}`));
-        });
-
-    return attributeParts.join(' ');
-  }
-
-  #keepAttributeValue(attribute, value) {
-    switch (typeof value) {
-      case 'undefined':
-        return false;
-
-      case 'object':
-        if (Array.isArray(value)) {
-          return value.some(Boolean);
-        } else if (value === null) {
-          return false;
-        } else {
-          // Other objects are an error.
-          break;
-        }
-
-      case 'boolean':
-        return value;
-
-      case 'string':
-      case 'number':
-        return true;
-
-      case 'array':
-        return value.some(Boolean);
-    }
-
-    throw new Error(
-      `Value for attribute "${attribute}" should be primitive or array, ` +
-      `got ${typeAppearance(value)}: ${inspect(value)}`);
-  }
-
-  #transformAttributeValue(attribute, value) {
-    const descriptor = attributeSpec[attribute];
-
-    switch (typeof value) {
-      case 'boolean':
-        return value;
-
-      case 'number':
-        return value.toString();
-
-      // If it's a kept object, it's an array.
-      case 'object': {
-        const joiner =
-          (descriptor?.arraylike && descriptor?.join)
-            ?? ' ';
-
-        return value.filter(Boolean).join(joiner);
-      }
-
-      default:
-        return value;
-    }
-  }
-
-  #escapeAttributeValue(value) {
-    return value
-      .toString()
-      .replaceAll('"', '&quot;')
-      .replaceAll("'", '&apos;');
-  }
-
-  static parse(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 (
-      Reflect.construct(this, [
-        Object.fromEntries(
-          Object.entries(attributes)
-            .map(([key, val]) => [
-              key,
-              (val === 'true'
-                ? true
-             : val === 'false'
-                ? false
-             : val === key
-                ? true
-                : val),
-            ])),
-      ]));
-  }
-
-  [inspect.custom]() {
-    const visiblePart = this.toString({color: true});
-
-    const numSymbols = Object.getOwnPropertySymbols(this.#attributes).length;
-    const numSymbolsPart =
-      (numSymbols >= 2
-        ? `${numSymbols} symbol`
-     : numSymbols === 1
-        ? `1 symbol`
-        : ``);
-
-    const symbolPart =
-      (visiblePart && numSymbolsPart
-        ? `(+${numSymbolsPart})`
-     : numSymbols
-        ? `(${numSymbolsPart})`
-        : ``);
-
-    const contentPart =
-      (visiblePart && symbolPart
-        ? `<${visiblePart} ${symbolPart}>`
-     : visiblePart || symbolPart
-        ? `<${visiblePart || symbolPart}>`
-        : `<no attributes>`);
-
-    return `Attributes ${contentPart}`;
-  }
-}
-
-export function resolve(tagOrTemplate, {
-  normalize = null,
-  slots = null,
-} = {}) {
-  if (slots) {
-    return Template.resolveForSlots(tagOrTemplate, slots);
-  } else if (normalize === 'tag') {
-    return Tag.normalize(tagOrTemplate);
-  } else if (normalize === 'string') {
-    return Tag.normalize(tagOrTemplate).toString();
-  } else if (normalize) {
-    throw new TypeError(`Expected normalize to be 'tag', 'string', or null`);
-  } else {
-    return Template.resolve(tagOrTemplate);
-  }
-}
-
-export function smush(smushee) {
-  if (
-    typeof smushee === 'string' ||
-    typeof smushee === 'number'
-  ) {
-    return tags([smushee.toString()]);
-  }
-
-  if (smushee instanceof Template) {
-    // Smushing is only really useful if the contents are resolved, because
-    // otherwise we can't actually inspect the boundaries. However, as usual
-    // for smushing, we don't care at all about the contents of tags (which
-    // aren't contentOnly) *within* the content we're smushing, so this won't
-    // for example smush a template nested within a *tag* within the contents
-    // of this template.
-    return smush(Template.resolve(smushee));
-  }
-
-  if (smushee instanceof Tag) {
-    return smushee.smush();
-  }
-
-  return smush(Tag.normalize(smushee));
-}
-
-// Much gentler version of smush - this only flattens nested html.tags(), and
-// guarantees the result is itself an html.tags(). It doesn't manipulate text
-// content, and it doesn't resolve templates.
-export function smooth(smoothie) {
-  // Helper function to avoid intermediate html.tags() calls.
-  function helper(tag) {
-    if (tag instanceof Tag && tag.contentOnly) {
-      return tag.content.flatMap(helper);
-    } else {
-      return tag;
-    }
-  }
-
-  return tags(helper(smoothie));
-}
-
-export function template(description) {
-  return new Template(description);
-}
-
-export class Template {
-  #description = {};
-  #slotValues = {};
-
-  constructor(description) {
-    if (!description[Stationery.validated]) {
-      Template.validateDescription(description);
-    }
-
-    this.#description = description;
-  }
-
-  clone() {
-    const clone = Reflect.construct(this.constructor, [
-      this.#description,
-    ]);
-
-    // getSlotValue(), called via #getReadySlotValues(), is responsible for
-    // preparing slot values for consumption, which includes cloning mutable
-    // html/attributes. We reuse that behavior here, in a recursive manner,
-    // so that clone() is effectively "deep" - slots that may be mutated are
-    // cloned, so that this template and its clones will never mutate the same
-    // identities.
-    clone.setSlots(this.#getReadySlotValues());
-
-    return clone;
-  }
-
-  static validateDescription(description) {
-    if (typeof description !== 'object') {
-      throw new TypeError(`Expected object, got ${typeAppearance(description)}`);
-    }
-
-    if (description === null) {
-      throw new TypeError(`Expected object, got null`);
-    }
-
-    const topErrors = [];
-
-    if (!('content' in description)) {
-      topErrors.push(new TypeError(`Expected description.content`));
-    } else if (typeof description.content !== 'function') {
-      topErrors.push(new TypeError(`Expected description.content to be function`));
-    }
-
-    if ('annotation' in description) {
-      if (typeof description.annotation !== 'string') {
-        topErrors.push(new TypeError(`Expected annotation to be string`));
-      }
-    }
-
-    if ('slots' in description) validateSlots: {
-      if (typeof description.slots !== 'object') {
-        topErrors.push(new TypeError(`Expected description.slots to be object`));
-        break validateSlots;
-      }
-
-      try {
-        this.validateSlotsDescription(description.slots);
-      } catch (slotError) {
-        topErrors.push(slotError);
-      }
-    }
-
-    if (!empty(topErrors)) {
-      throw new AggregateError(topErrors,
-        (typeof description.annotation === 'string'
-          ? `Errors validating template "${description.annotation}" description`
-          : `Errors validating template description`));
-    }
-
-    return true;
-  }
-
-  static validateSlotsDescription(slots) {
-    const slotErrors = [];
-
-    for (const [slotName, slotDescription] of Object.entries(slots)) {
-      if (typeof slotDescription !== 'object' || slotDescription === null) {
-        slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
-        continue;
-      }
-
-      if ('default' in slotDescription) validateDefault: {
-        if (
-          slotDescription.default === undefined ||
-          slotDescription.default === null
-        ) {
-          slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
-          break validateDefault;
-        }
-
-        try {
-          Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
-        } catch (error) {
-          error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
-          slotErrors.push(error);
-        }
-      }
-
-      if ('validate' in slotDescription && 'type' in slotDescription) {
-        slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
-      } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
-        slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
-      } else if ('validate' in slotDescription) {
-        if (typeof slotDescription.validate !== 'function') {
-          slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
-        }
-      } else if ('type' in slotDescription) {
-        const acceptableSlotTypes = [
-          'string',
-          'number',
-          'bigint',
-          'boolean',
-          'symbol',
-          'html',
-          'attributes',
-        ];
-
-        if (slotDescription.type === 'function') {
-          slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
-        } else if (slotDescription.type === 'object') {
-          slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
-        } else if (
-          (slotDescription.type === 'html' || slotDescription.type === 'attributes') &&
-          !('mutable' in slotDescription)
-        ) {
-          slotErrors.push(new TypeError(`(${slotName}) Specify mutable: true/false alongside type: ${slotDescription.type}`));
-        } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
-          slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
-        }
-      }
-
-      if ('mutable' in slotDescription) {
-        if (slotDescription.type !== 'html' && slotDescription.type !== 'attributes') {
-          slotErrors.push(new TypeError(`(${slotName}) Only specify mutable alongside type: html or attributes`));
-        }
-
-        if (typeof slotDescription.mutable !== 'boolean') {
-          slotErrors.push(new TypeError(`(${slotName}) Expected slot mutable to be boolean`));
-        }
-      }
-    }
-
-    if (!empty(slotErrors)) {
-      throw new AggregateError(slotErrors, `Errors in slot descriptions`);
-    }
-
-    return true;
-  }
-
-  slot(slotName, value) {
-    this.setSlot(slotName, value);
-    return this;
-  }
-
-  slots(slotNamesToValues) {
-    this.setSlots(slotNamesToValues);
-    return this;
-  }
-
-  setSlot(slotName, value) {
-    const description = this.#getSlotDescriptionOrError(slotName);
-
-    try {
-      Template.validateSlotValueAgainstDescription(value, description);
-    } catch (error) {
-      error.message =
-        (this.description.annotation
-          ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}`
-          : `Error validating template slot "${slotName}" value: ${error.message}`);
-      throw error;
-    }
-
-    this.#slotValues[slotName] = value;
-  }
-
-  setSlots(slotNamesToValues) {
-    if (
-      typeof slotNamesToValues !== 'object' ||
-      Array.isArray(slotNamesToValues) ||
-      slotNamesToValues === null
-    ) {
-      throw new TypeError(`Expected object mapping of slot names to values`);
-    }
-
-    const slotErrors = [];
-
-    for (const [slotName, value] of Object.entries(slotNamesToValues)) {
-      const description = this.#getSlotDescriptionNoError(slotName);
-      if (!description) {
-        slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`));
-        continue;
-      }
-
-      try {
-        Template.validateSlotValueAgainstDescription(value, description);
-      } catch (error) {
-        error.message = `(${slotName}) ${error.message}`;
-        slotErrors.push(error);
-      }
-    }
-
-    if (!empty(slotErrors)) {
-      throw new AggregateError(slotErrors,
-        (this.description.annotation
-          ? `Error validating template "${this.description.annotation}" slots`
-          : `Error validating template slots`));
-    }
-
-    Object.assign(this.#slotValues, slotNamesToValues);
-  }
-
-  static validateSlotValueAgainstDescription(value, description) {
-    if (value === undefined) {
-      throw new TypeError(`Specify value as null or don't specify at all`);
-    }
-
-    // Null is always an acceptable slot value.
-    if (value === null) {
-      return true;
-    }
-
-    if (Object.hasOwn(description, 'validate')) {
-      description.validate({
-        ...commonValidators,
-        ...validators,
-      })(value);
-
-      return true;
-    }
-
-    if (Object.hasOwn(description, 'type')) {
-      switch (description.type) {
-        case 'html': {
-          return isHTML(value);
-        }
-
-        case 'attributes': {
-          return isAttributesAdditionSinglet(value);
-        }
-
-        case 'string': {
-          if (typeof value === 'string')
-            return true;
-
-          // Tags and templates are valid in string arguments - they'll be
-          // stringified when exposed to the description's .content() function.
-          if (value instanceof Tag || value instanceof Template)
-            return true;
-
-          return true;
-        }
-
-        default: {
-          if (typeof value !== description.type)
-            throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`);
-
-          return true;
-        }
-      }
-    }
-
-    return true;
-  }
-
-  getSlotValue(slotName) {
-    const description = this.#getSlotDescriptionOrError(slotName);
-    const providedValue = this.#slotValues[slotName] ?? null;
-
-    if (description.type === 'html') {
-      if (!providedValue) {
-        return blank();
-      }
-
-      if (
-        (providedValue instanceof Tag || providedValue instanceof Template) &&
-        description.mutable
-      ) {
-        return providedValue.clone();
-      }
-
-      return providedValue;
-    }
-
-    if (description.type === 'attributes') {
-      if (!providedValue) {
-        return blankAttributes();
-      }
-
-      if (providedValue instanceof Attributes) {
-        if (description.mutable) {
-          return providedValue.clone();
-        } else {
-          return providedValue;
-        }
-      }
-
-      return new Attributes(providedValue);
-    }
-
-    if (description.type === 'string') {
-      if (providedValue instanceof Tag || providedValue instanceof Template) {
-        return providedValue.toString();
-      }
-
-      if (isBlank(providedValue)) {
-        return null;
-      }
-    }
-
-    if (providedValue !== null) {
-      return providedValue;
-    }
-
-    if ('default' in description) {
-      return description.default;
-    }
-
-    return null;
-  }
-
-  getSlotDescription(slotName) {
-    return this.#getSlotDescriptionOrError(slotName);
-  }
-
-  #getSlotDescriptionNoError(slotName) {
-    if (this.#description.slots) {
-      if (Object.hasOwn(this.#description.slots, slotName)) {
-        return this.#description.slots[slotName];
-      }
-    }
-
-    return null;
-  }
-
-  #getSlotDescriptionOrError(slotName) {
-    const description = this.#getSlotDescriptionNoError(slotName);
-
-    if (!description) {
-      throw new TypeError(
-        (this.description.annotation
-          ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot`
-          : `Template doesn't have a "${slotName}" slot`));
-    }
-
-    return description;
-  }
-
-  #getReadySlotValues() {
-    const slots = {};
-
-    for (const slotName of Object.keys(this.description.slots ?? {})) {
-      slots[slotName] = this.getSlotValue(slotName);
-    }
-
-    return slots;
-  }
-
-  set content(_value) {
-    throw new Error(`Template content can't be changed after constructed`);
-  }
-
-  get content() {
-    const slots = this.#getReadySlotValues();
-
-    try {
-      return this.description.content(slots);
-    } catch (caughtError) {
-      throw new Error(
-        `Error in content of ${inspect(this, {compact: true})}`,
-        {cause: caughtError});
-    }
-  }
-
-  set description(_value) {
-    throw new Error(`Template description can't be changed after constructed`);
-  }
-
-  get description() {
-    return this.#description;
-  }
-
-  get blank() {
-    return isBlank(this.content);
-  }
-
-  toString() {
-    return this.content.toString();
-  }
-
-  static resolve(tagOrTemplate) {
-    // Flattens contents of a template, recursively "resolving" until a
-    // non-template is ready (or just returns a provided non-template
-    // argument as-is).
-
-    if (!(tagOrTemplate instanceof Template)) {
-      return tagOrTemplate;
-    }
-
-    let {content} = tagOrTemplate;
-
-    while (content instanceof Template) {
-      content = content.content;
-    }
-
-    return content;
-  }
-
-  static resolveForSlots(tagOrTemplate, slots) {
-    if (!slots || typeof slots !== 'object') {
-      throw new Error(
-        `Expected slots to be an object or array, ` +
-        `got ${typeAppearance(slots)}`);
-    }
-
-    if (!Array.isArray(slots)) {
-      return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots);
-    }
-
-    while (tagOrTemplate && tagOrTemplate instanceof Template) {
-      try {
-        for (const slot of slots) {
-          tagOrTemplate.getSlotDescription(slot);
-        }
-
-        return tagOrTemplate;
-      } catch {
-        tagOrTemplate = tagOrTemplate.content;
-      }
-    }
-
-    throw new Error(
-      `Didn't find slots ${inspect(slots, {compact: true})} ` +
-      `resolving ${inspect(tagOrTemplate, {compact: true})}`);
-  }
-
-  [inspect.custom]() {
-    const {annotation} = this.description;
-
-    return (
-      (annotation
-        ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}`
-        : `Template ${colors.dim(`(no annotation)`)}`));
-  }
-}
-
-export function stationery(description) {
-  return new Stationery(description);
-}
-
-export class Stationery {
-  #templateDescription = null;
-
-  static validated = Symbol('Stationery.validated');
-
-  constructor(templateDescription) {
-    Template.validateDescription(templateDescription);
-    templateDescription[Stationery.validated] = true;
-    this.#templateDescription = templateDescription;
-  }
-
-  template() {
-    return new Template(this.#templateDescription);
-  }
-
-  [inspect.custom]() {
-    const {annotation} = this.description;
-
-    return (
-      (annotation
-        ? `Stationery ${colors.bright(colors.blue(`"${annotation}"`))}`
-        : `Stationery ${colors.dim(`(no annotation)`)}`));
-  }
-}
-
-export const isTag =
-  validateInstanceOf(Tag);
-
-export const isTemplate =
-  validateInstanceOf(Template);
-
-export const isArrayOfHTML =
-  validateArrayItems(value => isHTML(value));
-
-export const isHTML =
-  anyOf(
-    is(null, undefined, false),
-    isString,
-    isTag,
-    isTemplate,
-
-    value => {
-      isArray(value);
-      return value.length === 0;
-    },
-
-    isArrayOfHTML);
-
-export const isAttributeKey =
-  anyOf(isString, isSymbol);
-
-export const isAttributeValue =
-  anyOf(
-    isString, isNumber, isBoolean, isArray,
-    isTag, isTemplate,
-    validateArrayItems(item => isAttributeValue(item)));
-
-export const isAttributesAdditionPair = pair => {
-  isArray(pair);
-
-  if (pair.length !== 2) {
-    throw new TypeError(`Expected attributes pair to have two items`);
-  }
-
-  withAggregate({message: `Error validating attributes pair`}, ({push}) => {
-    try {
-      isAttributeKey(pair[0]);
-    } catch (caughtError) {
-      push(new Error(`Error validating key`, {cause: caughtError}));
-    }
-
-    try {
-      isAttributeValue(pair[1]);
-    } catch (caughtError) {
-      push(new Error(`Error validating value`, {cause: caughtError}));
-    }
-  });
-
-  return true;
-};
-
-const isAttributesAdditionSingletHelper =
-  anyOf(
-    validateInstanceOf(Template),
-    validateInstanceOf(Attributes),
-    validateAllPropertyValues(isAttributeValue),
-    looseArrayOf(value => isAttributesAdditionSinglet(value)));
-
-export const isAttributesAdditionSinglet = (value) => {
-  if (typeof value === 'object' && value !== null) {
-    if (Object.hasOwn(value, blessAttributes)) {
-      return true;
-    }
-
-    if (
-      Array.isArray(value) &&
-      value.length === 1 &&
-      typeof value[0] === 'object' &&
-      value[0] !== null &&
-      Object.hasOwn(value[0], blessAttributes)
-    ) {
-      return true;
-    }
-  }
-
-  return isAttributesAdditionSingletHelper(value);
-};
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
deleted file mode 100644
index 345d10aa..00000000
--- a/src/util/node-utils.js
+++ /dev/null
@@ -1,102 +0,0 @@
-// Utility functions which are only relevant to particular Node.js constructs.
-
-import {readdir, stat} from 'node:fs/promises';
-import * as path from 'node:path';
-import {fileURLToPath} from 'node:url';
-
-import _commandExists from 'command-exists';
-
-// This package throws an error instead of returning false when the command
-// doesn't exist, for some reason. Yay for making logic more difficult!
-// Here's a straightforward workaround.
-export function commandExists(command) {
-  return _commandExists(command).then(
-    () => true,
-    () => false
-  );
-}
-
-// Very cool function origin8ting in... http-music pro8a8ly!
-// Sorry if we happen to 8e violating past-us's copyright, lmao.
-export function promisifyProcess(proc, showLogging = true) {
-  // Takes a process (from the child_process module) and returns a promise
-  // that resolves when the process exits (or rejects, if the exit code is
-  // non-zero).
-  //
-  // Ayy look, no alpha8etical second letter! Couldn't tell this was written
-  // like three years ago 8efore I was me. 8888)
-
-  return new Promise((resolve, reject) => {
-    if (showLogging) {
-      proc.stdout.pipe(process.stdout);
-      proc.stderr.pipe(process.stderr);
-    }
-
-    proc.on('exit', (code) => {
-      if (code === 0) {
-        resolve();
-      } else {
-        reject(code);
-      }
-    });
-  });
-}
-
-// Handy-dandy utility function for detecting whether the passed URL is the
-// running JavaScript file. This takes `import.meta.url` from ES6 modules, which
-// is great 'cuz (module === require.main) doesn't work without CommonJS
-// modules.
-export function isMain(importMetaURL) {
-  const metaPath = fileURLToPath(importMetaURL);
-  const relative = path.relative(process.argv[1], metaPath);
-  const isIndexJS = path.basename(metaPath) === 'index.js';
-  return [
-    '',
-    isIndexJS && 'index.js'
-  ].includes(relative);
-}
-
-// Like readdir... but it's recursive! This returns a flat list of file paths.
-// By default, the paths include the provided top/root path, but this can be
-// changed with prefixPath to prefix some other path, or to just return paths
-// relative to the root. Change pathStyle to specify posix or win32, or leave
-// it as the default device-correct style. Provide a filterDir function to
-// control which directory names are traversed at all, and filterFile to
-// select which filenames are included in the final list.
-export async function traverse(rootPath, {
-  pathStyle = 'device',
-  filterFile = () => true,
-  filterDir = () => true,
-  prefixPath = rootPath,
-} = {}) {
-  const pathJoinDevice = path.join;
-  const pathJoinStyle = {
-    'device': path.join,
-    'posix': path.posix.join,
-    'win32': path.win32.join,
-  }[pathStyle];
-
-  if (!pathJoinStyle) {
-    throw new Error(`Expected pathStyle to be device, posix, or win32`);
-  }
-
-  const recursive = (names, ...subdirectories) =>
-    Promise.all(names.map(async name => {
-      const devicePath = pathJoinDevice(rootPath, ...subdirectories, name);
-      const stats = await stat(devicePath);
-
-      if (stats.isDirectory() && !filterDir(name)) return [];
-      else if (stats.isFile() && !filterFile(name)) return [];
-      else if (!stats.isDirectory() && !stats.isFile()) return [];
-
-      if (stats.isDirectory()) {
-        return recursive(await readdir(devicePath), ...subdirectories, name);
-      } else {
-        return pathJoinStyle(prefixPath, ...subdirectories, name);
-      }
-    }));
-
-  const names = await readdir(rootPath);
-  const results = await recursive(names);
-  return results.flat(Infinity);
-}
diff --git a/src/util/replacer.js b/src/util/replacer.js
deleted file mode 100644
index e3f5623e..00000000
--- a/src/util/replacer.js
+++ /dev/null
@@ -1,852 +0,0 @@
-// Regex-based forward parser for wiki content, breaking up text input into
-// text and (possibly nested) tag nodes.
-//
-// The behavior here is quite tied into the `transformContent` content
-// function, which converts nodes parsed here into actual HTML, links, etc
-// for embedding in a wiki webpage.
-
-import * as marked from 'marked';
-
-import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
-
-export const replacerSpec = {
-  'album': {
-    find: 'album',
-    link: 'linkAlbumDynamically',
-  },
-
-  'album-commentary': {
-    find: 'album',
-    link: 'linkAlbumCommentary',
-  },
-
-  'album-gallery': {
-    find: 'album',
-    link: 'linkAlbumGallery',
-  },
-
-  'artist': {
-    find: 'artist',
-    link: 'linkArtist',
-  },
-
-  'artist-gallery': {
-    find: 'artist',
-    link: 'linkArtistGallery',
-  },
-
-  'commentary-index': {
-    find: null,
-    link: 'linkCommentaryIndex',
-  },
-
-  'date': {
-    find: null,
-    value: (ref) => new Date(ref),
-    html: (date, {html, language}) =>
-      html.tag('time',
-        {datetime: date.toUTCString()},
-        language.formatDate(date)),
-  },
-
-  'flash-index': {
-    find: null,
-    link: 'linkFlashIndex',
-  },
-
-  'flash': {
-    find: 'flash',
-    link: 'linkFlash',
-    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;
-      }
-    },
-  },
-
-  'flash-act': {
-    find: 'flashAct',
-    link: 'linkFlashAct',
-  },
-
-  'group': {
-    find: 'group',
-    link: 'linkGroup',
-  },
-
-  'group-gallery': {
-    find: 'group',
-    link: 'linkGroupGallery',
-  },
-
-  'home': {
-    find: null,
-    link: 'linkWikiHome',
-  },
-
-  'listing-index': {
-    find: null,
-    link: 'linkListingIndex',
-  },
-
-  'listing': {
-    find: 'listing',
-    link: 'linkListing',
-  },
-
-  'media': {
-    find: null,
-    link: 'linkPathFromMedia',
-  },
-
-  'news-index': {
-    find: null,
-    link: 'linkNewsIndex',
-  },
-
-  'news-entry': {
-    find: 'newsEntry',
-    link: 'linkNewsEntry',
-  },
-
-  'root': {
-    find: null,
-    link: 'linkPathFromRoot',
-  },
-
-  'site': {
-    find: null,
-    link: 'linkPathFromSite',
-  },
-
-  'static': {
-    find: 'staticPage',
-    link: 'linkStaticPage',
-  },
-
-  'string': {
-    find: null,
-    value: (ref) => ref,
-    html: (ref, {language, args}) => language.$(ref, args),
-  },
-
-  'tag': {
-    find: 'artTag',
-    link: 'linkArtTag',
-  },
-
-  'track': {
-    find: 'track',
-    link: 'linkTrackDynamically',
-  },
-};
-
-// Syntax literals.
-const tagBeginning = '[[';
-const tagEnding = ']]';
-const tagReplacerValue = ':';
-const tagHash = '#';
-const tagArgument = '*';
-const tagArgumentValue = '=';
-const tagLabel = '|';
-
-const noPrecedingWhitespace = '(?<!\\s)';
-
-const R_tagBeginning = escapeRegex(tagBeginning);
-
-const R_tagEnding = escapeRegex(tagEnding);
-
-const R_tagReplacerValue =
-  noPrecedingWhitespace + escapeRegex(tagReplacerValue);
-
-const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash);
-
-const R_tagArgument = escapeRegex(tagArgument);
-
-const R_tagArgumentValue = escapeRegex(tagArgumentValue);
-
-const R_tagLabel = escapeRegex(tagLabel);
-
-const regexpCache = {};
-
-const makeError = (i, message) => ({i, type: 'error', data: {message}});
-const endOfInput = (i, comment) =>
-  makeError(i, `Unexpected end of input (${comment}).`);
-
-// These are 8asically stored on the glo8al scope, which might seem odd
-// for a recursive function, 8ut the values are only ever used immediately
-// after they're set.
-let stopped, stop_iParse, stop_literal;
-
-function parseOneTextNode(input, i, stopAt) {
-  return parseNodes(input, i, stopAt, true)[0];
-}
-
-function parseNodes(input, i, stopAt, textOnly) {
-  let nodes = [];
-  let string = '';
-  let iString = 0;
-
-  stopped = false;
-
-  const pushTextNode = (isLast) => {
-    string = input.slice(iString, i);
-
-    // If this is the last text node 8efore stopping (at a stopAt match
-    // or the end of the input), trim off whitespace at the end.
-    if (isLast) {
-      string = string.trimEnd();
-    }
-
-    string = cleanRawText(string);
-
-    if (string.length) {
-      nodes.push({i: iString, iEnd: i, type: 'text', data: string});
-      string = '';
-    }
-  };
-
-  const literalsToMatch = stopAt
-    ? stopAt.concat([R_tagBeginning])
-    : [R_tagBeginning];
-
-  // The 8ackslash stuff here is to only match an even (or zero) num8er
-  // of sequential 'slashes. Even amounts always cancel out! Odd amounts
-  // don't, which would mean the following literal is 8eing escaped and
-  // should 8e counted only as part of the current string/text.
-  //
-  // Inspired 8y this: https://stackoverflow.com/a/41470813
-  const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
-
-  // There are 8asically only a few regular expressions we'll ever use,
-  // 8ut it's a pain to hard-code them all, so we dynamically gener8te
-  // and cache them for reuse instead.
-  let regexp;
-  if (Object.hasOwn(regexpCache, regexpSource)) {
-    regexp = regexpCache[regexpSource];
-  } else {
-    regexp = new RegExp(regexpSource);
-    regexpCache[regexpSource] = regexp;
-  }
-
-  // Skip whitespace at the start of parsing. This is run every time
-  // parseNodes is called (and thus parseOneTextNode too), so spaces
-  // at the start of syntax elements will always 8e skipped. We don't
-  // skip whitespace that shows up inside content (i.e. once we start
-  // parsing below), though!
-  const whitespaceOffset = input.slice(i).search(/[^\s]/);
-
-  // If the string is all whitespace, that's just zero content, so
-  // return the empty nodes array.
-  if (whitespaceOffset === -1) {
-    return nodes;
-  }
-
-  i += whitespaceOffset;
-
-  while (i < input.length) {
-    const match = input.slice(i).match(regexp);
-
-    if (!match) {
-      iString = i;
-      i = input.length;
-      pushTextNode(true);
-      break;
-    }
-
-    const closestMatch = match[0];
-    const closestMatchIndex = i + match.index;
-
-    if (textOnly && closestMatch === tagBeginning)
-      throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
-
-    const stopHere = closestMatch !== tagBeginning;
-
-    iString = i;
-    i = closestMatchIndex;
-    pushTextNode(stopHere);
-
-    i += closestMatch.length;
-
-    if (stopHere) {
-      stopped = true;
-      stop_iParse = i;
-      stop_literal = closestMatch;
-      break;
-    }
-
-    if (closestMatch === tagBeginning) {
-      const iTag = closestMatchIndex;
-
-      let N;
-
-      // Replacer key (or value)
-
-      N = parseOneTextNode(input, i, [
-        R_tagReplacerValue,
-        R_tagHash,
-        R_tagArgument,
-        R_tagLabel,
-        R_tagEnding,
-      ]);
-
-      if (!stopped) throw endOfInput(i, `reading replacer key`);
-
-      if (!N) {
-        switch (stop_literal) {
-          case tagReplacerValue:
-          case tagArgument:
-            throw makeError(i, `Expected text (replacer key).`);
-          case tagLabel:
-          case tagHash:
-          case tagEnding:
-            throw makeError(i, `Expected text (replacer key/value).`);
-        }
-      }
-
-      const replacerFirst = N;
-      i = stop_iParse;
-
-      // Replacer value (if explicit)
-
-      let replacerSecond;
-
-      if (stop_literal === tagReplacerValue) {
-        N = parseNodes(input, i, [
-          R_tagHash,
-          R_tagArgument,
-          R_tagLabel,
-          R_tagEnding,
-        ]);
-
-        if (!stopped) throw endOfInput(i, `reading replacer value`);
-        if (!N.length) throw makeError(i, `Expected content (replacer value).`);
-
-        replacerSecond = N;
-        i = stop_iParse;
-      }
-
-      // Assign first & second to replacer key/value
-
-      let replacerKey, replacerValue;
-
-      // Value is an array of nodes, 8ut key is just one (or null).
-      // So if we use replacerFirst as the value, we need to stick
-      // it in an array (on its own).
-      if (replacerSecond) {
-        replacerKey = replacerFirst;
-        replacerValue = replacerSecond;
-      } else {
-        replacerKey = null;
-        replacerValue = [replacerFirst];
-      }
-
-      // Hash
-
-      let hash;
-
-      if (stop_literal === tagHash) {
-        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-        if (!stopped) throw endOfInput(i, `reading hash`);
-        if (!N) throw makeError(i, `Expected text (hash).`);
-
-        hash = N;
-        i = stop_iParse;
-      }
-
-      // Arguments
-
-      const args = [];
-
-      while (stop_literal === tagArgument) {
-        N = parseOneTextNode(input, i, [
-          R_tagArgumentValue,
-          R_tagArgument,
-          R_tagLabel,
-          R_tagEnding,
-        ]);
-
-        if (!stopped) throw endOfInput(i, `reading argument key`);
-
-        if (stop_literal !== tagArgumentValue)
-          throw makeError(
-            i,
-            `Expected ${tagArgumentValue.literal} (tag argument).`
-          );
-
-        if (!N) throw makeError(i, `Expected text (argument key).`);
-
-        const key = N;
-        i = stop_iParse;
-
-        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-        if (!stopped) throw endOfInput(i, `reading argument value`);
-        if (!N.length) throw makeError(i, `Expected content (argument value).`);
-
-        const value = N;
-        i = stop_iParse;
-
-        args.push({key, value});
-      }
-
-      let label;
-
-      if (stop_literal === tagLabel) {
-        N = parseOneTextNode(input, i, [R_tagEnding]);
-
-        if (!stopped) throw endOfInput(i, `reading label`);
-        if (!N) throw makeError(i, `Expected text (label).`);
-
-        label = N;
-        i = stop_iParse;
-      }
-
-      nodes.push({
-        i: iTag,
-        iEnd: i,
-        type: 'tag',
-        data: {replacerKey, replacerValue, hash, args, label},
-      });
-
-      continue;
-    }
-  }
-
-  return nodes;
-}
-
-export function squashBackslashes(text) {
-  // Squash backslashes which aren't themselves escaped into
-  // the following character, unless that character is one of
-  // a set of characters where the backslash carries meaning
-  // into later formatting (i.e. markdown). Note that we do
-  // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
-}
-
-export function restoreRawHTMLTags(text) {
-  // Replace stuff like <html:a> with <a>; these signal that
-  // the tag shouldn't be processed by the replacer system,
-  // and should just be embedded into the content as raw HTML.
-  return text.replace(/<html:(.*?)(?=[ >])/g, '<$1');
-}
-
-export function cleanRawText(text) {
-  text = squashBackslashes(text);
-  text = restoreRawHTMLTags(text);
-  return text;
-}
-
-export function postprocessComments(inputNodes) {
-  const outputNodes = [];
-
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
-
-    const commentRegexp =
-      new RegExp(
-        (// Remove comments which occupy entire lines, trimming the line break
-         // leading into them. These comments never include the ending of a
-         // comment which does not end a line, which is a regex way of saying
-         // "please fail early if we hit a --> that doesn't happen at the end
-         // of the line".
-         String.raw`\n<!--(?:(?!-->(?!$))[\s\S])*?-->(?=$)`
-       + '|' +
-
-         // Remove comments which appear at the start of a line, and any
-         // following spaces.
-         String.raw`^<!--[\s\S]*?--> *` +
-       + '|' +
-
-         // Remove comments which appear anywhere else, including in the
-         // middle of a line or at the end of a line, and any leading spaces.
-         String.raw` *<!--[\s\S]*?-->`),
-
-        'gm');
-
-    outputNodes.push({
-      type: 'text',
-
-      data:
-        node.data.replace(commentRegexp, ''),
-
-      i: node.i,
-      iEnd: node.iEnd,
-    });
-  }
-
-  return outputNodes;
-}
-
-export function postprocessImages(inputNodes) {
-  const outputNodes = [];
-
-  let atStartOfLine = true;
-
-  const lastNode = inputNodes.at(-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,
-          i: node.i + parseFrom,
-          iEnd: node.i + parseFrom + match.index,
-        });
-
-        parseFrom = match.index + match[0].length;
-
-        const imageNode = {type: 'image'};
-        const attributes = html.parseAttributes(match[1]);
-
-        imageNode.src = attributes.get('src');
-
-        if (previousText.endsWith('\n')) {
-          atStartOfLine = true;
-        } else if (previousText.length) {
-          atStartOfLine = false;
-        }
-
-        imageNode.inline = (() => {
-          // Images can force themselves to be rendered inline using a custom
-          // attribute - this style just works better for certain embeds,
-          // usually jokes or small images.
-          if (attributes.get('inline')) return true;
-
-          // 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.get('link')) imageNode.link = attributes.get('link');
-        if (attributes.get('style')) imageNode.style = attributes.get('style');
-        if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width'));
-        if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height'));
-        if (attributes.get('align')) imageNode.align = attributes.get('align');
-        if (attributes.get('pixelate')) imageNode.pixelate = true;
-
-        if (attributes.get('warning')) {
-          imageNode.warnings =
-            attributes.get('warning').split(', ');
-        }
-
-        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),
-          i: node.i + parseFrom,
-          iEnd: node.iEnd,
-        });
-      }
-
-      continue;
-    }
-
-    outputNodes.push(node);
-  }
-
-  return outputNodes;
-}
-
-export function postprocessVideos(inputNodes) {
-  const outputNodes = [];
-
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
-
-    const videoRegexp = /<video (.*?)>(<\/video>)?/g;
-
-    let match = null, parseFrom = 0;
-    while (match = videoRegexp.exec(node.data)) {
-      const previousText = node.data.slice(parseFrom, match.index);
-
-      outputNodes.push({
-        type: 'text',
-        data: previousText,
-        i: node.i + parseFrom,
-        iEnd: node.i + parseFrom + match.index,
-      });
-
-      parseFrom = match.index + match[0].length;
-
-      const videoNode = {type: 'video'};
-      const attributes = html.parseAttributes(match[1]);
-
-      videoNode.src = attributes.get('src');
-
-      if (attributes.get('width')) videoNode.width = parseInt(attributes.get('width'));
-      if (attributes.get('height')) videoNode.height = parseInt(attributes.get('height'));
-      if (attributes.get('align')) videoNode.align = attributes.get('align');
-      if (attributes.get('pixelate')) videoNode.pixelate = true;
-
-      outputNodes.push(videoNode);
-    }
-
-    if (parseFrom !== node.data.length) {
-      outputNodes.push({
-        type: 'text',
-        data: node.data.slice(parseFrom),
-        i: node.i + parseFrom,
-        iEnd: node.iEnd,
-      });
-    }
-  }
-
-  return outputNodes;
-}
-
-export function postprocessHeadings(inputNodes) {
-  const outputNodes = [];
-
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
-
-    const headingRegexp = /<h2 (.*?)>/g;
-
-    let textContent = '';
-
-    let match = null, parseFrom = 0;
-    while (match = headingRegexp.exec(node.data)) {
-      textContent += node.data.slice(parseFrom, match.index);
-      parseFrom = match.index + match[0].length;
-
-      const attributes = html.parseAttributes(match[1]);
-      attributes.push('class', 'content-heading');
-
-      // We're only modifying the opening tag here. The remaining content,
-      // including the closing tag, will be pushed as-is.
-      textContent += `<h2 ${attributes}>`;
-    }
-
-    if (parseFrom !== node.data.length) {
-      textContent += node.data.slice(parseFrom);
-    }
-
-    outputNodes.push({
-      type: 'text',
-      data: textContent,
-      i: node.i,
-      iEnd: node.iEnd,
-    });
-  }
-
-  return outputNodes;
-}
-
-export function postprocessSummaries(inputNodes) {
-  const outputNodes = [];
-
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
-
-    const summaryRegexp = /<summary>(.*)<\/summary>/g;
-
-    let textContent = '';
-
-    let match = null, parseFrom = 0;
-    while (match = summaryRegexp.exec(node.data)) {
-      textContent += node.data.slice(parseFrom, match.index);
-      parseFrom = match.index + match[0].length;
-
-      const colorizeWholeSummary = !match[1].includes('<b>');
-
-      // We're wrapping the contents of the <summary> with a <span>, and
-      // possibly with a <b>, too. This means we have to add the closing tags
-      // where the summary ends.
-      textContent += `<summary><span>`;
-      textContent += (colorizeWholeSummary ? `<b>` : ``);
-      textContent += match[1];
-      textContent += (colorizeWholeSummary ? `</b>` : ``);
-      textContent += `</span></summary>`;
-    }
-
-    if (parseFrom !== node.data.length) {
-      textContent += node.data.slice(parseFrom);
-    }
-
-    outputNodes.push({
-      type: 'text',
-      data: textContent,
-      i: node.i,
-      iEnd: node.iEnd,
-    });
-  }
-
-  return outputNodes;
-}
-
-export function postprocessExternalLinks(inputNodes) {
-  const outputNodes = [];
-
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
-
-    const plausibleLinkRegexp = /\[.*?\)/g;
-
-    let textContent = '';
-
-    let plausibleMatch = null, parseFrom = 0;
-    while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) {
-      textContent += node.data.slice(parseFrom, plausibleMatch.index);
-
-      // Pedantic rules use more particular parentheses detection in link
-      // destinations - they allow one level of balanced parentheses, and
-      // otherwise, parentheses must be escaped. This allows for entire links
-      // to be wrapped in parentheses, e.g below:
-      //
-      //   This is so cool. ([You know??](https://example.com))
-      //
-      const definiteMatch =
-        marked.Lexer.rules.inline.pedantic.link
-          .exec(node.data.slice(plausibleMatch.index));
-
-      if (definiteMatch) {
-        const {1: label, 2: href} = definiteMatch;
-
-        // Split the containing text node into two - the second of these will
-        // be added after iterating over matches, or by the next match.
-        if (textContent.length) {
-          outputNodes.push({type: 'text', data: textContent});
-          textContent = '';
-        }
-
-        const offset = plausibleMatch.index + definiteMatch.index;
-        const length = definiteMatch[0].length;
-
-        outputNodes.push({
-          i: node.i + offset,
-          iEnd: node.i + offset + length,
-          type: 'external-link',
-          data: {label, href},
-        });
-
-        parseFrom = offset + length;
-      } else {
-        parseFrom = plausibleMatch.index;
-      }
-    }
-
-    if (parseFrom !== node.data.length) {
-      textContent += node.data.slice(parseFrom);
-    }
-
-    if (textContent.length) {
-      outputNodes.push({type: 'text', data: textContent});
-    }
-  }
-
-  return outputNodes;
-}
-
-export function parseInput(input) {
-  if (typeof input !== 'string') {
-    throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
-  }
-
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = postprocessHeadings(output);
-    output = postprocessSummaries(output);
-    output = postprocessExternalLinks(output);
-    return output;
-  } catch (errorNode) {
-    if (errorNode.type !== 'error') {
-      throw errorNode;
-    }
-
-    const {
-      i,
-      data: {message},
-    } = errorNode;
-
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
-    }
-
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
-    } else {
-      lineEnd = input.length;
-    }
-
-    const line = input.slice(lineStart, lineEnd);
-
-    const cursor = i - lineStart;
-
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
-  }
-}