* 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
*/
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) {
})();
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
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}`);
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()),
);
}
}
};
+
+/**
+ * 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;
+ }
+ }(),
+ );