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,
- ),
- {
- name: { value: "Lemon" },
- prototype: { value: Object.create(Function.prototype) },
- },
- ),
- {
+const {
+ /**
+ * Create a D·O·M Element from a tagged template.
+ *
+ * Usage :—
+ *
+ * ```js
+ * 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
+ * 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), {
/** 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") {
- return O(
- Array.from(function* () {
- for (const character of P) {
- yield /[A-Z]/u.test(character)
- ? `-${character.toLowerCase()}`
- : character;
- }
- }()).join(""),
- );
+ // 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 makeLemon.call({
+ context: this ?? globalThis,
+ name: `${name}`,
+ namespace: `${namespace ?? ""}`,
+ attributes: Object.create(null),
+ });
+ },
+ Function,
+ ),
+ {
+ name: { value: "Lemon" },
+ 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) {
+ return new Proxy(
+ Function.prototype.bind.call(
+ this,
+ thisArg,
+ ...args,
+ ),
+ lemonProxyHandler,
+ );
+ },
+ { name: { value: "bind" } },
+ ),
+ writable: true,
+ enumerable: false,
+ configurable: true,
+ },
+ },
+ ),
+ lemonProxyHandler,
+ ),
+ };
+})();
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
* 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;
};
+/** 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 { 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(
- { attributes: {}, document, name, namespace },
+ { ...restOfThis, attributes: Object(oldAttributes) },
attrsOrStrings,
...expressions,
);
} else {
- // The first argument is not a template string.
- return createNode.bind({
- attributes: { ...Object(attrsOrStrings) },
- document,
- name,
- namespace,
+ // 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 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;
}
} 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.
}
}
};
+
+/**
+ * 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;
+ }
+ }(),
+ );