From: Lady Date: Sun, 7 Aug 2022 04:52:00 +0000 (-0700) Subject: Add method chaining syntax for attributes X-Git-Tag: 0.2.0 X-Git-Url: https://git.ladys.computer/Lemon/commitdiff_plain/1e551b64ae1b6fce925b81bae5a5ad42fb77a5dc?ds=inline;hp=3cfb1515952fbda9622c2a5d4d7b9925db1a03e3 Add method chaining syntax for attributes You can now do `` p.hidden.class("my-class")`My hidden paragraph` `` and so forth. --- diff --git a/README.markdown b/README.markdown index 7163491..bfde47b 100644 --- a/README.markdown +++ b/README.markdown @@ -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`}.`, ); ``` diff --git a/dev-deps.js b/dev-deps.js index 95bf219..3382019 100644 --- a/dev-deps.js +++ b/dev-deps.js @@ -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 e6913e3..018b42e 100644 --- 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 "&"; + case "\x22": + return """; + case "\x3C": + return "<"; + case "\x3E": + return ">"; + } + }); + +/** 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; + } + }(), + ); diff --git a/mod.test.js b/mod.test.js index 8af8f73..44e2b9e 100644 --- a/mod.test.js +++ b/mod.test.js @@ -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 . -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, + '