]> Lady’s Gitweb - Lemon/blob - mod.js
Add method chaining syntax for attributes
[Lemon] / mod.js
1 // 🍋🏷 Lemon ∷ mod.js
2 // ====================================================================
3 //
4 // Copyright © 2022 Lady [@ Lady’s Computer].
5 //
6 // This Source Code Form is subject to the terms of the Mozilla Public
7 // License, v. 2.0. If a copy of the MPL was not distributed with this
8 // file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
9
10 import { xhtmlNamespace } from "./names.js";
11
12 const {
13 /**
14 * Create a D·O·M Element from a tagged template.
15 *
16 * Usage :—
17 *
18 * ```js
19 * Lemon("elementName", "namespace")`content`;
20 * // or
21 * Lemon("elementName", "namespace")({ attr: "value" })`content`;
22 * // or
23 * Lemon("elementName", "namespace").attr("value")`content`;
24 * ```
25 *
26 * Content may, via substitutions, include additional nodes. As a
27 * convenience, if the namespace is not defined, it is the X·H·T·M·L
28 * namespace. As a convenience, `Lemon.elementName` is a shorthand
29 * for `Lemon("element-name").` As a convenience, substitutions may
30 * take the form of an array of nodes.
31 *
32 * By default, `globalThis.document` is used as the owner document
33 * for new nodes. To pick a different document, supply a different
34 * `document` on the `this` value when calling. You can use `bind` to
35 * simplify this in some cases :—
36 *
37 * ```js
38 * const MyLemon = Lemon.bind({ document });
39 * MyLemon.p`words`; // same as Lemon.call({ document }, "p")`words`
40 * ```
41 */
42 Lemon,
43 } = (() => {
44 const lemonProxyHandler = Object.assign(Object.create(null), {
45 /** If `P` doesn’t exist on `O`, calls `O` with `P` instead. */
46 get(O, P, Receiver) {
47 if (typeof P != "string" || Reflect.has(O, P)) {
48 // P is not a string or else exists on O.
49 return Reflect.get(O, P, Receiver);
50 } else if (typeof O == "function") {
51 // P is a string and O is a function.
52 return O(toNodeName(P));
53 } else {
54 // P is a string and O is not a function.
55 return undefined;
56 }
57 },
58 });
59
60 return {
61 Lemon: new Proxy(
62 Object.defineProperties(
63 Object.setPrototypeOf(
64 function (name, namespace = xhtmlNamespace) {
65 return makeLemon.call({
66 context: this ?? globalThis,
67 name: `${name}`,
68 namespace: `${namespace ?? ""}`,
69 attributes: Object.create(null),
70 });
71 },
72 Function,
73 ),
74 {
75 name: { value: "Lemon" },
76 prototype: {
77 value: Object.create(
78 Function.prototype,
79 {
80 attributes: {
81 get: function () {
82 return Object.assign(
83 Object.create(null),
84 Object(getMemo(this).attributes),
85 );
86 },
87 set: undefined,
88 enumerable: false,
89 configurable: true,
90 },
91 localName: {
92 get: function () {
93 const { name } = getMemo(this);
94 return name.substring(name.indexOf(":") + 1);
95 },
96 set: undefined,
97 enumerable: false,
98 configurable: true,
99 },
100 namespaceURI: {
101 get: function () {
102 return getMemo(this).namespace || null;
103 },
104 set: undefined,
105 enumerable: false,
106 configurable: true,
107 },
108 ownerDocument: {
109 get: function () {
110 return getMemo(this).context?.document ??
111 globalThis.document;
112 },
113 set: undefined,
114 enumerable: false,
115 configurable: true,
116 },
117 qualifiedName: {
118 get: function () {
119 return getMemo(this).name;
120 },
121 set: undefined,
122 enumerable: false,
123 configurable: true,
124 },
125 },
126 ),
127 },
128 bind: {
129 value: Object.defineProperties(
130 function (thisArg, ...args) {
131 return new Proxy(
132 Function.prototype.bind.call(
133 this,
134 thisArg,
135 ...args,
136 ),
137 lemonProxyHandler,
138 );
139 },
140 { name: { value: "bind" } },
141 ),
142 writable: true,
143 enumerable: false,
144 configurable: true,
145 },
146 },
147 ),
148 lemonProxyHandler,
149 ),
150 };
151 })();
152 export default Lemon;
153
154 /** An object with a retrievable memo value. */
155 class Memoized extends function ($) {
156 return $;
157 } {
158 #memo;
159
160 /**
161 * Remembers the provided memo as a private class feature on the
162 * provided object.
163 */
164 constructor(object, memo) {
165 super(object);
166 this.#memo = memo;
167 }
168
169 /** Retrieves the remembered memo from this object. */
170 get memo() {
171 return this.#memo;
172 }
173 }
174
175 /**
176 * Creates an Element with `name`, `namespace`, and `attributes` drawn
177 * from this object and content determined by the provided strings and
178 * expressions.
179 */
180 const createNode = function (strings, ...expressions) {
181 const { raw } = strings;
182 const { context, name, namespace, attributes } = this;
183 const document = context?.document ?? globalThis.document;
184 const result = document.createElementNS(namespace, name);
185 for (const [attName, attValue] of Object.entries(attributes)) {
186 result.setAttribute(attName, `${attValue}`);
187 }
188 const maxIndex = Math.max(raw.length, expressions.length);
189 for (let index = 0; index < maxIndex; ++index) {
190 result.append(
191 ...nodesFromExpression.call(context, raw[index]),
192 ...nodesFromExpression.call(context, expressions[index]),
193 );
194 }
195 result.normalize();
196 return result;
197 };
198
199 /** Retrieves the remembered memoized value from this object. */
200 const getMemo = ($) => Reflect.get(Memoized.prototype, "memo", $);
201
202 /** Returns a new Lemon proxy function bound to this value. */
203 const makeLemon = function (attName = null) {
204 const { attributes: oldAttributes, ...restOfThis } = this;
205 const boundThis = Object.assign(
206 Object.create(null),
207 restOfThis,
208 {
209 attributes: attName == null
210 ? Object(oldAttributes)
211 : mergeAttributes(oldAttributes, { [attName]: "" }),
212 },
213 );
214 return new Memoized(
215 new Proxy(
216 Object.defineProperties(
217 Object.setPrototypeOf(
218 nodeTagBuilder.bind(boundThis, attName),
219 Lemon.prototype,
220 ),
221 {
222 name: {
223 value: attName == null
224 ? toStartTag.call(boundThis)
225 : toStartTag.call(boundThis) +
226 ` /* pending ${attName} */`,
227 },
228 length: { value: 1 },
229 },
230 ),
231 Object.assign(
232 Object.create(null),
233 {
234 /**
235 * If `P` doesn’t exist on `O`, returns a bound `makeLemon`
236 * instead.
237 */
238 get(O, P, Receiver) {
239 if (typeof P != "string" || Reflect.has(O, P)) {
240 // P is not a string or else exists on O.
241 return Reflect.get(O, P, Receiver);
242 } else {
243 // P is a string which does not exist on O.
244 return makeLemon.call(boundThis, toNodeName(P));
245 }
246 },
247 },
248 ),
249 ),
250 boundThis,
251 );
252 };
253
254 /**
255 * Merges the provided attributes objects onto a new object and returns
256 * that object.
257 *
258 * There is special handling when the attribute value is empty or when
259 * it matches the attribute name. A false attribute value deletes the
260 * attribute, while an undefined one is treated as the empty string.
261 */
262 const mergeAttributes = (base, others) => {
263 const attributes = Object.assign(Object.create(null), Object(base));
264 for (const [attName, attValue] of Object.entries(others)) {
265 if (attValue === false) {
266 delete attributes[attName];
267 } else {
268 const value = attValue === true || attValue === undefined
269 ? ""
270 : `${attValue}`;
271 if (attName in attributes) {
272 const oldValue = attributes[attName];
273 if (value == "" || value == attName && value == oldValue) {
274 /* do nothing */
275 } else {
276 attributes[attName] = oldValue
277 ? `${oldValue} ${value}`
278 : value;
279 }
280 } else {
281 attributes[attName] = value;
282 }
283 }
284 }
285 return attributes;
286 };
287
288 /**
289 * Returns a new template tag builder with the provided attributes for
290 * for making Element’s with the `name` specified on this.
291 *
292 * As a shorthand, you can also use this function as a template tag
293 * itself, in which case it is treated as though its attributes were
294 * empty.
295 */
296 const nodeTagBuilder = function (
297 pending,
298 attrsOrStrings,
299 ...expressions
300 ) {
301 const { attributes: oldAttributes, ...restOfThis } = this;
302 if (Object(attrsOrStrings) !== attrsOrStrings) {
303 // attrsOrStrings is a primitive.
304 if (pending != null) {
305 return makeLemon.call({
306 ...restOfThis,
307 attributes: mergeAttributes(oldAttributes, {
308 [pending]: attrsOrStrings,
309 }),
310 });
311 } else {
312 throw new TypeError(
313 "Cannot set attribute value when no name is pending.",
314 );
315 }
316 } else if (
317 Array.isArray(attrsOrStrings) && "raw" in attrsOrStrings &&
318 Array.isArray(attrsOrStrings.raw)
319 ) {
320 // attrsOrStrings is usable as a template string.
321 return createNode.call(
322 { ...restOfThis, attributes: Object(oldAttributes) },
323 attrsOrStrings,
324 ...expressions,
325 );
326 } else {
327 // attrsOrStrings is not a template string.
328 return makeLemon.call({
329 ...restOfThis,
330 attributes: mergeAttributes(oldAttributes, attrsOrStrings),
331 });
332 }
333 };
334
335 /** Processes the provided expression and yields Nodes. */
336 const nodesFromExpression = function* (expression) {
337 const document = this?.document ?? globalThis.document;
338 const nodePrototype = Object.getPrototypeOf(
339 Object.getPrototypeOf(document.createDocumentFragment()),
340 );
341 if (expression == null) {
342 // The expression is nullish.
343 /* do nothing */
344 } else {
345 // The expression is not nullish.
346 const expressionIsNode = (() => {
347 // Due to how D·O·M‐to‐Ecmascript bindings work, attempting to
348 // call a D·O·M method or accessor on a value which is not
349 // actually of the appropriate type will throw.
350 //
351 // This I·I·F·E uses that fact to test whether the provided
352 // expression is already a Node.
353 try { // throws unless this is a Node
354 return !!Reflect.get(nodePrototype, "nodeType", expression);
355 } catch {
356 return false;
357 }
358 })();
359 if (expressionIsNode) {
360 // The expression is already a `Node`.
361 yield expression;
362 } else if (Array.isArray(expression)) {
363 // The expression is an array of expressions.
364 for (const subexpression of expression) {
365 yield* nodesFromExpression.call(this, subexpression);
366 }
367 } else {
368 // The expression is something else.
369 yield document.createTextNode(`${expression}`);
370 }
371 }
372 };
373
374 /**
375 * Returns the provided value escaped such that it is usable in a
376 * double‐quoted attribute value string.
377 */
378 const toAttributeValue = (value) =>
379 `${value}`.replaceAll(/[\x22\x26\x3C\x3E]/gu, ($) => {
380 switch ($) {
381 case "\x26":
382 return "&amp;";
383 case "\x22":
384 return "&quot;";
385 case "\x3C":
386 return "&lt;";
387 case "\x3E":
388 return "&gt;";
389 }
390 });
391
392 /** Returns an X·M·L start tag derived from this.*/
393 const toStartTag = function () {
394 const name = `${this.name}`;
395 const namespace = `${this.namespace ?? ""}`;
396 const attributes = Object(this.attributes);
397 return "".concat(
398 ...function* () {
399 yield `<${name}`;
400 if (namespace) {
401 // A namespace was specified.
402 const colonIndex = name.indexOf(":");
403 if (colonIndex != -1) {
404 // The node name has a prefix.
405 yield ` xmlns:${name.substring(0, colonIndex)}="${
406 toAttributeValue(namespace)
407 }"`;
408 } else {
409 // The node name is unprefixed.
410 yield ` xmlns="${toAttributeValue(namespace)}"`;
411 }
412 } else {
413 // No namespace was specified.
414 /* do nothing */
415 }
416 for (const [attName, attValue] of Object.entries(attributes)) {
417 // Iterate over attributes and serialize them.
418 yield ` ${attName}="${toAttributeValue(attValue)}"`;
419 }
420 yield ">";
421 }(),
422 );
423 };
424
425 /**
426 * Returns a node name derived from the provided Ecmascript property
427 * name by lowercasing uppercase ascii letters and prefixing them with
428 * a hyphen.
429 */
430 const toNodeName = (ecmascriptName) =>
431 "".concat(
432 ...function* () {
433 for (const character of ecmascriptName) {
434 yield /[A-Z]/u.test(character)
435 ? `-${character.toLowerCase()}`
436 : character;
437 }
438 }(),
439 );
This page took 0.184771 seconds and 5 git commands to generate.