« get me outta code hell

html: drastically simplify template/slot system - 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:
author(quasar) nebula <qznebula@protonmail.com>2023-04-08 16:54:39 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-04-08 16:54:39 -0300
commit776abf8d697716902692f357c6f179c1e681369f (patch)
treea75e7f63ffc13121c3c08f1f78c10bc883b4d4e1 /src/util
parent0cbfa8c1b70080c7ec4eb352902cf76f8ef30fcf (diff)
html: drastically simplify template/slot system
Diffstat (limited to 'src/util')
-rw-r--r--src/util/html.js469
1 files changed, 331 insertions, 138 deletions
diff --git a/src/util/html.js b/src/util/html.js
index 4a0c08e7..4735c9dc 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,19 +41,15 @@ export const joinChildren = Symbol();
 // or when there are multiple children.
 export const noEdgeWhitespace = Symbol();
 
-export function blank() {
-  return [];
-}
-
 // Note: This is only guaranteed to return true for blanks (as returned by
-// html.blank()) and false for Tags and Slots (regardless of contents or
+// 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 (value instanceof Tag) {
+  if (isTag(value)) {
     return false;
   }
 
-  if (value instanceof Slot) {
+  if (isTemplate(value)) {
     return false;
   }
 
@@ -61,6 +60,102 @@ export function isBlank(value) {
   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) {
+    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)
+
+  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 attributes;
@@ -69,8 +164,7 @@ export function tag(tagName, ...args) {
     typeof args[0] === 'object' &&
     !(Array.isArray(args[0]) ||
       args[0] instanceof Tag ||
-      args[0] instanceof Template ||
-      args[0] instanceof Slot)
+      args[0] instanceof Template)
   ) {
     attributes = args[0];
     content = args[1];
@@ -345,18 +439,10 @@ export class Attributes {
   toString() {
     return Object.entries(this.attributes)
       .map(([key, val]) => {
-        if (val instanceof Slot) {
-          const content = val.toString();
-          return [key, content, !!content];
-        } else {
-          return [key, val];
-        }
-      })
-      .map(([key, val, keepSlot]) => {
         if (typeof val === 'undefined' || val === null)
           return [key, val, false];
         else if (typeof val === 'string')
-          return [key, val, keepSlot ?? true];
+          return [key, val, true];
         else if (typeof val === 'boolean')
           return [key, val, val];
         else if (typeof val === 'number')
@@ -390,176 +476,283 @@ export class Attributes {
   }
 }
 
-export function template(getContent) {
-  return new Template(getContent);
+export function template(description) {
+  return new Template(description);
 }
 
 export class Template {
-  #tag = new Tag();
-
-  #slotContents = {};
-  #slotTraces = {};
+  #description = {};
+  #slotValues = {};
 
-  constructor(getContent) {
-    this.#prepareContent(getContent);
+  constructor(description) {
+    Template.validateDescription(description);
+    this.#description = description;
   }
 
-  #prepareContent(getContent) {
-    const slotFunction = (slotName, defaultValue) => {
-      return new Slot(this, slotName, defaultValue);
-    };
+  static validateDescription(description) {
+    if (description === null) {
+      return;
+    }
 
-    this.#tag.content = getContent(slotFunction);
-  }
+    if (typeof description !== 'object') {
+      throw new TypeError(`Expected object or null, got ${typeof description}`);
+    }
 
-  slot(slotName, content) {
-    this.setSlot(slotName, content);
-    return this;
-  }
+    const topErrors = [];
 
-  setSlot(slotName, content) {
-    if (Array.isArray(content)) {
-      this.#slotContents[slotName] = content;
-    } else {
-      this.#slotContents[slotName] = [content];
+    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`));
     }
-    this.#slotTraces[slotName] = getTopOfCallerTrace();
-  }
 
-  getSlot(slotName) {
-    if (this.#slotContents[slotName]) {
-      const contents = this.#slotContents[slotName]
-        .map(item =>
-          (item === null || item === undefined
-            ? item
-            : item.valueOf()));
-      return new Tag(null, null, contents).valueOf();
-    } else {
-      return blank();
+    if ('annotation' in description) {
+      if (typeof description.annotation !== 'string') {
+        topErrors.push(new TypeError(`Expected annotation to be string`));
+      }
     }
-  }
 
-  // Dragons.
-  getSlotTrace(slotName) {
-    return this.#slotTraces[slotName] ?? '';
+    const slotErrors = [];
+
+    if ('slots' in description) validateSlots: {
+      if (typeof description.slots !== 'object') {
+        slotErrors.push(new TypeError(`Expected description.slots to be object`));
+        break validateSlots;
+      }
+
+      for (const [key, value] of Object.entries(description.slots)) {
+        if (typeof value !== 'object' || value === null) {
+          slotErrors.push(new TypeError(`Expected slot description (of ${key}) to be object`));
+          continue;
+        }
+
+        if ('default' in value) validateDefault: {
+          if (value.default === undefined || value.default === null) {
+            slotErrors.push(new TypeError(`Leave slot default (of ${key}) unspecified instead of undefined or null`));
+            break validateDefault;
+          }
+
+          try {
+            Template.validateSlotValueAgainstDescription(value, description);
+          } catch (error) {
+            error.message = `Error validating slot "${key}" default value: ${error.message}`;
+            slotErrors.push(error);
+          }
+        }
+
+        if ('validate' in value && 'type' in value) {
+          slotErrors.push(new TypeError(`Don't specify both slot validate and type (of ${key})`));
+        } else if (!('validate' in value || 'type' in value)) {
+          slotErrors.push(new TypeError(`Expected either slot validate or type (of ${key})`));
+        } else if ('validate' in value) {
+          if (typeof value.validate !== 'function') {
+            slotErrors.push(new TypeError(`Expected slot validate of (${key}) to be function`));
+          }
+        } else if ('type' in value) {
+          const acceptableSlotTypes = [
+            'string',
+            'number',
+            'bigint',
+            'boolean',
+            'symbol',
+            'html',
+          ];
+
+          if (value.type === 'function') {
+            slotErrors.push(new TypeError(`Functions shouldn't be provided to slots (${key})`));
+          }
+
+          if (value.type === 'object') {
+            slotErrors.push(new TypeError(`Provide validate function instead of type: object (${key})`));
+          }
+
+          if (!acceptableSlotTypes.includes(value.type)) {
+            slotErrors.push(new TypeError(`Expected slot type (of ${key}) 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,
+        (description.annotation
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
+
+    return true;
   }
 
-  set content(_value) {
-    throw new Error(`Template content can't be changed after constructed`);
+  slot(slotName, value) {
+    this.setSlot(slotName, value);
+    return this;
   }
 
-  get content() {
-    return this.#tag.content;
+  slots(slotNamesToValues) {
+    this.setSlots(slotNamesToValues);
+    return this;
   }
 
-  toString() {
-    return this.content.toString();
+  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;
   }
-}
 
-function getTopOfCallerTrace() {
-  const error = new Error();
-  return error.stack.split('\n')
-    .find(line => line.includes('at ') && !line.includes('/util/html.js'))
-    .replace('at ', '')
-    .trim();
-}
+  setSlots(slotNamesToValues) {
+    if (
+      typeof slotNamesToValues !== 'object' ||
+      Array.isArray(slotNamesToValues) ||
+      slotNamesToValues === null
+    ) {
+      throw new TypeError(`Expected object mapping of slot names to values`);
+    }
 
-export class Slot {
-  #defaultTag = new Tag();
-  #handleContent = null;
+    const slotErrors = [];
 
-  #stackIdentifier = '';
-  #stackTrace = '';
+    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;
+      }
 
-  constructor(template, slotName, defaultContentOrHandleContent) {
-    if (!template) {
-      throw new Error(`Expected template`);
+      try {
+        Template.validateSlotValueAgainstDescription(value, description);
+      } catch (error) {
+        error.message = `(${slotName}) ${error.message}`;
+        slotErrors.push(error);
+      }
     }
 
-    if (typeof slotName !== 'string') {
-      throw new Error(`Expected slotName to be string, got ${slotName}`);
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors,
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slots`
+          : `Error validating template slots`));
     }
 
-    this.template = template;
-    this.slotName = slotName;
+    Object.assign(this.#slotValues, slotNamesToValues);
+  }
 
-    this.#setupStackMutation();
+  static validateSlotValueAgainstDescription(value, description) {
+    if (value === undefined) {
+      throw new TypeError(`Specify value as null or don't specify at all`);
+    }
 
-    if (typeof defaultContentOrHandleContent === 'function') {
-      this.#handleContent = defaultContentOrHandleContent;
-    } else {
-      this.defaultContent = defaultContentOrHandleContent;
+    // 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 ${value}`);
+          }
+        } else {
+          if (typeof value !== type) {
+            throw new TypeError(`Slot expects ${type}, got ${value}`);
+          }
+        }
+      }
     }
+
+    return true;
   }
 
-  #setupStackMutation() {
-    // Here be dragons.
+  getSlotValue(slotName) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+    const providedValue = this.#slotValues[slotName] ?? null;
+
+    if (description.type === 'html') {
+      if (!providedValue) {
+        return blank();
+      }
+
+      return providedValue;
+    }
 
-    this.#stackIdentifier = `Slot.valueOf:${Math.floor(10000000 * Math.random())}`;
+    if (providedValue) {
+      return providedValue;
+    }
 
-    this.valueOf = () => this.constructor.prototype.valueOf.apply(this);
-    Object.defineProperty(this.valueOf, 'name', {
-      value: this.#stackIdentifier,
-    });
+    if ('default' in description) {
+      return description.default;
+    }
 
-    this.#stackTrace = getTopOfCallerTrace();
+    return null;
   }
 
-  #mutateStack(error) {
-    // Splice the line marked with #stackIdentifier with a more descriptive message,
-    // and erase the line above as well because it's the trace for the constructor's
-    // valueOf().
-    const lines = error.stack.split('\n');
-    const index = lines.findIndex(line => line.includes(`at ${this.#stackIdentifier}`))
-    const setTrace = this.template.getSlotTrace(this.slotName);
-    lines.splice(
-      index - 1, 2,
-      `at Slot("${this.slotName}") (from ${this.#stackTrace})`,
-      (setTrace
-        ? `at …set from ${setTrace}`
-        : `at …left unset`));
-    error.stack = lines.join('\n');
+  getSlotDescription(slotName) {
+    return this.#getSlotDescriptionOrError(slotName);
   }
 
-  set defaultContent(value) {
-    this.#defaultTag.content = value;
+  #getSlotDescriptionNoError(slotName) {
+    if (this.#description.slots) {
+      if (Object.hasOwn(this.#description.slots, slotName)) {
+        return this.#description.slots[slotName];
+      }
+    }
+
+    return null;
   }
 
-  get defaultContent() {
-    return this.#defaultTag.content;
+  #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) {
-    // Content is stored on the template rather than the slot itself so that
-    // a given slot name can be reused (i.e. two slots can share a name and
-    // will be filled with the same value).
-    this.template.setSlot(this.slotName, value);
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
   }
 
   get content() {
-    const contentTag = this.template.getSlot(this.slotName);
-    return contentTag?.content ?? this.#defaultTag.content;
+    const slots = {};
+
+    for (const slotName of Object.keys(this.description.slots ?? {})) {
+      slots[slotName] = this.getSlotValue(slotName);
+    }
+
+    return this.description.content(slots);
   }
 
-  toString() {
-    return this.valueOf().toString();
+  set description(_value) {
+    throw new Error(`Template description can't be changed after constructed`);
   }
 
-  valueOf() {
-    try {
-      if (this.#handleContent) {
-        const result = this.#handleContent(this.content);
-        if (result === null || result === undefined) {
-          throw new Error(`Expected function for slot ${this.slotName} to return a value, got ${result}`);
-        }
-        return result.valueOf();
-      } else {
-        return this.content.valueOf();
-      }
-    } catch (error) {
-      this.#mutateStack(error);
-      throw error;
-    }
+  get description() {
+    return this.#description;
+  }
+
+  toString() {
+    return this.content.toString();
   }
 }