]> Lady’s Gitweb - Lemon/commitdiff
Add method chaining syntax for attributes 0.2.0
authorLady <redacted>
Sun, 7 Aug 2022 04:52:00 +0000 (21:52 -0700)
committerLady <redacted>
Sat, 29 Apr 2023 03:36:44 +0000 (20:36 -0700)
You can now do `` p.hidden.class("my-class")`My hidden paragraph` ``
and so forth.

README.markdown
dev-deps.js
mod.js
mod.test.js

index 716349138eea24f4782fecddb8bfcc7ea275c060..bfde47be6cacc7b9dd21c7118621f80fe69942dc 100644 (file)
@@ -8,8 +8,11 @@ import Lemon from "./mod.js";
 const { p, a, em } = Lemon;
 
 document.body.append(
-  p`This is a ${em`very cool`} paragraph made with ${a({
-    href: "https://gitlab.com/kibigo/Lemon",
-  })`Lemon`}.`,
+  p`This is a ${em`very cool`} paragraph made with ${(
+    a
+      .class("gitlab-link")
+      .style("color: orangered")
+      .href("https://gitlab.com/kibigo/Lemon")
+  )`Lemon`}.`,
 );
 ```
index 95bf219777ebc6682b91dc4e693d5608646b734c..338201946f378fc8aca84fe003657d152f04998c 100644 (file)
@@ -11,4 +11,7 @@ import "./window/mod.js";
 
 export { DOMImplementation } from "./window/deps.js";
 
-export { assertStrictEquals } from "https://deno.land/std@0.134.0/testing/asserts.ts";
+export {
+  assertEquals,
+  assertStrictEquals,
+} from "https://deno.land/std@0.134.0/testing/asserts.ts";
diff --git a/mod.js b/mod.js
index e6913e30539aff7a0f1ec87db1bc26901288331e..018b42e687ac06245df5be5704ad5c922c870c9b 100644 (file)
--- a/mod.js
+++ b/mod.js
@@ -19,6 +19,8 @@ const {
    * Lemon("elementName", "namespace")`content`;
    * // or
    * Lemon("elementName", "namespace")({ attr: "value" })`content`;
+   * // or
+   * Lemon("elementName", "namespace").attr("value")`content`;
    * ```
    *
    * Content may, via substitutions, include additional nodes. As a
@@ -39,46 +41,90 @@ const {
    */
   Lemon,
 } = (() => {
-  const lemonProxyHandler = Object.assign(
-    Object.create(null),
-    {
-      /** If `P` doesn’t exist on `O`, calls `O` with `P` instead. */
-      get(O, P, Receiver) {
-        if (typeof P != "string" || Reflect.has(O, P)) {
-          return Reflect.get(O, P, Receiver);
-        } else if (typeof O == "function") {
-          return O([...function* () {
-            for (const character of P) {
-              yield /[A-Z]/u.test(character)
-                ? `-${character.toLowerCase()}`
-                : character;
-            }
-          }()].join(""));
-        } else {
-          return undefined;
-        }
-      },
+  const lemonProxyHandler = Object.assign(Object.create(null), {
+    /** If `P` doesn’t exist on `O`, calls `O` with `P` instead. */
+    get(O, P, Receiver) {
+      if (typeof P != "string" || Reflect.has(O, P)) {
+        // P is not a string or else exists on O.
+        return Reflect.get(O, P, Receiver);
+      } else if (typeof O == "function") {
+        // P is a string and O is a function.
+        return O(toNodeName(P));
+      } else {
+        // P is a string and O is not a function.
+        return undefined;
+      }
     },
-  );
+  });
+
   return {
     Lemon: new Proxy(
       Object.defineProperties(
         Object.setPrototypeOf(
           function (name, namespace = xhtmlNamespace) {
-            return Object.setPrototypeOf(
-              nodeTagBuilder.bind({
-                context: this ?? globalThis,
-                name: `${name}`,
-                namespace: `${namespace}`,
-              }),
-              Lemon.prototype,
-            );
+            return makeLemon.call({
+              context: this ?? globalThis,
+              name: `${name}`,
+              namespace: `${namespace ?? ""}`,
+              attributes: Object.create(null),
+            });
           },
           Function,
         ),
         {
           name: { value: "Lemon" },
-          prototype: { value: Object.create(Function.prototype) },
+          prototype: {
+            value: Object.create(
+              Function.prototype,
+              {
+                attributes: {
+                  get: function () {
+                    return Object.assign(
+                      Object.create(null),
+                      Object(getMemo(this).attributes),
+                    );
+                  },
+                  set: undefined,
+                  enumerable: false,
+                  configurable: true,
+                },
+                localName: {
+                  get: function () {
+                    const { name } = getMemo(this);
+                    return name.substring(name.indexOf(":") + 1);
+                  },
+                  set: undefined,
+                  enumerable: false,
+                  configurable: true,
+                },
+                namespaceURI: {
+                  get: function () {
+                    return getMemo(this).namespace || null;
+                  },
+                  set: undefined,
+                  enumerable: false,
+                  configurable: true,
+                },
+                ownerDocument: {
+                  get: function () {
+                    return getMemo(this).context?.document ??
+                      globalThis.document;
+                  },
+                  set: undefined,
+                  enumerable: false,
+                  configurable: true,
+                },
+                qualifiedName: {
+                  get: function () {
+                    return getMemo(this).name;
+                  },
+                  set: undefined,
+                  enumerable: false,
+                  configurable: true,
+                },
+              },
+            ),
+          },
           bind: {
             value: Object.defineProperties(
               function (thisArg, ...args) {
@@ -105,6 +151,27 @@ const {
 })();
 export default Lemon;
 
+/** An object with a retrievable memo value. */
+class Memoized extends function ($) {
+  return $;
+} {
+  #memo;
+
+  /**
+   * Remembers the provided memo as a private class feature on the
+   * provided object.
+   */
+  constructor(object, memo) {
+    super(object);
+    this.#memo = memo;
+  }
+
+  /** Retrieves the remembered memo from this object. */
+  get memo() {
+    return this.#memo;
+  }
+}
+
 /**
  * Creates an Element with `name`, `namespace`, and `attributes` drawn
  * from this object and content determined by the provided strings and
@@ -113,7 +180,7 @@ export default Lemon;
 const createNode = function (strings, ...expressions) {
   const { raw } = strings;
   const { context, name, namespace, attributes } = this;
-  const document = context.document ?? globalThis.document;
+  const document = context?.document ?? globalThis.document;
   const result = document.createElementNS(namespace, name);
   for (const [attName, attValue] of Object.entries(attributes)) {
     result.setAttribute(attName, `${attValue}`);
@@ -129,40 +196,145 @@ const createNode = function (strings, ...expressions) {
   return result;
 };
 
+/** Retrieves the remembered memoized value from this object. */
+const getMemo = ($) => Reflect.get(Memoized.prototype, "memo", $);
+
+/** Returns a new Lemon proxy function bound to this value. */
+const makeLemon = function (attName = null) {
+  const { attributes: oldAttributes, ...restOfThis } = this;
+  const boundThis = Object.assign(
+    Object.create(null),
+    restOfThis,
+    {
+      attributes: attName == null
+        ? Object(oldAttributes)
+        : mergeAttributes(oldAttributes, { [attName]: "" }),
+    },
+  );
+  return new Memoized(
+    new Proxy(
+      Object.defineProperties(
+        Object.setPrototypeOf(
+          nodeTagBuilder.bind(boundThis, attName),
+          Lemon.prototype,
+        ),
+        {
+          name: {
+            value: attName == null
+              ? toStartTag.call(boundThis)
+              : toStartTag.call(boundThis) +
+                ` /* pending ${attName} */`,
+          },
+          length: { value: 1 },
+        },
+      ),
+      Object.assign(
+        Object.create(null),
+        {
+          /**
+           * If `P` doesn’t exist on `O`, returns a bound `makeLemon`
+           * instead.
+           */
+          get(O, P, Receiver) {
+            if (typeof P != "string" || Reflect.has(O, P)) {
+              // P is not a string or else exists on O.
+              return Reflect.get(O, P, Receiver);
+            } else {
+              // P is a string which does not exist on O.
+              return makeLemon.call(boundThis, toNodeName(P));
+            }
+          },
+        },
+      ),
+    ),
+    boundThis,
+  );
+};
+
 /**
- * Creates a new template tag builder with the provided attributes for
+ * Merges the provided attributes objects onto a new object and returns
+ * that object.
+ *
+ * There is special handling when the attribute value is empty or when
+ * it matches the attribute name. A false attribute value deletes the
+ * attribute, while an undefined one is treated as the empty string.
+ */
+const mergeAttributes = (base, others) => {
+  const attributes = Object.assign(Object.create(null), Object(base));
+  for (const [attName, attValue] of Object.entries(others)) {
+    if (attValue === false) {
+      delete attributes[attName];
+    } else {
+      const value = attValue === true || attValue === undefined
+        ? ""
+        : `${attValue}`;
+      if (attName in attributes) {
+        const oldValue = attributes[attName];
+        if (value == "" || value == attName && value == oldValue) {
+          /* do nothing */
+        } else {
+          attributes[attName] = oldValue
+            ? `${oldValue} ${value}`
+            : value;
+        }
+      } else {
+        attributes[attName] = value;
+      }
+    }
+  }
+  return attributes;
+};
+
+/**
+ * Returns a new template tag builder with the provided attributes for
  * for making Element’s with the `name` specified on this.
  *
  * As a shorthand, you can also use this function as a template tag
  * itself, in which case it is treated as though its attributes were
  * empty.
  */
-const nodeTagBuilder = function (attrsOrStrings, ...expressions) {
-  const { context, name, namespace } = this;
-  if (
+const nodeTagBuilder = function (
+  pending,
+  attrsOrStrings,
+  ...expressions
+) {
+  const { attributes: oldAttributes, ...restOfThis } = this;
+  if (Object(attrsOrStrings) !== attrsOrStrings) {
+    // attrsOrStrings is a primitive.
+    if (pending != null) {
+      return makeLemon.call({
+        ...restOfThis,
+        attributes: mergeAttributes(oldAttributes, {
+          [pending]: attrsOrStrings,
+        }),
+      });
+    } else {
+      throw new TypeError(
+        "Cannot set attribute value when no name is pending.",
+      );
+    }
+  } else if (
     Array.isArray(attrsOrStrings) && "raw" in attrsOrStrings &&
     Array.isArray(attrsOrStrings.raw)
   ) {
-    // The first argument is usable as a template string.
+    // attrsOrStrings is usable as a template string.
     return createNode.call(
-      { context, name, namespace, attributes: {} },
+      { ...restOfThis, attributes: Object(oldAttributes) },
       attrsOrStrings,
       ...expressions,
     );
   } else {
-    // The first argument is not a template string.
-    return createNode.bind({
-      context,
-      name,
-      namespace,
-      attributes: { ...Object(attrsOrStrings) },
+    // attrsOrStrings is not a template string.
+    return makeLemon.call({
+      ...restOfThis,
+      attributes: mergeAttributes(oldAttributes, attrsOrStrings),
     });
   }
 };
 
 /** Processes the provided expression and yields Nodes. */
 const nodesFromExpression = function* (expression) {
-  const document = this.document ?? globalThis.document;
+  const document = this?.document ?? globalThis.document;
   const nodePrototype = Object.getPrototypeOf(
     Object.getPrototypeOf(document.createDocumentFragment()),
   );
@@ -198,3 +370,70 @@ const nodesFromExpression = function* (expression) {
     }
   }
 };
+
+/**
+ * Returns the provided value escaped such that it is usable in a
+ * double‐quoted attribute value string.
+ */
+const toAttributeValue = (value) =>
+  `${value}`.replaceAll(/[\x22\x26\x3C\x3E]/gu, ($) => {
+    switch ($) {
+      case "\x26":
+        return "&amp;";
+      case "\x22":
+        return "&quot;";
+      case "\x3C":
+        return "&lt;";
+      case "\x3E":
+        return "&gt;";
+    }
+  });
+
+/** Returns an X·M·L start tag derived from this.*/
+const toStartTag = function () {
+  const name = `${this.name}`;
+  const namespace = `${this.namespace ?? ""}`;
+  const attributes = Object(this.attributes);
+  return "".concat(
+    ...function* () {
+      yield `<${name}`;
+      if (namespace) {
+        // A namespace was specified.
+        const colonIndex = name.indexOf(":");
+        if (colonIndex != -1) {
+          // The node name has a prefix.
+          yield ` xmlns:${name.substring(0, colonIndex)}="${
+            toAttributeValue(namespace)
+          }"`;
+        } else {
+          // The node name is unprefixed.
+          yield ` xmlns="${toAttributeValue(namespace)}"`;
+        }
+      } else {
+        // No namespace was specified.
+        /* do nothing */
+      }
+      for (const [attName, attValue] of Object.entries(attributes)) {
+        // Iterate over attributes and serialize them.
+        yield ` ${attName}="${toAttributeValue(attValue)}"`;
+      }
+      yield ">";
+    }(),
+  );
+};
+
+/**
+ * Returns a node name derived from the provided Ecmascript property
+ * name by lowercasing uppercase ascii letters and prefixing them with
+ * a hyphen.
+ */
+const toNodeName = (ecmascriptName) =>
+  "".concat(
+    ...function* () {
+      for (const character of ecmascriptName) {
+        yield /[A-Z]/u.test(character)
+          ? `-${character.toLowerCase()}`
+          : character;
+      }
+    }(),
+  );
index 8af8f73901c351b89b97423a951e0199f99d1355..44e2b9e150704aed359f2c97decc43955b214118 100644 (file)
@@ -7,7 +7,11 @@
 // License, v. 2.0. If a copy of the MPL was not distributed with this
 // file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
 
-import { assertStrictEquals, DOMImplementation } from "./dev-deps.js";
+import {
+  assertEquals,
+  assertStrictEquals,
+  DOMImplementation,
+} from "./dev-deps.js";
 import Lemon from "./mod.js";
 
 Deno.test({
@@ -117,6 +121,71 @@ Deno.test({
   },
 });
 
+Deno.test({
+  name: "Lemon tags can assign attributes with property chaining.",
+  fn: () => {
+    const elt = (
+      Lemon.xxx
+        .class("abc")
+        .class("def")
+        .dataWet("💦")
+        .dataFalse("failure")
+        .dataTrue()
+        .hidden
+        .hidden
+        .dataName("data-name")
+        .dataName("data-name")
+        .dataFalse(false)
+    )``;
+    assertStrictEquals(elt.getAttribute("class"), "abc def");
+    assertStrictEquals(elt.getAttribute("data-wet"), "💦");
+    assertStrictEquals(elt.getAttribute("data-true"), "");
+    assertStrictEquals(elt.getAttribute("data-hidden"), "");
+    assertStrictEquals(elt.getAttribute("data-name"), "data-name");
+    assertStrictEquals(elt.hasAttribute("data-false"), false);
+  },
+});
+
+Deno.test({
+  name: "Lemon tags expose their internal state.",
+  fn: () => {
+    const eltTag = (
+      Lemon("xxx:yyy", "example:xxx")
+        .class("abc")
+        .class("def")
+        .dataWet("💦")
+        .dataFalse("failure")
+        .dataTrue()
+        .hidden
+        .hidden
+        .dataName("data-name")
+        .dataName("data-name")
+        .dataFalse(false)
+        .dataExtra
+    );
+    assertEquals(eltTag.attributes, {
+      class: "abc def",
+      "data-wet": "💦",
+      "data-true": "",
+      hidden: "",
+      "data-name": "data-name",
+      "data-extra": "",
+    });
+    assertStrictEquals(eltTag.localName, "yyy");
+    assertStrictEquals(eltTag.namespaceURI, "example:xxx");
+    assertStrictEquals(eltTag.qualifiedName, "xxx:yyy");
+    assertStrictEquals(eltTag.ownerDocument, document);
+    assertStrictEquals(
+      eltTag.name,
+      '<xxx:yyy xmlns:xxx="example:xxx" class="abc def" data-wet="💦" data-true="" hidden="" data-name="data-name" data-extra=""> /* pending data-extra */',
+    );
+    assertStrictEquals(
+      eltTag().name,
+      '<xxx:yyy xmlns:xxx="example:xxx" class="abc def" data-wet="💦" data-true="" hidden="" data-name="data-name" data-extra="">',
+    );
+  },
+});
+
 Deno.test({
   name: "Lemon tags support all kinds of expression.",
   fn: () => {
This page took 0.036126 seconds and 4 git commands to generate.