« 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/aggregate.js108
-rw-r--r--src/util/cli.js49
-rw-r--r--src/util/external-links.js681
-rw-r--r--src/util/html.js100
-rw-r--r--src/util/replacer.js87
-rw-r--r--src/util/serialize.js6
6 files changed, 750 insertions, 281 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
index c5d4198..3ad8bdb 100644
--- a/src/util/aggregate.js
+++ b/src/util/aggregate.js
@@ -91,6 +91,46 @@ export function openAggregate({
     return aggregate.callAsync(() => withAggregateAsync(...args));
   };
 
+  aggregate.receive = (results) => {
+    if (!Array.isArray(results)) {
+      if (typeof results === 'object' && results.aggregate) {
+        const {aggregate, result} = results;
+
+        try {
+          aggregate.close();
+        } catch (error) {
+          errors.push(error);
+        }
+
+        return result;
+      }
+
+      throw new Error(`Expected an array or {aggregate, result} object`);
+    }
+
+    return results.map(({aggregate, result}) => {
+      if (!aggregate) {
+        console.log('nope:', results);
+        throw new Error(`Expected an array of {aggregate, result} objects`);
+      }
+
+      try {
+        aggregate.close();
+      } catch (error) {
+        errors.push(error);
+      }
+
+      return result;
+    });
+  };
+
+  aggregate.contain = (results) => {
+    return {
+      aggregate,
+      result: aggregate.receive(results),
+    };
+  };
+
   aggregate.map = (...args) => {
     const parent = aggregate;
     const {result, aggregate: child} = mapAggregate(...args);
@@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
-// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
-// in aggregate utilities.
-function _reorganizeAggregateArguments(arg1, arg2) {
-  if (typeof arg1 === 'function') {
-    return {fn: arg1, opts: arg2 ?? {}};
-  } else if (typeof arg2 === 'function') {
-    return {fn: arg2, opts: arg1 ?? {}};
+// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate
+// utilities (or other shapes besides functions).
+function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') {
+  if (desire(arg1)) {
+    return [arg1, arg2 ?? {}];
+  } else if (desire(arg2)) {
+    return [arg2, arg1];
   } else {
-    throw new Error(`Expected a function`);
+    return [undefined, undefined];
   }
 }
 
+// Takes a list of {aggregate, result} objects, puts all the aggregates into
+// a new aggregate, and puts all the results into an array, returning both on
+// a new {aggregate, result} object. This is essentailly the generalized
+// composable version of functions like mapAggregate or filterAggregate.
+export function receiveAggregate(arg1, arg2) {
+  const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray);
+  if (!array) {
+    throw new Error(`Expected an array`);
+  }
+
+  const aggregate = openAggregate(opts);
+  const result = aggregate.receive(array);
+  return {aggregate, result};
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _mapAggregate('sync', null, array, fn, opts);
 }
 
 export function mapAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _filterAggregate('sync', null, array, fn, opts);
 }
 
 export async function filterAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('sync', opts, fn);
 }
 
 export function withAggregateAsync(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('async', opts, fn);
 }
 
@@ -294,6 +373,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /aggregate/,
   /node:/,
   /<anonymous>/,
 ];
diff --git a/src/util/cli.js b/src/util/cli.js
index 973fef1..ce513f0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -215,12 +215,30 @@ export function decorateTime(arg1, arg2) {
     timeSpent: 0,
     timesCalled: 0,
     displayTime() {
-      const averageTime = meta.timeSpent / meta.timesCalled;
+      const align1 = 48;
+      const align2 = 22;
+
+      const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1);
+      const idPart = typeof id === 'symbol' ? id.description : id;
+      const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`;
+      const avgPart = `(avg: ${averageTime} ms)`;
+
+      const alignPart1 =
+        (idPart.length >= align1
+          ? ' '
+          : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' ');
+
+      const alignPart2 =
+        (timePart.length >= align2
+          ? ' '
+          : ' '.repeat(Math.max(0, align2 - timePart.length)));
+
       console.log(
-        `\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${
-          meta.timeSpent
-        } ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`
-      );
+        colors.bright(idPart) +
+        alignPart1 +
+        timePart +
+        alignPart2 +
+        colors.dim(avgPart));
     },
   };
 
@@ -228,7 +246,7 @@ export function decorateTime(arg1, arg2) {
 
   const fn = function (...args) {
     const start = Date.now();
-    const ret = functionToBeWrapped(...args);
+    const ret = functionToBeWrapped.apply(this, args);
     const end = Date.now();
     meta.timeSpent += end - start;
     meta.timesCalled++;
@@ -250,11 +268,20 @@ decorateTime.displayTime = function () {
     ...Object.getOwnPropertyNames(map),
   ];
 
-  if (keys.length) {
-    console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-    for (const key of keys) {
-      map[key].displayTime();
-    }
+  if (!keys.length) {
+    return;
+  }
+
+  console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
+
+  const metas =
+    keys
+      .map(key => map[key])
+      .filter(meta => meta.timeSpent >= 1)  // Milliseconds!
+      .sort((a, b) => a.timeSpent - b.timeSpent);
+
+  for (const meta of metas) {
+    meta.displayTime();
   }
 };
 
diff --git a/src/util/external-links.js b/src/util/external-links.js
index 8ab8dec..a616efb 100644
--- a/src/util/external-links.js
+++ b/src/util/external-links.js
@@ -1,8 +1,10 @@
-import {empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays, withEntries} from '#sugar';
 
 import {
   anyOf,
   is,
+  isBoolean,
+  isObject,
   isStringNonEmpty,
   looseArrayOf,
   optional,
@@ -13,9 +15,8 @@ import {
 } from '#validators';
 
 export const externalLinkStyles = [
-  'normal',
-  'compact',
   'platform',
+  'handle',
   'icon-id',
 ];
 
@@ -86,25 +87,26 @@ export const isExternalLinkSpec =
       }),
 
       platform: isStringNonEmpty,
-      substring: optional(isStringNonEmpty),
-
-      // TODO: Don't allow 'handle' or 'custom' options if the corresponding
-      // properties aren't provided
-      normal: optional(is('domain', 'handle', 'custom')),
-      compact: optional(is('domain', 'handle', 'custom')),
-      icon: optional(isStringNonEmpty),
 
       handle: optional(isExternalLinkExtractSpec),
 
-      // TODO: This should validate each value with isExternalLinkExtractSpec.
-      custom: optional(validateAllPropertyValues(isExternalLinkExtractSpec)),
+      detail:
+        optional(anyOf(
+          isStringNonEmpty,
+          validateProperties({
+            [validateProperties.validateOtherKeys]:
+              isExternalLinkExtractSpec,
+
+            substring: isStringNonEmpty,
+          }))),
+
+      unusualDomain: optional(isBoolean),
+
+      icon: optional(isStringNonEmpty),
     }));
 
 export const fallbackDescriptor = {
   platform: 'external',
-
-  normal: 'domain',
-  compact: 'domain',
   icon: 'globe',
 };
 
@@ -120,7 +122,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'playlist',
+    detail: 'playlist',
 
     icon: 'youtube',
   },
@@ -133,7 +135,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'fullAlbum',
+    detail: 'fullAlbum',
 
     icon: 'youtube',
   },
@@ -145,43 +147,9 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'fullAlbum',
-
-    icon: 'youtube',
-  },
-
-  // Special handling for artist links
-
-  {
-    match: {
-      domain: 'patreon.com',
-      context: 'artist',
-    },
-
-    platform: 'patreon',
-
-    normal: 'handle',
-    compact: 'handle',
-    icon: 'globe',
-
-    handle: /([^/]*)\/?$/,
-  },
-
-  {
-    match: {
-      context: 'artist',
-      domain: 'youtube.com',
-    },
-
-    platform: 'youtube',
+    detail: 'fullAlbum',
 
-    normal: 'handle',
-    compact: 'handle',
     icon: 'youtube',
-
-    handle: {
-      pathname: /^(@.*?)\/?$/,
-    },
   },
 
   // Special handling for flash links
@@ -193,7 +161,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'bgreco',
-    substring: 'flash',
+    detail: 'flash',
 
     icon: 'globe',
   },
@@ -203,20 +171,16 @@ export const externalLinkSpec = [
     match: {
       context: 'flash',
       domain: 'homestuck.com',
-      pathname: /^story\/[0-9]+\/?$/,
     },
 
     platform: 'homestuck',
-    substring: 'page',
-
-    normal: 'custom',
-    icon: 'globe',
 
-    custom: {
-      page: {
-        pathname: /[0-9]+/,
-      },
+    detail: {
+      substring: 'page',
+      page: {pathname: /^story\/([0-9]+)\/?$/,},
     },
+
+    icon: 'globe',
   },
 
   {
@@ -227,7 +191,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'homestuck',
-    substring: 'secretPage',
+    detail: 'secretPage',
 
     icon: 'globe',
   },
@@ -239,7 +203,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'flash',
+    detail: 'flash',
 
     icon: 'youtube',
   },
@@ -247,12 +211,48 @@ export const externalLinkSpec = [
   // 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,
 
-    normal: 'domain',
-    compact: 'domain',
     icon: 'bandcamp',
   },
 
@@ -260,11 +260,36 @@ export const externalLinkSpec = [
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
+    handle: {domain: /^[^.]+/},
 
-    compact: 'handle',
     icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: 'bsky.app'},
+
+    platform: 'bluesky',
+    handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/},
+
+    icon: 'bluesky',
+  },
+
+  {
+    match: {domain: '.carrd.co'},
+
+    platform: 'carrd',
+    handle: {domain: /^[^.]+/},
 
-    handle: {domain: /^[^.]*/},
+    icon: 'carrd',
+  },
+
+  {
+    match: {domain: 'cohost.org'},
+
+    platform: 'cohost',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'cohost',
   },
 
   {
@@ -280,24 +305,60 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {domain: '.deviantart.com'},
+
+    platform: 'deviantart',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'deviantart',
+  },
+
+  {
     match: {domain: 'deviantart.com'},
+
     platform: 'deviantart',
+    handle: {pathname: /^([^/]+)\/?$/},
+
     icon: 'deviantart',
   },
 
   {
-    match: {
-      domain: 'mspaintadventures.fandom.com',
-      pathname: /^wiki\/(.+)\/?$/,
-    },
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
 
-    platform: 'fandom',
-    substring: 'mspaintadventures.page',
+  {
+    match: {domain: 'facebook.com'},
 
-    normal: 'custom',
-    icon: 'globe',
+    platform: 'facebook',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'facebook',
+  },
 
-    custom: {
+  {
+    match: {domain: 'facebook.com'},
+
+    platform: 'facebook',
+    handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/},
+
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'facebook.com'},
+    platform: 'facebook',
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'mspaintadventures.fandom.com'},
+
+    platform: 'fandom.mspaintadventures',
+
+    detail: {
+      substring: 'page',
       page: {
         pathname: /^wiki\/(.+)\/?$/,
         transform: [
@@ -306,52 +367,150 @@ export const externalLinkSpec = [
         ],
       },
     },
+
+    icon: 'globe',
   },
 
   {
     match: {domain: 'mspaintadventures.fandom.com'},
 
-    platform: 'fandom',
-    substring: 'mspaintadventures',
+    platform: 'fandom.mspaintadventures',
 
     icon: 'globe',
   },
 
   {
-    match: {domain: 'fandom.com'},
+    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: 'local',
+    platform: 'hsmusic',
     icon: 'globe',
   },
 
   {
     match: {domain: 'instagram.com'},
+
     platform: 'instagram',
+    handle: {pathname: /^([^/]+)\/?$/},
+
     icon: 'instagram',
   },
 
   {
-    match: {domains: ['types.pl']},
+    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,
 
-    normal: 'domain',
-    compact: 'domain',
     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',
@@ -359,8 +518,17 @@ export const externalLinkSpec = [
 
   {
     match: {domain: 'patreon.com'},
+
     platform: 'patreon',
-    icon: 'globe',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'patreon',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'patreon',
   },
 
   {
@@ -373,51 +541,111 @@ export const externalLinkSpec = [
     match: {domain: 'soundcloud.com'},
 
     platform: 'soundcloud',
+    handle: {pathname: /^([^/]+)\/?$/},
 
-    compact: 'handle',
     icon: 'soundcloud',
+  },
 
-    handle: /([^/]*)\/?$/,
+  {
+    match: {domain: 'soundcloud.com'},
+    platform: 'soundcloud',
+    icon: 'soundcloud',
   },
 
   {
-    match: {domain: 'spotify.com'},
+    match: {domains: ['spotify.com', 'open.spotify.com']},
     platform: 'spotify',
-    icon: 'globe',
+    icon: 'spotify',
+  },
+
+  {
+    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',
+  },
 
-    compact: 'handle',
+  {
+    match: {domain: 'tumblr.com'},
+    platform: 'tumblr',
     icon: 'tumblr',
+  },
 
-    handle: {domain: /^[^.]*/},
+  {
+    match: {domain: 'twitch.tv'},
+
+    platform: 'twitch',
+    handle: {pathname: /^(.+)\/?/},
+
+    icon: 'twitch',
   },
 
   {
     match: {domain: 'twitter.com'},
 
     platform: 'twitter',
+    handle: {pathname: /^@?([^/]+)\/?$/},
 
-    compact: 'handle',
     icon: 'twitter',
+  },
 
-    handle: {
-      prefix: '@',
-      pathname: /^@?([a-zA-Z0-9_]*)\/?$/,
-    },
+  {
+    match: {domain: 'twitter.com'},
+    platform: 'twitter',
+    icon: 'twitter',
   },
 
   {
-    match: {domain: 'wikipedia.org'},
+    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',
@@ -443,10 +671,30 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, {
 } = {}) {
   const {domain, pathname, query} = urlParts(url);
 
-  const compareDomain = string => domain.includes(string);
+  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
@@ -454,33 +702,55 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, {
 
   const matchingDescriptors =
     descriptors
-      .filter(({match}) => {
-        if (match.domain) return compareDomain(match.domain);
-        if (match.domains) return match.domains.some(compareDomain);
-        return false;
-      })
-      .filter(({match}) => {
-        if (Array.isArray(match.context))
-          return match.context.some(c => contextArray.includes(c));
-        if (match.context)
-          return contextArray.includes(match.context);
-        return true;
-      })
-      .filter(({match}) => {
-        if (match.pathname) return comparePathname(match.pathname);
-        if (match.pathnames) return match.pathnames.some(comparePathname);
-        return true;
-      })
-      .filter(({match}) => {
-        if (match.query) return compareQuery(match.query);
-        if (match.queries) return match.quieries.some(compareQuery);
-        return true;
-      });
+      .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) {
+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 = [];
@@ -556,15 +826,23 @@ export function extractPartFromExternalLink(url, extract) {
   })) {
     const match = test.match(regex);
     if (match) {
-      value = prefix + (match[1] ?? match[0]);
+      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);
   }
@@ -587,134 +865,77 @@ export function extractAllCustomPartsFromExternalLink(url, custom) {
 export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
   const prefix = 'misc.external';
 
-  function getPlatform() {
-    return language.$(prefix, descriptor.platform);
-  }
-
-  function getDomain() {
-    return urlParts(url).domain;
-  }
-
-  function getCustom() {
-    if (!descriptor.custom) {
+  function getDetail() {
+    if (!descriptor.detail) {
       return null;
     }
 
-    const customParts =
-      extractAllCustomPartsFromExternalLink(url, descriptor.custom);
-
-    if (!customParts) {
-      return null;
-    }
+    if (typeof descriptor.detail === 'string') {
+      return language.$(prefix, descriptor.platform, descriptor.detail);
+    } else {
+      const {substring, ...rest} = descriptor.detail;
 
-    return language.$(prefix, descriptor.platform, descriptor.substring, customParts);
-  }
+      const opts =
+        withEntries(rest, entries => entries
+          .map(([key, value]) => [
+            key,
+            extractPartFromExternalLink(url, value),
+          ]));
 
-  function getHandle() {
-    if (!descriptor.handle) {
-      return null;
+      return language.$(prefix, descriptor.platform, substring, opts);
     }
-
-    return extractPartFromExternalLink(url, descriptor.handle);
   }
 
-  function getNormal() {
-    if (descriptor.custom) {
-      if (descriptor.normal === 'custom') {
-        return getCustom();
+  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 null;
+        return platform;
       }
     }
 
-    if (descriptor.normal === 'domain') {
-      const platform = getPlatform();
-      const domain = getDomain();
-
-      if (!platform || !domain) {
-        return null;
-      }
-
-      return language.$(prefix, 'withDomain', {platform, domain});
-    }
-
-    if (descriptor.normal === 'handle') {
-      const platform = getPlatform();
-      const handle = getHandle();
-
-      if (!platform || !handle) {
-        return null;
-      }
-
-      return language.$(prefix, 'withHandle', {platform, handle});
-    }
-
-    return language.$(prefix, descriptor.platform, descriptor.substring);
-  }
-
-  function getCompact() {
-    if (descriptor.custom) {
-      if (descriptor.compact === 'custom') {
-        return getCustom();
+    case 'handle': {
+      if (descriptor.handle) {
+        return extractPartFromExternalLink(url, descriptor.handle);
       } else {
         return null;
       }
     }
 
-    if (descriptor.compact === 'domain') {
-      const domain = getDomain();
-
-      if (!domain) {
+    case 'icon-id': {
+      if (descriptor.icon) {
+        return descriptor.icon;
+      } else {
         return null;
       }
-
-      return language.sanitize(domain.replace(/^www\./, ''));
     }
-
-    if (descriptor.compact === 'handle') {
-      const handle = getHandle();
-
-      if (!handle) {
-        return null;
-      }
-
-      return language.sanitize(handle);
-    }
-  }
-
-  function getIconId() {
-    return descriptor.icon ?? null;
-  }
-
-  switch (style) {
-    case 'normal': return getNormal();
-    case 'compact': return getCompact();
-    case 'platform': return getPlatform();
-    case 'icon-id': return getIconId();
   }
 }
 
 export function couldDescriptorSupportStyle(descriptor, style) {
-  if (style === 'normal') {
-    if (descriptor.custom) {
-      return descriptor.normal === 'custom';
-    } else {
-      return true;
-    }
-  }
-
-  if (style === 'compact') {
-    if (descriptor.custom) {
-      return descriptor.compact === 'custom';
-    } else {
-      return !!descriptor.compact;
-    }
-  }
-
   if (style === 'platform') {
     return true;
   }
 
+  if (style === 'handle') {
+    return !!descriptor.handle;
+  }
+
   if (style === 'icon-id') {
     return !!descriptor.icon;
   }
@@ -744,15 +965,13 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript
 }
 
 export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
-  const getStyle = style =>
-    getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
-
-  return {
-    'normal': getStyle('normal'),
-    'compact': getStyle('compact'),
-    'platform': getStyle('platform'),
-    'icon-id': getStyle('icon-id'),
-  };
+  return (
+    Object.fromEntries(
+      externalLinkStyles.map(style =>
+        getExternalLinkStringOfStyleFromDescriptor(
+          url,
+          style,
+          descriptor, {language}))));
 }
 
 export function getExternalLinkStringsFromDescriptors(url, descriptors, {
diff --git a/src/util/html.js b/src/util/html.js
index b8dea51..9e07f9b 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -73,6 +73,15 @@ export const joinChildren = Symbol();
 // 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
@@ -373,13 +382,12 @@ export class Tag {
       throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
     }
 
-    let contentArray;
-
-    if (Array.isArray(value)) {
-      contentArray = value;
-    } else {
-      contentArray = [value];
-    }
+    const contentArray =
+      (Array.isArray(value)
+        ? value.flat(Infinity).filter(Boolean)
+     : value
+        ? [value]
+        : []);
 
     if (this.chunkwrap) {
       if (contentArray.some(content => content?.blockwrap)) {
@@ -387,10 +395,7 @@ export class Tag {
       }
     }
 
-    this.#content = contentArray
-      .flat(Infinity)
-      .filter(Boolean);
-
+    this.#content = contentArray;
     this.#content.toString = () => this.#stringifyContent();
   }
 
@@ -697,7 +702,7 @@ export class Tag {
             if (index === 0) {
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/);
+              const whitespace = chunk.match(/^\s+/) ?? '';
               content += chunkwrapSplitter;
               content += '</span>';
               content += whitespace;
@@ -994,6 +999,12 @@ export class Attributes {
     }
   }
 
+  with(...args) {
+    const clone = this.clone();
+    clone.add(...args);
+    return clone;
+  }
+
   #addMultipleAttributes(attributes) {
     const flatInputAttributes =
       [attributes].flat(Infinity).filter(Boolean);
@@ -1007,6 +1018,8 @@ export class Attributes {
       const setResults = {};
 
       for (const key of Reflect.ownKeys(set)) {
+        if (key === blessAttributes) continue;
+
         const value = set[key];
         setResults[key] = this.#addOneAttribute(key, value);
       }
@@ -1088,8 +1101,17 @@ export class Attributes {
     return this.#attributes[attribute];
   }
 
-  has(attribute) {
-    return attribute in this.#attributes;
+  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) {
@@ -1325,6 +1347,22 @@ export function smush(smushee) {
   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);
 }
@@ -1546,14 +1584,16 @@ export class Template {
       return true;
     }
 
-    if ('validate' in description) {
+    if (Object.hasOwn(description, 'validate')) {
       description.validate({
         ...commonValidators,
         ...validators,
       })(value);
+
+      return true;
     }
 
-    if ('type' in description) {
+    if (Object.hasOwn(description, 'type')) {
       switch (description.type) {
         case 'html': {
           return isHTML(value);
@@ -1564,14 +1604,14 @@ export class Template {
         }
 
         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;
 
-          if (typeof value !== 'string')
-            throw new TypeError(`Slot expects string, got ${typeof value}`);
-
           return true;
         }
 
@@ -1815,9 +1855,29 @@ export const isAttributesAdditionPair = pair => {
   return true;
 };
 
-export const isAttributesAdditionSinglet =
+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/replacer.js b/src/util/replacer.js
index 0a3117e..d1b0a26 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -5,6 +5,8 @@
 // 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';
 
@@ -460,7 +462,14 @@ export function postprocessImages(inputNodes) {
       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});
+
+        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'};
@@ -532,6 +541,8 @@ export function postprocessImages(inputNodes) {
         outputNodes.push({
           type: 'text',
           data: node.data.slice(parseFrom),
+          i: node.i + parseFrom,
+          iEnd: node.iEnd,
         });
       }
 
@@ -574,7 +585,78 @@ export function postprocessHeadings(inputNodes) {
       textContent += node.data.slice(parseFrom);
     }
 
-    outputNodes.push({type: 'text', data: textContent});
+    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;
@@ -589,6 +671,7 @@ export function parseInput(input) {
     let output = parseNodes(input, 0);
     output = postprocessImages(output);
     output = postprocessHeadings(output);
+    output = postprocessExternalLinks(output);
     return output;
   } catch (errorNode) {
     if (errorNode.type !== 'error') {
diff --git a/src/util/serialize.js b/src/util/serialize.js
index 4992e2b..eb18a75 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -14,10 +14,10 @@ export function serializeLink(thing) {
 }
 
 export function serializeContribs(contribs) {
-  return contribs.map(({who, what}) => {
+  return contribs.map(({artist, annotation}) => {
     const ret = {};
-    ret.artist = serializeLink(who);
-    if (what) ret.contribution = what;
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
     return ret;
   });
 }