From: Lady Date: Sat, 6 Aug 2022 21:10:08 +0000 (-0700) Subject: Allow document specification and binding X-Git-Tag: 0.1.0 X-Git-Url: https://git.ladys.computer/Lemon/commitdiff_plain/4e37b348ed00dbaee105883de3de0af447129d0d?hp=0bcf6926ddc22baf9ba097bf644c908ffcf12c3f Allow document specification and binding The document for created nodes is now taken from the `document` property on `this`. A `bind` method is added to `Lemon` to allow for binding of context without losing the proxy behaviours of the original. Testing dependencies were refactored into a `./window` module and the dependency on a `globalThis.Node` object was removed. --- diff --git a/dev-deps.js b/dev-deps.js index 9a670e7..95bf219 100644 --- a/dev-deps.js +++ b/dev-deps.js @@ -7,95 +7,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at . -import { xhtmlNamespace } from "./names.js"; -import { - DOMImplementation, - Node, -} from "https://esm.sh/@xmldom/xmldom@0.8.2/lib/dom.js"; +import "./window/mod.js"; -{ // Polyfill document. - globalThis.document = new DOMImplementation().createDocument( - xhtmlNamespace, - "html", - null, - ); -} - -{ // Polyfill `element.append()`. - Object.getPrototypeOf( - globalThis.document.documentElement, - ).append = function (...children) { - for (const child of children) { - this.appendChild( - typeof child === "string" - ? this.ownerDocument.createTextNode(child) - : child, - ); - } - }; -} - -{ // Polyfill `Node` and apply patches to `Node.nodeType` and - // `Node.normalize`. - globalThis.Node = Node; - const originalNodeTypeGetter = Object.getOwnPropertyDescriptor( - Node.prototype, - "nodeType", - )?.get ?? (() => { - const originalNodeTypeValue = Node.prototype.nodeType; - return () => originalNodeTypeValue; - })(); - Object.defineProperty(Node.prototype, "nodeType", { - configurable: true, - enumerable: true, - /** - * To simulate restrictions on calling `nodeType` only on actual - * Nodes, check for inheritance first. - */ - get() { - if (!(this instanceof Node)) { - // This is not a Node. - throw new TypeError("nodeType requires this be a Node."); - } else { - // This is a Node; walk the prototype chain and attempt to get - // the node type. - for ( - //deno-lint-ignore no-this-alias - let target = this; - target != null && target != Node.prototype; - target = Object.getPrototypeOf(target) - ) { - if (Object.hasOwn(target, "nodeType")) { - return Reflect.get(target, "nodeType", this); - } else { - continue; - } - } - return originalNodeTypeGetter.call(this); - } - }, - set: undefined, - }); - Node.prototype.normalize = function () { // modified from xmldom - let child = this.firstChild; - while (child) { - const next = child.nextSibling; - if ( - next && next.nodeType == Node.TEXT_NODE && - child.nodeType == Node.TEXT_NODE - ) { - this.removeChild(next); - child.appendData(next.data); - } else { - if (child.nodeType == Node.TEXT_NODE && !child.data) { - this.removeChild(child); - } else { - child.normalize(); - } - child = next; - } - } - }; -} +export { DOMImplementation } from "./window/deps.js"; export { assertStrictEquals } from "https://deno.land/std@0.134.0/testing/asserts.ts"; diff --git a/mod.js b/mod.js index 51acb77..e6913e3 100644 --- a/mod.js +++ b/mod.js @@ -9,63 +9,100 @@ import { xhtmlNamespace } from "./names.js"; -/** - * Create a D·O·M Element from a tagged template. - * - * Usage :— - * - * ```js - * Lemon("elementName", "namespace")`content`; - * // or - * Lemon("elementName", "namespace")({ attribute: "value" })`content`; - * ``` - * - * Content may, via substitutions, include additional nodes. As a - * convenience, if the namespace is not defined, it is the X·H·T·M·L - * namespace. As a convenience, `Lemon.elementName` is a shorthand for - * `Lemon("element-name").` As a convenience, substitutions may take - * the form of an array of nodes. - */ -const Lemon = new Proxy( - Object.defineProperties( - Object.setPrototypeOf( - function (name, namespace = xhtmlNamespace) { - return Object.setPrototypeOf( - nodeTagBuilder.bind({ - name: `${name}`, - namespace: `${namespace}`, - }), - Lemon.prototype, - ); - }, - Function, - ), +const { + /** + * Create a D·O·M Element from a tagged template. + * + * Usage :— + * + * ```js + * Lemon("elementName", "namespace")`content`; + * // or + * Lemon("elementName", "namespace")({ attr: "value" })`content`; + * ``` + * + * Content may, via substitutions, include additional nodes. As a + * convenience, if the namespace is not defined, it is the X·H·T·M·L + * namespace. As a convenience, `Lemon.elementName` is a shorthand + * for `Lemon("element-name").` As a convenience, substitutions may + * take the form of an array of nodes. + * + * By default, `globalThis.document` is used as the owner document + * for new nodes. To pick a different document, supply a different + * `document` on the `this` value when calling. You can use `bind` to + * simplify this in some cases :— + * + * ```js + * const MyLemon = Lemon.bind({ document }); + * MyLemon.p`words`; // same as Lemon.call({ document }, "p")`words` + * ``` + */ + Lemon, +} = (() => { + const lemonProxyHandler = Object.assign( + Object.create(null), { - name: { value: "Lemon" }, - prototype: { value: Object.create(Function.prototype) }, - }, - ), - { - /** 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( - Array.from(function* () { + /** 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; - } + }()].join("")); + } else { + 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, + ); + }, + Function, + ), + { + name: { value: "Lemon" }, + prototype: { value: Object.create(Function.prototype) }, + bind: { + value: Object.defineProperties( + function (thisArg, ...args) { + return new Proxy( + Function.prototype.bind.call( + this, + thisArg, + ...args, + ), + lemonProxyHandler, + ); + }, + { name: { value: "bind" } }, + ), + writable: true, + enumerable: false, + configurable: true, + }, + }, + ), + lemonProxyHandler, + ), + }; +})(); export default Lemon; /** @@ -74,16 +111,19 @@ export default Lemon; * expressions. */ const createNode = function (strings, ...expressions) { - const { attributes, name, namespace } = this; const { raw } = strings; + const { context, name, namespace, attributes } = this; + const document = context.document ?? globalThis.document; const result = document.createElementNS(namespace, name); for (const [attName, attValue] of Object.entries(attributes)) { result.setAttribute(attName, `${attValue}`); } const maxIndex = Math.max(raw.length, expressions.length); for (let index = 0; index < maxIndex; ++index) { - result.append(...nodesFromExpression(raw[index])); - result.append(...nodesFromExpression(expressions[index])); + result.append( + ...nodesFromExpression.call(context, raw[index]), + ...nodesFromExpression.call(context, expressions[index]), + ); } result.normalize(); return result; @@ -98,39 +138,48 @@ const createNode = function (strings, ...expressions) { * empty. */ const nodeTagBuilder = function (attrsOrStrings, ...expressions) { - const { name, namespace } = this; + const { context, name, namespace } = this; if ( Array.isArray(attrsOrStrings) && "raw" in attrsOrStrings && Array.isArray(attrsOrStrings.raw) ) { // The first argument is usable as a template string. return createNode.call( - { attributes: {}, document, name, namespace }, + { context, name, namespace, attributes: {} }, attrsOrStrings, ...expressions, ); } else { // The first argument is not a template string. return createNode.bind({ - attributes: { ...Object(attrsOrStrings) }, - document, + context, name, namespace, + attributes: { ...Object(attrsOrStrings) }, }); } }; /** Processes the provided expression and yields Nodes. */ const nodesFromExpression = function* (expression) { + const document = this.document ?? globalThis.document; + const nodePrototype = Object.getPrototypeOf( + Object.getPrototypeOf(document.createDocumentFragment()), + ); if (expression == null) { // The expression is nullish. /* do nothing */ } else { // The expression is not nullish. const expressionIsNode = (() => { - try { - Reflect.get(Node.prototype, "nodeType", expression); - return true; + // Due to how D·O·M‐to‐Ecmascript bindings work, attempting to + // call a D·O·M method or accessor on a value which is not + // actually of the appropriate type will throw. + // + // This I·I·F·E uses that fact to test whether the provided + // expression is already a Node. + try { // throws unless this is a Node + return !!Reflect.get(nodePrototype, "nodeType", expression); } catch { return false; } @@ -141,7 +190,7 @@ const nodesFromExpression = function* (expression) { } else if (Array.isArray(expression)) { // The expression is an array of expressions. for (const subexpression of expression) { - yield* nodesFromExpression(subexpression); + yield* nodesFromExpression.call(this, subexpression); } } else { // The expression is something else. diff --git a/mod.test.js b/mod.test.js index 1c950ab..8af8f73 100644 --- a/mod.test.js +++ b/mod.test.js @@ -7,7 +7,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at . -import { assertStrictEquals } from "./dev-deps.js"; +import { assertStrictEquals, DOMImplementation } from "./dev-deps.js"; import Lemon from "./mod.js"; Deno.test({ @@ -19,6 +19,20 @@ Deno.test({ }, }); +Deno.test({ + name: + "Lemon can be used to make a new element in a specified document.", + fn: () => { + const doc = new DOMImplementation().createDocument( + null, + "福", + null, + ); + const elt = Lemon.call({ document: doc }, "xxx")({})``; + assertStrictEquals(elt.ownerDocument, doc); + }, +}); + Deno.test({ name: "Lemon uses X·H·T·M·L as the default namespace.", fn: () => { @@ -58,6 +72,22 @@ Deno.test({ }, }); +Deno.test({ + name: "Bound Lemons are also proxied.", + fn: () => { + const doc = new DOMImplementation().createDocument( + null, + "福", + null, + ); + const myLemon = Lemon.bind({ document: doc }); + const elt = myLemon.creamPie({})``; + assertStrictEquals(typeof elt, "object"); + assertStrictEquals(elt.localName, "cream-pie"); + assertStrictEquals(elt.ownerDocument, doc); + }, +}); + Deno.test({ name: "Lemon tags can be destructured.", fn: () => { @@ -90,8 +120,9 @@ Deno.test({ Deno.test({ name: "Lemon tags support all kinds of expression.", fn: () => { - const elt = Lemon.xxx({})` a ${null} b ${Lemon.creamPie({}) - `\t`}${" c "}`; + const elt = Lemon.xxx({})` a ${null} b ${Lemon.creamPie( + {}, + )`\t`}${" c "}`; assertStrictEquals(elt.childNodes.length, 3); assertStrictEquals(elt.childNodes[0].nodeType, 3); assertStrictEquals(elt.childNodes[0].textContent, " a b "); diff --git a/window/deps.js b/window/deps.js new file mode 100644 index 0000000..f1d4237 --- /dev/null +++ b/window/deps.js @@ -0,0 +1,14 @@ +// 🍋🏷 Lemon ∷ window/deps.js +// ==================================================================== +// +// Copyright © 2022 Lady [@ Lady’s Computer]. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at . + +export { + DOMImplementation, + Node, +} from "https://esm.sh/@xmldom/xmldom@0.8.2/lib/dom.js"; +export { xhtmlNamespace } from "../names.js"; diff --git a/window/mod.js b/window/mod.js new file mode 100644 index 0000000..5e97d4f --- /dev/null +++ b/window/mod.js @@ -0,0 +1,101 @@ +// 🍋🏷 Lemon ∷ window/mod.js +// ==================================================================== +// +// Copyright © 2022 Lady [@ Lady’s Computer]. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at . + +import { DOMImplementation, Node, xhtmlNamespace } from "./deps.js"; + +{ // Polyfill document. + globalThis.document = new DOMImplementation().createDocument( + xhtmlNamespace, + "html", + null, + ); + const { documentElement } = document; + documentElement.appendChild( + document.createElementNS(xhtmlNamespace, "head"), + ); + documentElement.appendChild( + document.createElementNS(xhtmlNamespace, "body"), + ); +} + +{ // Polyfill `element.append`. + Object.getPrototypeOf( + globalThis.document.documentElement, + ).append = function (...children) { + for (const child of children) { + this.appendChild( + typeof child === "string" + ? this.ownerDocument.createTextNode(child) + : child, + ); + } + }; +} + +{ // Apply patches to `Node.nodeType` and `Node.normalize`. + const { TEXT_NODE, prototype: nodePrototype } = Node; + const originalNodeTypeGetter = Object.getOwnPropertyDescriptor( + nodePrototype, + "nodeType", + )?.get ?? (() => { + const originalNodeTypeValue = nodePrototype.nodeType; + return () => originalNodeTypeValue; + })(); + Object.defineProperty(nodePrototype, "nodeType", { + configurable: true, + enumerable: true, + /** + * To simulate restrictions on calling `nodeType` only on actual + * Nodes, check for inheritance first. + */ + get() { + if (!(this instanceof Node)) { + // This is not a Node. + throw new TypeError("nodeType requires this be a Node."); + } else { + // This is a Node; walk the prototype chain and attempt to get + // the node type. + for ( + //deno-lint-ignore no-this-alias + let target = this; + target != null && target != nodePrototype; + target = Object.getPrototypeOf(target) + ) { + if (Object.hasOwn(target, "nodeType")) { + return Reflect.get(target, "nodeType", this); + } else { + continue; + } + } + return originalNodeTypeGetter.call(this); + } + }, + set: undefined, + }); + nodePrototype.normalize = function () { + let child = this.firstChild; + while (child) { + const next = child.nextSibling; + if ( + next && next.nodeType == TEXT_NODE && + child.nodeType == TEXT_NODE + ) { + this.removeChild(next); + child.appendData(next.data); + } else { + if (child.nodeType == TEXT_NODE && !child.data) { + this.removeChild(child); + } else { + child.normalize(); + } + child = next; + } + } + }; +}