« 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/html.js784
-rw-r--r--src/util/link.js168
-rw-r--r--src/util/replacer.js11
-rw-r--r--src/util/sugar.js106
-rw-r--r--src/util/transform-content.js3
5 files changed, 920 insertions, 152 deletions
diff --git a/src/util/html.js b/src/util/html.js
index 1c55fb8c..b5930d06 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,5 +1,8 @@
 // Some really simple functions for formatting HTML content.
 
+import * as commonValidators from '../data/things/validators.js';
+import {empty} from './sugar.js';
+
 // COMPREHENSIVE!
 // https://html.spec.whatwg.org/multipage/syntax.html#void-elements
 export const selfClosingTags = [
@@ -38,128 +41,731 @@ export const joinChildren = Symbol();
 // or when there are multiple children.
 export const noEdgeWhitespace = Symbol();
 
-export function tag(tagName, ...args) {
-  const selfClosing = selfClosingTags.includes(tagName);
+// Note: This is only guaranteed to return true for blanks (as returned by
+// html.blank()) and false for Tags and Templates (regardless of contents or
+// other properties). Don't depend on this to match any other values.
+export function isBlank(value) {
+  if (isTag(value)) {
+    return false;
+  }
+
+  if (isTemplate(value)) {
+    return false;
+  }
+
+  if (!Array.isArray(value)) {
+    return false;
+  }
+
+  return value.length === 0;
+}
+
+export function isTag(value) {
+  return value instanceof Tag;
+}
+
+export function isTemplate(value) {
+  return value instanceof Template;
+}
+
+export function isHTML(value) {
+  if (typeof value === 'string') {
+    return true;
+  }
+
+  if (value === null || value === undefined || value === false) {
+    return true;
+  }
+
+  if (isBlank(value) || isTag(value) || isTemplate(value)) {
+    return true;
+  }
+
+  if (Array.isArray(value)) {
+    if (value.every(isHTML)) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+export function isAttributes(value) {
+  if (typeof value !== 'object' || Array.isArray(value)) {
+    return false;
+  }
+
+  if (value === null) {
+    return false;
+  }
+
+  if (isTag(value) || isTemplate(value)) {
+    return false;
+  }
+
+  // TODO: Validate attribute values (just the general shape)
 
-  let openTag;
+  return true;
+}
+
+export const validators = {
+  // TODO: Move above implementations here and detail errors
+
+  isBlank(value) {
+    if (!isBlank(value)) {
+      throw new TypeError(`Expected html.blank()`);
+    }
+
+    return true;
+  },
+
+  isTag(value) {
+    if (!isTag(value)) {
+      throw new TypeError(`Expected HTML tag`);
+    }
+
+    return true;
+  },
+
+  isTemplate(value) {
+    if (!isTemplate(value)) {
+      throw new TypeError(`Expected HTML template`);
+    }
+
+    return true;
+  },
+
+  isHTML(value) {
+    if (!isHTML(value)) {
+      throw new TypeError(`Expected HTML content`);
+    }
+
+    return true;
+  },
+
+  isAttributes(value) {
+    if (!isAttributes(value)) {
+      throw new TypeError(`Expected HTML attributes`);
+    }
+
+    return true;
+  },
+};
+
+export function blank() {
+  return [];
+}
+
+export function tag(tagName, ...args) {
   let content;
-  let attrs;
+  let attributes;
 
-  if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-    attrs = args[0];
+  if (
+    typeof args[0] === 'object' &&
+    !(Array.isArray(args[0]) ||
+      args[0] instanceof Tag ||
+      args[0] instanceof Template)
+  ) {
+    attributes = args[0];
     content = args[1];
   } else {
     content = args[0];
   }
 
-  if (selfClosing && content) {
-    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  return new Tag(tagName, attributes, content);
+}
+
+export function tags(content) {
+  return new Tag(null, null, content);
+}
+
+export class Tag {
+  #tagName = '';
+  #content = null;
+  #attributes = null;
+
+  constructor(tagName, attributes, content) {
+    this.tagName = tagName;
+    this.attributes = attributes;
+    this.content = content;
+  }
+
+  clone() {
+    return new Tag(this.tagName, this.attributes, this.content);
   }
 
-  if (Array.isArray(content)) {
-    if (content.some(item => Array.isArray(item))) {
-      throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`);
+  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) {
+    if (
+      this.selfClosing &&
+      !(value === null ||
+        value === undefined ||
+        !Boolean(value) ||
+        Array.isArray(value) && value.filter(Boolean).length === 0)
+    ) {
+      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
+    }
+
+    let contentArray;
+
+    if (Array.isArray(value)) {
+      contentArray = value;
+    } else {
+      contentArray = [value];
+    }
+
+    this.#content = contentArray
+      .flat(Infinity)
+      .filter(Boolean);
+
+    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;
+    }
+  }
+
+  #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 joinChildren(value) {
+    this.#setAttributeString(joinChildren, value);
+  }
+
+  get joinChildren() {
+    return this.#getAttributeString(joinChildren);
+  }
+
+  set noEdgeWhitespace(value) {
+    this.#setAttributeFlag(noEdgeWhitespace, value);
+  }
+
+  get noEdgeWhitespace() {
+    return this.#getAttributeFlag(noEdgeWhitespace);
+  }
+
+  toString() {
+    const attributesString = this.attributes.toString();
+    const contentString = this.content.toString();
+
+    if (this.onlyIfContent && !contentString) {
+      return '';
     }
 
-    const joiner = attrs?.[joinChildren];
-    content = content.filter(Boolean).join(
-      (joiner === ''
+    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
         ? ''
-        : (joiner
-            ? `\n${joiner}\n`
-            : '\n')));
+        : '\n'));
   }
 
-  if (attrs?.[onlyIfContent] && !content) {
-    return '';
+  #stringifyContent() {
+    if (this.selfClosing) {
+      return '';
+    }
+
+    const joiner =
+      (this.joinChildren === undefined
+        ? '\n'
+        : (this.joinChildren === ''
+            ? ''
+            : `\n${this.joinChildren}\n`));
+
+    return this.content
+      .map(item => item.toString())
+      .filter(Boolean)
+      .join(joiner);
   }
+}
+
+export class Attributes {
+  #attributes = Object.create(null);
 
-  if (attrs) {
-    const attrString = attributes(attrs);
-    if (attrString) {
-      openTag = `${tagName} ${attrString}`;
+  constructor(attributes) {
+    this.attributes = attributes;
+  }
+
+  set attributes(value) {
+    if (value === undefined || value === null) {
+      this.#attributes = {};
+      return;
+    }
+
+    if (typeof value !== 'object') {
+      throw new Error(`Expected attributes to be an object`);
     }
+
+    this.#attributes = Object.create(null);
+    Object.assign(this.#attributes, value);
   }
 
-  if (!openTag) {
-    openTag = tagName;
+  get attributes() {
+    return this.#attributes;
   }
 
-  if (content) {
-    if (content.includes('\n')) {
-      return [
-        `<${openTag}>`,
-        content
-          .split('\n')
-          .map((line, i) =>
-            (i === 0 && attrs?.[noEdgeWhitespace]
-              ? line
-              : '    ' + line))
-          .join('\n'),
-        `</${tagName}>`,
-      ].join(
-        (attrs?.[noEdgeWhitespace]
-          ? ''
-          : '\n'));
+  set(attribute, value) {
+    if (value === null || value === undefined) {
+      this.remove(attribute);
     } else {
-      return `<${openTag}>${content}</${tagName}>`;
+      this.#attributes[attribute] = value;
     }
-  } else if (selfClosing) {
-    return `<${openTag}>`;
-  } else {
-    return `<${openTag}></${tagName}>`;
+    return value;
+  }
+
+  get(attribute) {
+    return this.#attributes[attribute];
+  }
+
+  remove(attribute) {
+    return delete this.#attributes[attribute];
+  }
+
+  toString() {
+    return Object.entries(this.attributes)
+      .map(([key, val]) => {
+        if (typeof val === 'undefined' || val === null)
+          return [key, val, false];
+        else if (typeof val === 'string')
+          return [key, val, true];
+        else if (typeof val === 'boolean')
+          return [key, val, val];
+        else if (typeof val === 'number')
+          return [key, val.toString(), true];
+        else if (Array.isArray(val))
+          return [key, val.filter(Boolean).join(' '), val.length > 0];
+        else
+          throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
+      })
+      .filter(([_key, _val, keep]) => keep)
+      .map(([key, val]) => {
+        switch (key) {
+          case 'href':
+            return [key, encodeURI(val)];
+          default:
+            return [key, val];
+        }
+      })
+      .map(([key, val]) =>
+        typeof val === 'boolean'
+          ? `${key}`
+          : `${key}="${this.#escapeAttributeValue(val)}"`
+      )
+      .join(' ');
+  }
+
+  #escapeAttributeValue(value) {
+    return value
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&apos;');
   }
 }
 
-export function escapeAttributeValue(value) {
-  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+export function template(description) {
+  return new Template(description);
 }
 
-export function attributes(attribs) {
-  return Object.entries(attribs)
-    .map(([key, val]) => {
-      if (typeof val === 'undefined' || val === null)
-        return [key, val, false];
-      else if (typeof val === 'string')
-        return [key, val, true];
-      else if (typeof val === 'boolean')
-        return [key, val, val];
-      else if (typeof val === 'number')
-        return [key, val.toString(), true];
-      else if (Array.isArray(val))
-        return [key, val.filter(Boolean).join(' '), val.length > 0];
-      else
-        throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-    })
-    .filter(([_key, _val, keep]) => keep)
-    .map(([key, val]) => {
-      switch (key) {
-        case 'href':
-          return [key, encodeURI(val)];
-        default:
-          return [key, val];
+export class Template {
+  #description = {};
+  #slotValues = {};
+
+  constructor(description) {
+    Template.validateDescription(description);
+    this.#description = description;
+  }
+
+  clone() {
+    const clone = new Template(this.#description);
+    clone.setSlots(this.#slotValues);
+    return clone;
+  }
+
+  static validateDescription(description) {
+    if (typeof description !== 'object') {
+      throw new TypeError(`Expected object, got ${typeof 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`));
       }
-    })
-    .map(([key, val]) =>
-      typeof val === 'boolean'
-        ? `${key}`
-        : `${key}="${escapeAttributeValue(val)}"`
-    )
-    .join(' ');
-}
+    }
+
+    if ('slots' in description) validateSlots: {
+      if (typeof description.slots !== 'object') {
+        topErrors.push(new TypeError(`Expected description.slots to be object`));
+        break validateSlots;
+      }
+
+      const slotErrors = [];
 
-// Ensures the passed value is an array of elements, for usage in [...spread]
-// syntax. This may be used when it's not guaranteed whether the return value of
-// an external function is one child or an array, or in combination with
-// conditionals, e.g. fragment(cond && [x, y, z]).
-export function fragment(childOrChildren) {
-  if (!childOrChildren) {
-    return [];
+      for (const [slotName, slotDescription] of Object.entries(description.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',
+          ];
+
+          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 (!acceptableSlotTypes.includes(slotDescription.type)) {
+            slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
+          }
+        }
+      }
+
+      if (!empty(slotErrors)) {
+        topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`));
+      }
+    }
+
+    if (!empty(topErrors)) {
+      throw new AggregateError(topErrors,
+        (typeof description.annotation === 'string'
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
+
+    return true;
   }
 
-  if (Array.isArray(childOrChildren)) {
-    return childOrChildren;
+  slot(slotName, value) {
+    this.setSlot(slotName, value);
+    return this;
   }
 
-  return [childOrChildren];
+  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) {
+      if ('validate' in description) {
+        description.validate({
+          ...commonValidators,
+          ...validators,
+        })(value);
+      }
+
+      if ('type' in description) {
+        const {type} = description;
+        if (type === 'html') {
+          if (!isHTML(value)) {
+            throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`);
+          }
+        } else {
+          if (typeof value !== type) {
+            throw new TypeError(`Slot expects ${type}, got ${typeof value}`);
+          }
+        }
+      }
+    }
+
+    return true;
+  }
+
+  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) {
+        return providedValue.clone();
+      }
+
+      return providedValue;
+    }
+
+    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;
+  }
+
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
+  }
+
+  get content() {
+    const slots = {};
+
+    for (const slotName of Object.keys(this.description.slots ?? {})) {
+      slots[slotName] = this.getSlotValue(slotName);
+    }
+
+    return this.description.content(slots);
+  }
+
+  set description(_value) {
+    throw new Error(`Template description can't be changed after constructed`);
+  }
+
+  get description() {
+    return this.#description;
+  }
+
+  toString() {
+    return this.content.toString();
+  }
 }
diff --git a/src/util/link.js b/src/util/link.js
index 62106345..a9f79c8b 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -24,23 +24,29 @@ export function unbound_getLinkThemeString(color, {
 
 const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/;
 
-const linkHelper =
-  (hrefFn, {
-    color = true,
-    attr = null,
-  } = {}) =>
-  (thing, {
+function linkHelper({
+  path: pathOption,
+
+  expectThing = true,
+  color: colorOption = true,
+
+  attr: attrOption = null,
+  data: dataOption = null,
+  text: textOption = null,
+}) {
+  const generateLink = (data, {
     getLinkThemeString,
     to,
 
     text = '',
     attributes = null,
     class: className = '',
-    color: color2 = true,
+    color = true,
     hash = '',
     preferShortName = false,
   }) => {
-    let href = hrefFn(thing, {to});
+    const path = (expectThing ? pathOption(data) : pathOption());
+    let href = to(...path);
 
     if (link.globalOptions.appendIndexHTML) {
       if (appendIndexHTMLRegex.test(href)) {
@@ -52,41 +58,100 @@ const linkHelper =
       href += (hash.startsWith('#') ? '' : '#') + hash;
     }
 
-    return html.tag(
-      'a',
+    return html.tag('a',
       {
-        ...(attr ? attr(thing) : {}),
+        ...(attrOption ? attrOption(data) : {}),
         ...(attributes ? attributes : {}),
         href,
         style:
-          typeof color2 === 'string'
-            ? getLinkThemeString(color2)
-            : color2 && color
-            ? getLinkThemeString(thing.color)
+          typeof color === 'string'
+            ? getLinkThemeString(color)
+            : color && colorOption
+            ? getLinkThemeString(data.color)
             : '',
         class: className,
       },
+
       (text ||
-        (preferShortName
-          ? thing.nameShort ?? thing.name
-          : thing.name))
-    );
+        (textOption
+          ? textOption(data)
+          : (preferShortName
+              ? data.nameShort ?? data.name
+              : data.name))));
+  };
+
+  generateLink.data = thing => {
+    if (!expectThing) {
+      throw new Error(`This kind of link doesn't need any data serialized`);
+    }
+
+    const data = (dataOption ? dataOption(thing) : {});
+
+    if (colorOption) {
+      data.color = thing.color;
+    }
+
+    if (!textOption) {
+      data.name = thing.name;
+      data.nameShort = thing.nameShort ?? thing.name;
+    }
+
+    return data;
   };
 
-const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-  linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-    attr: (thing) => ({
-      ...(attr ? attr(thing) : {}),
-      ...(expose ? {[expose]: thing.directory} : {}),
+  return generateLink;
+}
+
+function linkDirectory(key, {
+  exposeDirectory = null,
+  prependLocalized = true,
+
+  data = null,
+  attr = null,
+  ...conf
+} = {}) {
+  return linkHelper({
+    data: thing => ({
+      ...(data ? data(thing) : {}),
+      directory: thing.directory,
     }),
+
+    path: data =>
+      (prependLocalized
+        ? ['localized.' + key, data.directory]
+        : [key, data.directory]),
+
+    attr: (data) => ({
+      ...(attr ? attr(data) : {}),
+      ...(exposeDirectory ? {[exposeDirectory]: data.directory} : {}),
+    }),
+
     ...conf,
   });
+}
 
-const linkPathname = (key, conf) =>
-  linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
+function linkIndex(key, conf) {
+  return linkHelper({
+    path: () => [key],
 
-const linkIndex = (key, conf) =>
-  linkHelper((_, {to}) => to('localized.' + key), conf);
+    expectThing: false,
+    ...conf,
+  });
+}
+
+function linkAdditionalFile(key, conf) {
+  return linkHelper({
+    data: ({file, album}) => ({
+      directory: album.directory,
+      file,
+    }),
+
+    path: data => ['media.albumAdditionalFile', data.directory, data.file],
+
+    color: false,
+    ...conf,
+  });
+}
 
 // Mapping of Thing constructor classes to the key for a link.x() function.
 // These represent a sensible "default" link, i.e. to the primary page for
@@ -114,6 +179,7 @@ const link = {
   },
 
   album: linkDirectory('album'),
+  albumAdditionalFile: linkAdditionalFile('albumAdditionalFile'),
   albumGallery: linkDirectory('albumGallery'),
   albumCommentary: linkDirectory('albumCommentary'),
   artist: linkDirectory('artist', {color: false}),
@@ -130,32 +196,26 @@ const link = {
   newsEntry: linkDirectory('newsEntry', {color: false}),
   staticPage: linkDirectory('staticPage', {color: false}),
   tag: linkDirectory('tag'),
-  track: linkDirectory('track', {expose: 'data-track'}),
-
-  // TODO: This is a bit hacky. Files are just strings (not objects), so we
-  // have to manually provide the album alongside the file. They also don't
-  // follow the usual {name: whatever} type shape, so we have to provide that
-  // ourselves.
-  _albumAdditionalFileHelper: linkHelper(
-    (fakeFileObject, {to}) =>
-      to(
-        'media.albumAdditionalFile',
-        fakeFileObject.album.directory,
-        fakeFileObject.name),
-    {color: false}),
-
-  albumAdditionalFile: ({file, album}, {to, ...opts}) =>
-    link._albumAdditionalFileHelper(
-      {
-        name: file,
-        album,
-      },
-      {to, ...opts}),
-
-  media: linkPathname('media.path', {color: false}),
-  root: linkPathname('shared.path', {color: false}),
-  data: linkPathname('data.path', {color: false}),
-  site: linkPathname('localized.path', {color: false}),
+  track: linkDirectory('track', {exposeDirectory: 'data-track'}),
+
+  media: linkDirectory('media.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  root: linkDirectory('shared.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+  data: linkDirectory('data.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  site: linkDirectory('localized.path', {
+    prependLocalized: false,
+    color: false,
+  }),
 
   // This is NOT an arrow functions because it should be callable for other
   // "this" objects - i.e, if we bind arguments in other functions on the same
diff --git a/src/util/replacer.js b/src/util/replacer.js
index ea957eda..50a90004 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -221,11 +221,10 @@ function parseNodes(input, i, stopAt, textOnly) {
       let hash;
 
       if (stop_literal === tagHash) {
-        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
         if (!stopped) throw endOfInput(i, `reading hash`);
-
-        if (!N) throw makeError(i, `Expected content (hash).`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
 
         hash = N;
         i = stop_iParse;
@@ -294,6 +293,10 @@ function parseNodes(input, i, stopAt, textOnly) {
 }
 
 export function parseInput(input) {
+  if (typeof input !== 'string') {
+    throw new TypeError(`Expected input to be string, got ${input}`);
+  }
+
   try {
     return parseNodes(input, 0);
   } catch (errorNode) {
@@ -378,7 +381,7 @@ function evaluateTag(node, opts) {
     (transformName && transformName(value.name, node, input)) ||
     null;
 
-  const hash = node.data.hash && transformNodes(node.data.hash, opts);
+  const hash = node.data.hash && transformNode(node.data.hash, opts);
 
   const args =
     node.data.args &&
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 0813c1d4..6ab70bc6 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -82,6 +82,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
 export const withEntries = (obj, fn) =>
   Object.fromEntries(fn(Object.entries(obj)));
 
+export function filterProperties(obj, properties) {
+  const set = new Set(properties);
+  return Object.fromEntries(
+    Object
+      .entries(obj)
+      .filter(([key]) => set.has(key)));
+}
+
 export function queue(array, max = 50) {
   if (max === 0) {
     return array.map((fn) => fn());
@@ -146,10 +154,20 @@ export function bindOpts(fn, bind) {
     ]);
   };
 
-  Object.defineProperty(bound, 'name', {
-    value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`,
+  annotateFunction(bound, {
+    name: fn,
+    trait: 'options-bound',
   });
 
+  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
+    if (key === 'length') continue;
+    if (key === 'name') continue;
+    if (key === 'arguments') continue;
+    if (key === 'caller') continue;
+    if (key === 'prototype') continue;
+    Object.defineProperty(bound, key, descriptor);
+  }
+
   return bound;
 }
 
@@ -216,6 +234,10 @@ export function openAggregate({
       );
     };
 
+  aggregate.push = (error) => {
+    errors.push(error);
+  };
+
   aggregate.call = (fn, ...args) => {
     return aggregate.wrap(fn)(...args);
   };
@@ -421,6 +443,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 export function showAggregate(topError, {
   pathToFileURL = f => f,
   showTraces = true,
+  print = true,
 } = {}) {
   const recursive = (error, {level}) => {
     let header = showTraces
@@ -465,7 +488,13 @@ export function showAggregate(topError, {
     }
   };
 
-  console.error(recursive(topError, {level: 0}));
+  const message = recursive(topError, {level: 0});
+
+  if (print) {
+    console.error(message);
+  } else {
+    return message;
+  }
 }
 
 export function decorateErrorWithIndex(fn) {
@@ -478,3 +507,74 @@ export function decorateErrorWithIndex(fn) {
     }
   };
 }
+
+// Delicious function annotations, such as:
+//
+//   (*bound) soWeAreBackInTheMine
+//   (data *unfulfilled) generateShrekTwo
+//
+export function annotateFunction(fn, {
+  name: nameOrFunction = null,
+  description: newDescription,
+  trait: newTrait,
+}) {
+  let name;
+
+  if (typeof nameOrFunction === 'function') {
+    name = nameOrFunction.name;
+  } else if (typeof nameOrFunction === 'string') {
+    name = nameOrFunction;
+  }
+
+  name ??= fn.name ?? 'anonymous';
+
+  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
+
+  let prefix, suffix, description, trait;
+  if (match) {
+    ({prefix, suffix, description, trait} = match.groups);
+  }
+
+  prefix ??= '';
+  suffix ??= name;
+  description ??= '';
+  trait ??= '';
+
+  if (newDescription) {
+    if (description) {
+      description += '; ' + newDescription;
+    } else {
+      description = newDescription;
+    }
+  }
+
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
+    } else {
+      trait = '#' + newTrait;
+    }
+  }
+
+  let parenthesesPart;
+
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
+  } else {
+    parenthesesPart = '';
+  }
+
+  let finalName;
+
+  if (prefix && parenthesesPart) {
+    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
+  } else if (parenthesesPart) {
+    finalName = `(${parenthesesPart}) ${suffix}`;
+  } else {
+    finalName = suffix;
+  }
+
+  Object.defineProperty(fn, 'name', {value: finalName});
+}
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
index d1d0f51a..454cb374 100644
--- a/src/util/transform-content.js
+++ b/src/util/transform-content.js
@@ -3,7 +3,6 @@
 // interfaces for converting various content found in wiki data to HTML for
 // display on the site.
 
-import * as html from './html.js';
 export {transformInline} from './replacer.js';
 
 export const replacerSpec = {
@@ -34,7 +33,7 @@ export const replacerSpec = {
   date: {
     find: null,
     value: (ref) => new Date(ref),
-    html: (date, {language}) =>
+    html: (date, {html, language}) =>
       html.tag('time',
         {datetime: date.toString()},
         language.formatDate(date)),