« 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/html.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/util/html.js')
-rw-r--r--src/util/html.js1859
1 files changed, 1781 insertions, 78 deletions
diff --git a/src/util/html.js b/src/util/html.js
index a6b0d62..d1d509e 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,4 +1,25 @@
-// Some really simple functions for formatting HTML content.
+// 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
@@ -18,6 +39,20 @@ export const selfClosingTags = [
   '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 string 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.
@@ -38,118 +73,1786 @@ 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
+// 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();
+
+// 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.
+
+  const arrayContent = [];
+  const templateContent = [];
+
+  for (const item of nonStringContent) {
+    if (item instanceof Tag) {
+      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) {
+      if (!template.blank) {
+        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 selfClosing = selfClosingTags.includes(tagName);
+  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);
+}
 
-  let openTag;
+export function metatag(identifier, ...args) {
   let content;
-  let attrs;
+  let opts = {};
 
-  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)
+  ) {
+    opts = args[0];
     content = args[1];
   } else {
     content = args[0];
   }
 
-  if (selfClosing && content) {
-    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  switch (identifier) {
+    case 'blockwrap':
+      return new Tag(null, {[blockwrap]: true}, content);
+
+    case 'chunkwrap':
+      return new Tag(null, {[chunkwrap]: true, ...opts}, content);
+
+    default:
+      throw new Error(`Unknown metatag "${identifier}"`);
   }
+}
 
-  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?`);
+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) {
+    if (
+      this.selfClosing &&
+      !(value === null ||
+        value === undefined ||
+        !value ||
+        Array.isArray(value) && value.filter(Boolean).length === 0)
+    ) {
+      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
     }
 
-    const joiner = attrs?.[joinChildren];
-    content = content.filter(Boolean).join(
-      (joiner
-        ? `\n${joiner}\n`
+    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() {
+    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 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 = content;
+    } catch (error) {
+      this.#setAttributeFlag(chunkwrap, false);
+      throw error;
+    }
+  }
+
+  get chunkwrap() {
+    return this.#getAttributeFlag(chunkwrap);
+  }
+
+  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'));
   }
 
-  if (attrs?.[onlyIfContent] && !content) {
-    return '';
+  #getContentJoiner() {
+    if (this.joinChildren === undefined) {
+      return '\n';
+    }
+
+    if (this.joinChildren === '') {
+      return '';
+    }
+
+    return `\n${this.joinChildren}\n`;
   }
 
-  if (attrs) {
-    const attrString = attributes(attrs);
-    if (attrString) {
-      openTag = `${tagName} ${attrString}`;
+  #stringifyContent() {
+    if (this.selfClosing) {
+      return '';
+    }
+
+    const joiner = this.#getContentJoiner();
+
+    let content = '';
+    let blockwrapClosers = '';
+
+    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()) {
+      let itemContent;
+
+      try {
+        itemContent = item.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;
+      }
+
+      const chunkwrapChunks =
+        (typeof item === 'string' && chunkwrapSplitter
+          ? itemContent.split(chunkwrapSplitter)
+          : null);
+
+      const itemIncludesChunkwrapSplit =
+        (chunkwrapChunks
+          ? chunkwrapChunks.length > 1
+          : null);
+
+      if (content) {
+        if (itemIncludesChunkwrapSplit) {
+          if (!seenChunkwrapSplitter) {
+            // The first time we see a chunkwrap splitter, backtrack and wrap
+            // the content *so far* in a chunk.
+            content = `<span class="chunkwrap">` + content;
+          }
+
+          // Close the existing chunk. We'll add the new chunks after the
+          // (normal) joiner.
+          content += `</span>`;
+        }
+
+        content += joiner;
+      } else {
+        // 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.
+        if (itemIncludesChunkwrapSplit) {
+          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 (item instanceof Tag && item.blockwrap && content) {
+        content += `<span class="blockwrap">`;
+        blockwrapClosers += `</span>`;
+      }
+
+      appendItemContent: {
+        if (itemIncludesChunkwrapSplit) {
+          for (const [index, chunk] of chunkwrapChunks.entries()) {
+            if (index === 0) {
+              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 (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);
   }
 
-  if (!openTag) {
-    openTag = tagName;
+  set attributes(value) {
+    this.#attributes = Object.create(null);
+
+    if (value === undefined || value === null) {
+      return;
+    }
+
+    this.add(value);
   }
 
-  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'));
+  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 {
-      return `<${openTag}>${content}</${tagName}>`;
+      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;
     }
-  } else if (selfClosing) {
-    return `<${openTag}>`;
+
+    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) {
+    return attribute in this.#attributes;
+  }
+
+  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} = {}) {
+  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 `<${openTag}></${tagName}>`;
+    return Template.resolve(tagOrTemplate);
   }
 }
 
-export function escapeAttributeValue(value) {
-  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+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));
+}
+
+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,
+    ]);
+
+    clone.setSlots(this.#slotValues);
+
+    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 (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);
+    }
+
+    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;
+  }
+
+  [inspect.custom]() {
+    const {annotation} = this.description;
+
+    return (
+      (annotation
+        ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}`
+        : `Template ${colors.dim(`(no annotation)`)}`));
+  }
 }
 
-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]) =>
-      typeof val === 'boolean'
-        ? `${key}`
-        : `${key}="${escapeAttributeValue(val)}"`
-    )
-    .join(' ');
+export function stationery(description) {
+  return new Stationery(description);
 }
 
-// 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 [];
+export class Stationery {
+  #templateDescription = null;
+
+  static validated = Symbol('Stationery.validated');
+
+  constructor(templateDescription) {
+    Template.validateDescription(templateDescription);
+    templateDescription[Stationery.validated] = true;
+    this.#templateDescription = templateDescription;
   }
 
-  if (Array.isArray(childOrChildren)) {
-    return childOrChildren;
+  template() {
+    return new Template(this.#templateDescription);
   }
 
-  return [childOrChildren];
+  [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);
+};