Source: databind.js

/**
 * This class allows you to bind data to DOM elements, either one way or
 * bi-directional. This enables you to keep your domain model in sync with
 * what's shown in the browser.
 *
 * ## Usage
 *
 * Bind part of the DOM to a data object:
 *
 * ```js
 * import Databind from './databind.js';
 *
 * const dataProxy = new Databind("#app", {
 *   user: {
 *     name: "Alice",
 *     visible: true,
 *     role: "admin",
 *   },
 *   clickCounter: 0,
 *   items: [1, 2, 3, "hello"],
 * });
 * ```
 *
 * Then you can use the following attributes in the DOM to bind to that data:
 *
 *  - `data-bind`: Two-way binding. The value of the attribute is a path in the
 *    data object. The value of the DOM element will be kept in sync with the
 *    value in the data object, and changes to the DOM element will be written
 *    back to the data object.
 *  - `data-read`: One-way binding. The value of the attribute is a path in the
 *    data object. Changes to the data object at the given path will get synced
 *    to the DOM element.
 *  - `data-write`: One-way binding. The value of the attribute is a path in the
 *    data object. Changes to the DOM element will be written to the data object
 *    at that path.
 *  - `data-click`: The value of the attribute is an assignment expression. When
 *    the element is clicked, the expression will be evaluated and the result
 *    will be stored in the data object. For example, `data-click="user.role =
 *    reviewer"` will update the `role` property of `user` in the data object
 *    when the element is clicked.
 *  - `data-active-if`: The value of the attribute is a path or an expression
 *    (see below). If the expression evaluates to a truthy value, the class
 *    specified in the `class` option (default: "active") will be added to the
 *    element. Otherwise, it will be removed.
 *
 * For example:
 *
 * ```html
 * <div id="app" data-active-if="user.visible">
 *   <input type="text" data-bind="user.name">
 *   <p data-read="user.role"></p>
 *   <button data-click="user.role = admin">Admin</button>
 *   <button data-click="user.role = contrib">Contributor</button>
 *   <p data-read="clickCounter"></p>
 *   <button id="counter">Click me</button>
 * </div>
 * ```
 *
 * ### Programmatic access
 *
 * The constructor returns a proxy object which you can use to read from and
 * write to the data object. Changes you make through this proxy will be
 * reflected in the DOM in the annotated places.
 *
 * ```js
 * dataProxy.data.user.visible = false;   // This will hide the entire div with id "app"
 * console.log(dataProxy.data.user.name); // This will initially log "Alice"
 *
 * // Listen for clicks on the button, and update the counter shown
 * document.getElementById("counter").addEventListener("click", () =>
 *   dataProxy.data.clickCounter++);
 * ```
 *
 * You can also listen for changes to the data object by adding `change` event
 * listeners to the returned object:
 *
 * ```js
 * dataProxy.addEventListener("change", (path, oldValue, newValue) => {
 *   console.log(`Change at ${path} from ${oldValue} to ${newValue}`);
 *
 *   switch(path) {
 *     case "user.name":
 *       console.log(`User's name changed to ${newValue}`);
 *       break;
 *   }
 * });
 * ```
 *
 * ### Expressions
 *
 * The values of the `data-active-if` and `data-click` attributes can be more
 * than just paths in the data object. They allow for simple expressions that
 * include comparisons and assignments. For example,
 * `data-active-if="user.age >= 18"` will add the active class to the element if
 * the user's age is greater than or equal to 18. `data-active-if="user.name ==
 * Alice"` will add the active class if the user's name is Alice. The supported
 * comparisons are `==`, `!=`, `>`, `<`, `>=`, and `<=`. Any whitespace is only
 * for readability and will be ignored when parsing the expression.
 *
 * The `data-click` attribute supports assignment expressions. For example,
 * `data-click="user.name = John"` will set the user's name to John when the
 * element is clicked.
 *
 * Don't try to be too "clever" with these expressions. They are very limited
 * for a reason; in most people's mental model HTML is not for logic. If you
 * want to do something more complicated, just write write some complementary
 * Javascript code that listens for changes to the data object and updates it
 * accordingly, like in the examples above.
 *
 * ### Loops
 *
 * Loop are basically the "data-read" property for arrays. You can use the
 * `data-loop` attribute to repeat a section of the DOM for each element in an
 * array in the data object.
 *
 * The value of the `data-loop` attribute should be an assignment expression
 * where the assignee is a variable name and the assigned value is a path to an
 * array in the data object. For example, `data-loop="index = items"` will
 * repeat the section of the DOM for each element in the `items` array in the
 * data object, and within that section you can use your variable (in this
 * example `index`) to refer to the index of the current element.
 *
 * For example:
 *
 * ```html
 * <ul data-loop="index = items">
 *   <li data-read="items.index"></li>
 * </ul>
 * ```
 *
 * This will dynamically expand to:
 *
 * ```html
 * <ul>
 *   <li>1</li>
 *   <li>2</li>
 *   <li>3</li>
 *   <li>hello</li>
 * </ul>
 * ```
 *
 * When items are added to or removed from the array, the `data-loop` attribute
 * will keep the number of items in sync. When the value of items in the array
 * or their order changes, the `data-read` attributes will take care of that.
 * Items in the array can be objects, in which case you can use paths like
 * `items.index.name` to refer to their properties.
 *
 * There is no `data-write` equivalent for arrays.
 *
 * ## Options
 *
 * ### Behaviour settings
 *
 * The DataBind constructor takes an optional third argument with the following
 * options:
 *
 * - `class`: The class to toggle for `data-active-if` expressions (default:
 *   "active")
 * - `stopEvents`: Whether to call `preventDefault` and `stopPropagation` on
 *   events after handling them (default: true)
 * - `immediate`: Whether to listen for `input` events instead of `change`
 *   events for form elements (default: false). If true, changes to form
 *   elements will be written to the data object immediately as the user types,
 *   instead of only when they blur the element.
 * - `customConversions`: An array of custom conversion objects. Each object
 *   should have a `selector` property, and optionally `toDom` and `fromDom`
 *   functions. If an element matches the selector, the corresponding conversion
 *   functions will be used to convert values between the data object and the
 *   DOM, instead of the default conversions.
 *     - The `toDom` function takes three arguments: the path that changed in
 *       the data object, the new value at that path, and the DOM element to
 *       update.
 *     - The `fromDom` function takes two arguments: the path to write to in the
 *       data object, and the DOM element to read from. It should return the
 *       value to write to the data object.
 *
 * For example:
 *
 * ```js
 * const dataProxy = new Databind("#app", {
 *   date: new Date(),
 * }, {
 *   class: "enabled",
 *   immediate: true,
 *   customConversions: [
 *     {
 *       selector: "input[type=date]",
 *       toDom: (path, value, element) => element.value = value.toISOString().substring(0, 10),
 *       fromDom: (path, element) => new Date(element.value),
 *     },
 *   ],
 * });
 * ```
 *
 * This allows you to build custom interface elements that still respond
 * dynamically to changes in either the DOM or the data, using all the same
 * binding attributes.
 *
 * ### Attribute names
 *
 * You can also use the options object to override the attribute names for the
 * bindings by passing different values. For example, if you want to use
 * `data-model` instead of `data-bind` and `data-is-active` instead of
 * `data-active-if` for some reason, you can do:
 *
 * ```js
 * const dataProxy = new Databind("#app", {...}, {
 *   bind: "data-model",
 *   activeIf: "data-is-active"
 * });
 * ```
 *
 * This feature is mainly there to allow you to use this library in contexts
 * where the default attribute names might conflict with other libraries or
 * frameworks, and I would advise against changing them for personal preference.
 * Keeping them predictable keeps your code in line with this documentation and
 * helps people to move from project to project without having to adapt their
 * mental model.
 *
 * @module
 */

export default class Databind {
  constructor(scope, data, options = {}) {
    this._scope = scope;
    this._data = new DataProxy(data);
    this._data.addEventListener("change", (p) => this._applyValue(p));
    this._options = this._normalizeOptions(options);

    // Register for form element change events
    if (this._options.immediate) {
      document.addEventListener("input", (e) => this._handleChange(e));
    } else {
      document.addEventListener("change", (e) => this._handleChange(e));
    }

    // Register for click events
    document.addEventListener("click", (e) => this._handleClick(e));

    // Apply the data to the DOM to bring them into initial sync
    this._applyValue();

    return this._data;
  }

  _normalizeOptions(options) {
    return Object.assign(
      {
        // Settings
        class: "active",
        stopEvents: true,
        immediate: false,
        customConversions: [],

        // Attribute names
        bind: "data-bind",
        write: "data-write",
        read: "data-read",
        click: "data-click",
        activeIf: "data-active-if",
        loop: "data-loop",
      },
      options,
    );
  }

  _parseExpression(expression) {
    expression = expression.trim();

    if (expression.match(`^[^=!]+=[^=].*$`) != null) {
      // This translates to: "an equals sign not part of a comparison". If we
      // match that, we have an assignment in the form of "something=anything".
      const assigned = this._parseExpression(
        expression.substring(expression.indexOf("=") + 1),
      );
      return {
        type: assigned.type == "unparseable" ? "unparseable" : "assignment",
        assignee: expression.split("=")[0].trim(),
        assigned: assigned,
        value: assigned.value,
      };
    }

    const comparisons = {
      "==": (a, b) => a == b,
      "!=": (a, b) => a != b,
      ">": (a, b) => a > b,
      "<": (a, b) => a < b,
      "<=": (a, b) => a <= b,
      ">=": (a, b) => a >= b,
    };
    for (const comp in comparisons) {
      if (expression.includes(comp)) {
        const parts = expression.split(comp);
        const left = this._parseExpression(parts[0]);
        const right = this._parseExpression(parts[1]);
        return {
          type:
            left.type == "unparseable" || right.type == "unparseable"
              ? "unparseable"
              : "equality",
          left: left,
          right: right,
          value: comparisons[comp](left.value, right.value),
        };
      }
    }

    if (this._data.pathExists(expression)) {
      return {
        type: "path",
        path: expression,
        value: this._data.retrieve(expression),
      };
    }

    try {
      return {
        type: "value",
        value: toValue(expression),
      };
    } catch (e) {}

    return {
      type: "unparseable",
    };
  }

  /* DOM --> data object */

  _handleChange(evnt) {
    // Which element changed?
    const target = evnt.target.closest(
      `${this._scope} [${this._options.bind}], ${this._scope} [${this._options.write}]`,
    );
    if (!target) return;

    // Apply the changes to the data object
    this._apply(target.getAttribute(this._options.bind), target);
    this._apply(target.getAttribute(this._options.write), target);

    if (this._options.stopEvents) {
      // We're done with this event, don't try to evaluate it any further
      evnt.preventDefault();
      evnt.stopPropagation();
    }
  }

  _apply(attribute, target) {
    if (!attribute) return;
    try {
      const expression = this._parseExpression(attribute);
      if (!expression.type == "path") {
        return console.error("Expected path");
      }

      let value;
      for (const conv of this._options.customConversions) {
        if (target.matches(conv.selector) && conv.fromDom) {
          value = conv.fromDom(expression.path, target);
          this._data.store(expression.path, value);
          return;
        }
      }

      if (target.matches("input[type=checkbox")) {
        value = target.checked;
      } else if ("value" in target) {
        value = target.value;
      } else throw new Error("no value to write to data object");

      this._data.store(expression.path, value);
    } catch (e) {
      console.error(e);
    }
  }

  _handleClick(evnt) {
    // Which element was clicked?
    const target = evnt.target.closest(
      `${this._scope} [${this._options.click}]`,
    );
    if (!target) return;

    // Apply the changes to the data object
    const attribute = target.getAttribute(this._options.click);
    const expression = this._parseExpression(attribute);

    if (!expression.type == "assignment")
      return console.error(
        `Expected assignment in ${this._options.click}, got ${attribute}`,
      );

    try {
      this._data.store(expression.assignee, expression.value);
    } catch (e) {
      console.error(e);
    }

    if (this._options.stopEvents) {
      // We're done with this event, don't try to evaluate it any further
      evnt.preventDefault();
      evnt.stopPropagation();
    }
  }

  /* Data object --> DOM */

  _applyValue(path) {
    // Lists need to be done first, so elements in the lists get updates too.
    // For the rest the order doesn't matter.
    this._applyLists(path);
    this._applyReads(path);
    this._applyBinds(path);
    this._applyClasses(path);
  }

  _applyReads(path) {
    // Update values in `read` elements
    document
      .querySelectorAll(`${this._scope} [${this._options.read}]`)
      .forEach((e) => {
        const readExpression = e.getAttribute(this._options.read);
        if (path != null && !readExpression.includes(path)) return;
        const expression = this._parseExpression(readExpression);

        if (expression.type == "unparseable")
          return console.error("Could not parse expression: " + readExpression);

        for (const conv of this._options.customConversions) {
          if (e.matches(conv.selector) && conv.toDom) {
            conv.toDom(path, expression.value, e);
            return;
          }
        }

        if (e.matches("input[type=checkbox")) {
          e.checked = expression.value;
        } else if ("value" in e) {
          e.value = expression.value;
        } else if ("innerText" in e) {
          e.innerText = expression.value;
        } else throw new Error("don't know how to write value to DOM element");
      });
  }

  _applyBinds(path) {
    // Update values in `bind` elements
    document
      .querySelectorAll(`${this._scope} [${this._options.bind}]`)
      .forEach((e) => {
        const bindExpression = e.getAttribute(this._options.bind);
        if (path != null && !bindExpression.includes(path)) return;
        const expression = this._parseExpression(bindExpression);

        if (expression.type == "unparseable")
          return console.error("Could not parse expression: " + bindExpression);

        for (const conv of this._options.customConversions) {
          if (e.matches(conv.selector) && conv.toDom) {
            conv.toDom(path, expression.value, e);
            return;
          }
        }

        if (e.matches("input[type=checkbox")) {
          e.checked = expression.value;
        } else if ("value" in e) {
          e.value = expression.value;
        } else throw new Error("don't know how to write value to DOM element");
      });
  }

  _applyClasses(path) {
    // Update classes (for active-if expressions)
    document
      .querySelectorAll(`${this._scope} [${this._options.activeIf}]`)
      .forEach((e) => {
        const activeIfExpression = e.getAttribute(this._options.activeIf);
        if (path != null && !activeIfExpression.includes(path)) return;
        const expression = this._parseExpression(activeIfExpression);

        if (expression.type == "unparseable")
          return console.error(
            "Could not parse expression: " + activeIfExpression,
          );

        e.classList.toggle("active", !!expression.value);
      });
  }

  _applyLists(path) {
    // Update loops to have all the right elements in them
    document
      .querySelectorAll(`${this._scope} [${this._options.loop}]`)
      .forEach((e) => {
        const loopExpression = e.getAttribute(this._options.loop);
        if (path != null && !loopExpression.includes(path)) return;
        const expression = this._parseExpression(loopExpression);

        if (
          !(
            expression.type == "assignment" &&
            expression.assigned.type == "path"
          )
        )
          return console.error(
            `Expected assignment of path in ${this._options.loop} in ${attribute}`,
          );

        if (!Array.isArray(expression.value))
          return console.error(
            `Invalid expression in ${this._options.loop}: Can't iterate over ${expression.assigned.path}`,
          );

        // If this is the first time we see this, save original DOM contents as
        // a template for adding child elements
        if (!e.originalContents) {
          e.originalContents = e.innerHTML;
          e._listLength = 0;
        }

        const variable = expression.assignee;
        const list = this._data.retrieve(expression.assigned.path);
        if (list.length == e._listLength) return;

        // Rebuild sub-tree
        e.innerHTML = "";
        for (let i = 0; i < list.length; i++) {
          e.appendChild(
            this._renderListTemplate(e.originalContents, i, variable),
          );
        }
        e._listLength = list.length;
      });
  }

  _renderListTemplate(html, index, variable) {
    // Create safe regexp to find variable name
    variable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const re = new RegExp(`(?<!\\w)${variable}(?!\\w)`, "g");

    // Put the original HTML in a template element
    const template = document.createElement("template");
    template.innerHTML = html;

    // Modify the template's relevant attributes so we point to `index` instead
    // of `variable`.
    const attributes = [
      this._options.read,
      this._options.write,
      this._options.bind,
      this._options.click,
      this._options.activeIf,
    ];
    template.content
      .querySelectorAll(attributes.map((a) => `[${a}]`).join(","))
      .forEach((e) => {
        for (let i = 0; i < e.attributes.length; i++) {
          if (attributes.includes(e.attributes[i].name)) {
            e.attributes[i].value = e.attributes[i].value.replace(re, index);
          }
        }
      });

    return template.content;
  }
}

export class DataProxy {
  constructor(data) {
    this._listeners = [];
    this.data = new Wrapper(data, [], (path, oldVal, newVal) =>
      this._listeners.forEach((f) => f(path, oldVal, newVal)),
    );
  }

  /* Public API */

  addEventListener(evnt, listener) {
    // Only accepts change listeners for now
    if (evnt != "change")
      throw new Error("Can currently only support 'change' events");
    if (typeof listener != "function")
      throw new Error("Event listener should be a function");
    this._listeners.push(listener);
  }

  /* API for databind.js */

  store(path, value) {
    if (!validPathString(path))
      throw new Error("attempted to store to invalid path");

    // Find the right place in the object hierarchy based on the path
    const parts = path.split(".");
    let target = this.data;
    for (let i = 0; i < parts.length - 1; i++) {
      const p = parts[i];
      if (target == null || typeof target !== "object" || !(p in target)) {
        throw new Error(`Data object has no property ${p} for path ${path}`);
      }
      target = target[p];
    }

    // We have stopped a bit too early, so we can actually store the value
    // (`target = value` just overwrites the pointer, `target[leaf] = value`
    // actually stores in it).
    const leaf = parts[parts.length - 1];
    if (target == null || typeof target !== "object" || !(leaf in target)) {
      throw new Error(`Data object has no property ${leaf} for path ${path}`);
    }

    target[leaf] = cast(value, target[leaf]);
  }

  retrieve(path) {
    if (!validPathString(path))
      throw new Error("attempted to retrieve from invalid path: " + path);
    const parts = path.split(".");
    let target = this.data;
    for (const p of parts) {
      if (target == null || typeof target !== "object" || !(p in target)) {
        throw new Error(`Data object has no property ${p} for path ${path}`);
      }
      target = target[p];
    }
    return target;
  }

  pathExists(path) {
    try {
      this.retrieve(path);
      return true;
    } catch (e) {
      return false;
    }
  }
}

function validPathString(path) {
  if (typeof path != "string") return false;
  // A valid property of an object may not start with a number, but we do
  // accept number-only indices for arrays
  const validStartChar = "[a-zA-Z_]";
  const validFollowingChar = "[a-zA-Z_0-9]";
  const validIndex = "[0-9]+";
  const validProperty = `(${validStartChar}+${validFollowingChar}*|${validIndex})`;
  // We're looking for at least one valid property, or a chain of valid
  // properties separated by periods.
  return path.match(`^${validProperty}(\\.${validProperty})*$`) != null;
}

function cast(value, oldValue) {
  if (oldValue === null) {
    // Because `typeof null == "object"` :(
    oldValue = undefined;
  }
  switch (typeof oldValue) {
    case "number":
      return Number.parseFloat(value);
    case "string":
      return `${value}`;
    case "boolean":
      return !!value;
    case "undefined":
      // The target has no type, so try to infer from the value itself
      return toValue(value);
    case "object":
      if (typeof value == "object") {
        return value;
      } else {
        // fall through to default below
      }
    default:
      throw new Error(`Can't cast a value to type ${type}`);
  }
}

export function toValue(expression) {
  switch (expression) {
    case "undefined":
      return undefined;
    case "true":
      return true;
    case "false":
      return false;
  }
  const number = Number.parseFloat(expression);
  if (!isNaN(number)) {
    return number;
  }
  return expression;
}

class Wrapper {
  constructor(data, path, notify) {
    this.data = data;
    this.path = path;
    this.notify = notify;
    return new Proxy(data, this);
  }

  set(obj, prop, value, receiver) {
    // This would be a no-op, so ignore and don't bother listeners
    if (obj[prop] === value) {
      return true;
    }

    // Warn user if they are trying to use internal properties
    if (["__isProxied", "__path", "__originalData"].includes(prop)) {
      throw new Error(
        `Can't set property '${prop}' on proxy object; internal use only`,
      );
    }

    // When assigning other proxy objects to properties, make a deep copy
    if (typeof value == "object" && value.__isProxied) {
      value = structuredClone(value.__originalData);
    }

    // Apply change
    const oldValue = obj[prop];
    const result = Reflect.set(obj, prop, value, receiver);

    // Notify listeners
    const p = [...this.path, prop].join(".");
    if (this.notify) this.notify(p, oldValue, value);

    return result;
  }

  get(obj, prop) {
    switch (prop) {
      case "__isProxied":
        return true;
      case "__path":
        return this.path;
      case "__originalData":
        return this.data;
    }

    // When getting an object, make sure it is wrapped in a RealProxy too
    if (obj[prop] != null && typeof obj[prop] == "object") {
      return new Wrapper(obj[prop], [...this.path, prop], this.notify);
    }

    // Otherwise, just get me the property of the object
    return Reflect.get(...arguments);
  }
}