-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcdaTemplate.js
560 lines (560 loc) · 20.8 KB
/
cdaTemplate.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
"use strict";
var cdaTemplate = (function () {
//+---------------------------+
//| Data Injection Functions |
//+---------------------------+
// Custom Data Attribute -> Data Injector Function
const _injectors = {
// Sets the `alt` attribute of the element.
"data-alt": function (input, target) {
target.setAttribute("alt", input);
},
// Sets the `class` attribute of the element.
"data-class": function (input, target) {
target.setAttribute("class", input);
},
// Inserts the data into the element as document nodes.
"data-content": function (input, target, pos = false) {
if (!pos) {
pos = "afterbegin";
empty(target);
}
target.insertAdjacentHTML(pos, input);
},
// Appends the data to the element's children as document nodes.
"data-content-append": function (input, target) {
_injectors["data-content"](input, target, "beforeend");
},
// Prepends the data to the element's children as document nodes.
"data-content-prepend": function (input, target) {
_injectors["data-content"](input, target, "afterbegin");
},
// Inserts data into the element as a text node.
"data-content-text": function (input, target, pos = false) {
if (!pos) {
pos = "afterbegin";
empty(target);
}
target.insertAdjacentText(pos, String(input));
},
// Appends the data to the element's children as a text node.
"data-content-text-append": function (input, target) {
_injectors["data-content-text"](input, target, "beforeend");
},
// Prepends the data to the element's children as a text node.
"data-content-text-prepend": function (input, target) {
_injectors["data-content-text"](input, target, "afterbegin");
},
// Sets the `for` attribute of the element.
"data-for": function (input, target) {
target.setAttribute("for", input);
},
// Sets the `href` attribute of the element.
"data-href": function (input, target) {
target.setAttribute("href", input);
},
// Sets the `id` attribute of the element.
"data-id": function (input, target) {
target.setAttribute("id", input);
},
// Wraps the element's contents in an `<a>` tag and sets it's `href` attribute.
"data-link": function (input, target) {
var a = document.createElement("a");
a.setAttribute("href", input);
a.insertAdjacentHTML("afterbegin", target.innerHTML);
empty(target);
target.insertAdjacentElement("afterbegin", a);
},
// Wraps the entire element in an `<a>` tag and sets it's `href` attribute.
"data-link-wrap": function (input, target) {
var a = document.createElement("a");
a.setAttribute("href", input);
a.insertAdjacentElement("afterbegin", target.cloneNode(true));
target.parentNode.replaceChild(a, target);
},
// Sets the `style` attribute of the element.
"data-style": function (input, target) {
target.setAttribute("style", input);
},
// Sets the `value` attribute of the element.
"data-value": function (input, target) {
target.setAttribute("value", input);
}
};
//+---------------------------+
//| Generic Utility Functions |
//+---------------------------+
/**
* ajax - A Promise wrapper for XMLHttpRequest
* @param {String} url = "/" A web address, URL or URI.
* @param {String} resType = 'text' The server response type.
* @param {Mixed} data = null Data to send to the server.
* @return {Promise} Resolved with response or Rejected with error.
*/
function ajax(url = "/") {
return new Promise(function (resolve, reject) {
var req = new XMLHttpRequest();
req.onreadystatechange = function () {
if (req.readyState === 4) {
if (req.status >= 200 && req.status < 400) {
resolve(req.response);
} else {
return reject(new Error("XHR HTTP Error " + req.status + ": " + req.statusText));
}
}
};
req.open("GET", url);
req.responseType = "text";
req.setRequestHeader("X-Requested-With", "XMLHttpRequest");
req.send();
});
}
// For empty callback parameters
const _noOp = () => { };
/**
* assert - Logs or throws an Error if `boolean` is false,
* If `boolean` is `true`, nothing happens.
* If `errorType` is set, throws a new Error of type `errorType` instead of logging to console.
* @param {Boolean} boolean The activation Boolean.
* @param {String} message The message to log, or include in the Error.
* @param {Error} errorType = null If not `null`, throws a new error of `errorType`.
*/
function assert(boolean, message, errorType = null) {
if (!boolean) {
if (errorType !== null && (errorType === Error || Error.isPrototypeOf(errorType))) {
throw new errorType(message);
} else {
console.error(message);
}
}
}
// Thunks to `assert` for method argument type checking.
assert.argType = (boolean, typeString, argName) => assert(boolean, "Argument " + argName + " must be " + typeString, TypeError);
assert.string = (input, argName) => assert.argType(typeof input === "string", "a String", argName);
assert.function = (input, argName) => assert.argType(typeof input === "function", "a Function", argName);
assert.object = (input, argName) => assert.argType(isObject(input), "an Object", argName);
assert.array = (input, argName) => assert.argType(Array.isArray(input), "an Array", argName);
assert.container = (input, argName) => assert.argType(isContainer(input), "an Object or Array", argName);
/**
* isContainer - Returns `true` if `input` is an Object or Array, otherwise returns `false`.
* @param {any} input
* @returns {Boolean}
*/
function isContainer(input) {
return (input !== null && (isObject(input) || Array.isArray(input)));
}
/**
* isObject - Returns `true` if `input` is an Object and not an Array, or `false` if otherwise.
* @param {any} input
* @returns {Boolean}
*/
function isObject(input) {
return (input !== null && typeof (input) === "object" && !Array.isArray(input));
}
/**
* dataAttr - Returns a W3C valid Data Attribute Name based on `string`.
* @param {String} string The original String to base the new one off of.
* @return {String} The new Data Attribute Name String.
*/
function dataAttr(string) {
string = string.trim().replace(/\s+/g, "-").toLowerCase();
return string.substring(0, 5) === "data-" ? string : "data-" + string;
}
/**
* empty - Deletes all the child nodes of an element.
* @param {Element} target The Element to empty.
*/
function empty(target) {
while (target.hasChildNodes()) {
target.removeChild(target.lastChild);
}
}
/**
* newFragmentClone - Clone the child nodes of an element into a new Document Fragment.
* @param {Element} sourceElem The Element to Clone.
* @return {DocumentFragment} The clone of sourceElem.
*/
function newFragmentClone(sourceElem) {
const frag = document.createDocumentFragment();
for (var node of sourceElem.childNodes.values()) {
frag.appendChild(node.cloneNode(true));
}
return frag;
}
/**
* newFragmentParse - Parses an HTML string into DOM nodes in a Document Fragment.
* @param {String} tagString Parse an HTML string into a new Document Fragment.
* @return {DocumentFragment} Contains the DOM parsed from `tagString`.
*/
function newFragmentParse(tagString) {
return document.createRange().createContextualFragment(tagString);
}
//+---------------------------+
//| Engine-Specific Functions |
//+---------------------------+
// Document Fragment Cache
function Cache() {
this.docs = [];
}
// Resets internal array to an empty one.
Cache.prototype.reset = function () {
this.docs = [];
};
// Checks if a document named `id` is in the cache.
Cache.prototype.hasDoc = function (id) {
this.docs.indexOf(id) !== -1;
};
// Returns a cloned DocumentFragment or `null` if one was not found.
Cache.prototype.getDoc = function (id) {
var index = this.docs.indexOf(id);
if (index !== -1) {
return this.docs[index].cloneNode(true);
} else {
return null;
}
};
// Saves a clone of `doc` named `id` to the cache.
Cache.prototype.saveDoc = function (id, doc) {
this.docs[this.docs.length] = doc.cloneNode(true);
};
/**
* insertTemplate - Insert a Template into destElems
* @param {DocumentFragment} templDoc The templates, combined into a single DocumentFragment.
* @param {Array<Node>|NodeList} destElems Destination Node(s). An Array of Nodes, or a NodeList.
* @param {Object} conf Configuration.
* @return {Array<Node>} The inserted Template Node(s).
*/
function insertTemplates(templDoc, destElems, conf) {
// Before insertion Callback
conf.beforeInsert();
// Insert the Templates into destElement
const insertedTemplates = [];
// Only proceed if we have some nodes to insert
if (templDoc.hasChildNodes()) {
let errorStatus = false;
// Iterate through the destinations for insertion
for (var destElem of (Array.isArray(destElems) ? destElems[Symbol.iterator]() : destElems.values())) {
let liveTmpl = false;
if (conf.prepend) {
// Prepend the destination's children
liveTmpl = destElem.insertBefore(templDoc.cloneNode(true), destElem.firstChild);
} else {
if (!conf.append) {
// Overwrite the destination's children
empty(destElem);
}
// Append the destination's children
liveTmpl = destElem.appendChild(templDoc.cloneNode(true));
}
if (liveTmpl != false) {
insertedTemplates[insertedTemplates.length] = liveTmpl;
} else {
errorStatus = true;
break;
}
}
if (!errorStatus) {
conf.afterInsert(insertedTemplates);
conf.success(insertedTemplates);
} else {
if (conf.error && (conf.error instanceof Function)) {
conf.error();
} else {
destElem.insertAdjacentText("beforeend", document.createTextNode(conf.errorMessage));
}
}
}
conf.complete();
return insertedTemplates;
}
/**
* getData - Get relevant data to the current template load.
* @param {Object} conf Configuration.
* @return {Array} An ordered queue of data to inject into templates.
*/
function getData(conf) {
// Paginate data
if (conf.paged) {
// We can safely assume `conf.data` is an Array at this point, as it gets checked in the public interface.
const pageCount = Math.ceil(conf.data.length / conf.elemPerPage);
if (conf.pageNo >= pageCount && conf.data !== null && conf.data.length > 0) {
return conf.data.slice((conf.pageNo - 1) * conf.elemPerPage, conf.elemPerPage + conf.pageNo);
} else {
return null;
}
} else {
return [conf.data];
}
}
/**
* injectData - Format & inject data into templDoc.
* @param {Array<DocumentFragment>} templDocs The unpopulated template DocumentFragments.
* @param {Object} conf Configuration.
* @return {DoucmentFragment} All prepared templates in a single DocumentFragment.
*/
function injectData(templDocs, conf) {
var preparedDoc = document.createDocumentFragment();
const dataQueue = getData(conf);
if (templDocs.length > 0) {
// Iterate the templates and data, for data injection
_iterTemplates: for (var loc = 0; loc < templDocs.length; loc++) {
const templDoc = templDocs[loc];
if (dataQueue !== null && loc < dataQueue.length) {
var curData = dataQueue[loc];
} else {
var curData = null;
}
if (curData === null) {
continue;
}
if (!templDoc.hasChildNodes()) {
continue;
}
// Iterate the data injectors and look for them in the `templDoc`
_iterAttributes: for (var dataAttr in conf.injectors) {
const dataNodes = templDoc.querySelectorAll("[" + dataAttr + "]");
if (dataNodes.length === 0) {
continue;
}
for (var dataNode of dataNodes.values()) {
// NOTE: For non-existent attrs, `getAttribute` may return `null` OR an empty string, depending on DOM Core.
// Get data formatter attribute.
const formatTag = dataNode.getAttribute(dataAttr + "-format");
if (formatTag !== null && formatTag !== "" && formatTag in conf.formatters) {
// Format data.
curData = conf.formatters[formatTag](curData);
}
// Get data injection attribute.
const templTag = dataNode.getAttribute(dataAttr);
if (templTag !== null && templTag !== "" && templTag in curData) {
if (conf.removeAttr) {
dataNode.removeAttribute(dataAttr);
}
// Inject data.
conf.injectors[dataAttr](curData[templTag], dataNode);
}
}
}
if (conf.paged && conf.elemPerPage > 1) {
// If we have multiple templates
preparedDoc.appendChild(templDoc);
} else {
preparedDoc = templDoc;
}
}
}
return preparedDoc;
}
/**
* getDestinations - Get destination Node(s) from the DOM.
* @param {String} destSel Destination QuerySelector.
* @param {Object} conf Configuration.
* @return {Array<Node>|NodeList} Destination Node(s). An Array of Nodes, or a NodeList.
*/
function getDestinations(destSel, conf) {
if (conf.multiDest) {
var destElems = document.querySelectorAll(destSel);
assert(destElems !== null && destElems.length > 0, "Template Destination \"" + destSel + "\" not found.", Error);
} else {
var destElems = [document.querySelector(destSel)];
assert(destElems !== null, "Template Destination \"" + destSel + "\" not found.", Error);
}
return destElems;
}
/**
* getTemplateXHR - Get template from a remote file.
* @param {String} templLoc URL of the Template HTML file.
* @param {Array} destElems Array of Destination Elements.
* @param {Object} conf Configuration.
* @return {Promise} Resolves with Template DocumentFragment.
*/
function getTemplateXHR(templUrl, conf) {
return conf.ajax(templUrl).then(tagString => newFramgmentParse(tagString));
}
/**
* getTemplateDOM - Get template from a DOM node.
* @param {String} templLoc Template QuerySelector.
* @param {Array} destElems Array of Destination Elements.
* @param {Object} conf Configuration.
* @return {DocumentFragment} Document containing the Template.
*/
function getTemplateDOM(templLoc, destElems, conf) {
var templElem = document.querySelector(templLoc);
assert(templElem !== null, "Template \"" + templLoc + "\" not found.", Error);
// Load the template into a new Document Fragment
var tagName = templElem.tagName;
var childNodes = templElem.childNodes;
if ((tagName == "TEMPLATE" || tagName == "SCRIPT") && (childNodes.length === 1 && childNodes[0].nodeType === 3)) {
// Get template from a script/template element
// Convert the template text node into document nodes
return newFragmentParse(templElem.textContent);
} else {
// Get template from a live node
return newFragmentClone(templElem);
}
}
/**
* loadTemplate - Load, prepare, and insert a Template into destElem(s).
* @param {String} templLoc Template QuerySelector or URI/URL.
* @param {String} destSel Destination QuerySelector.
* @param {Object} conf Configuration.
* @return {Array<Node>|Promise} An array of inserted template Nodes, or a Promise which resolves with that.
*/
function loadTemplate(templLoc, destSel, conf) {
var destElems = getDestinations(destSel, conf);
// Handles cache saving, pagination, and continuing control flow for injection/insertion.
function insertionThunk(templDoc) {
// Check if we need to save the template to the cache.
// `cachedDoc` is hoisted from below this function.
if (cachedDoc === null) {
conf.cache.saveDoc(templLoc, templDoc);
}
// Duplicate the template for pagination
var templDocs = [];
if (conf.paged && conf.elemPerPage > 1) {
for (var loc = 0; loc < conf.elemPerPage; loc++) {
templDocs[templDocs.length] = templDoc.cloneNode(true);
}
} else {
templDocs = [templDoc];
}
// Formats & injects data into `templDocs`, inserts them into `destElems`, and returns an array of inserted live Nodes.
return insertTemplates(injectData(templDocs, conf), destElems, conf);
}
var cachedDoc = null;
if (!conf.overwriteCache) {
cachedDoc = conf.cache.getDoc(templLoc);
}
if (cachedDoc !== null) {
if (conf.isFile || conf.async) {
// Asynchronous
return Promise.resolve(insertionThunk(cachedDoc));
}
// Synchronous
return insertionThunk(cachedDoc);
}
if (conf.isFile) {
// Asynchronous XHR
return getTemplateXHR(templLoc, conf).then(insertionThunk);
}
if (conf.async) {
// Asynchronous
return Promise.resolve(insertionThunk(getTemplateDOM(templLoc)));
}
// Synchronous
return insertionThunk(getTemplateDOM(templLoc));
}
// The default Configuration Object
function newConfiguration() {
return {
afterInsert: _noOp,
ajax: ajax,
append: false,
async: false,
beforeInsert: _noOp,
cache: new Cache(),
complete: _noOp,
data: null,
elemPerPage: 10,
error: false,
errorMessage: "There was an error loading the template.",
formatters: {},
injectors: Object.assign({}, _injectors),
isFile: false,
multiDest: false,
overwriteCache: false,
paged: false,
pageNo: 1,
prepend: false,
removeAttr: true,
success: _noOp
};
}
//+---------------------------+
//| Public Interface |
//+---------------------------+
function _interface(conf = {}) {
assert.object(conf, 1);
this.conf = Object.assign({}, newConfiguration(), conf);
}
/**
* addInjector - Adds a custom Data Injection Attriute and node-mutating callback.
* The `data` prefix is automatically added if it is missing.
* @param {String} name The name of the attribute, with or without a `data` prefix.
* @param {injectorCallback} callback The injector callback to run. Accepts two parameters, `input` and `target`.
* @callback injectorCallback This callback can mutate `target` directly, via the DOM API.
* @param {Mixed} input A value to be injected into `target`.
* @param {Node} target The target Node to inject `input` into.
*/
_interface.prototype.addInjector = function (name, callback) {
assert.string(name, 1);
assert.function(callback, 2);
this.conf.injectors[dataAttr(name)] = callback;
};
/**
* addFormatter - Adds a Formatter callback to the config object.
* It should not attempt to mutate the `value` parameter directly.
* @param {String} name Name of the formatter.
* @param {formatterCallback} callback The formatter callback, accepts one "value" parameter.
* @callback formatterCallback
* @param {Mixed} value Data to be formatted.
* @return {Mixed} The formatted Data.
*/
_interface.prototype.addFormatter = function (name, callback) {
assert.string(name, 1);
assert.function(callback, 2);
this.conf.formatters[name] = callback;
};
/**
* loadTemplate - Load a clone of template `templSel`, inject it with data, and insert it into `destSel`.
* Synchronous; returns the inserted Template.
* @param {String} templSel Template QuerySelector.
* @param {String} destSel Destination QuerySelector.
* @param {Object} conf = {} Configuration.
* @return {Array<Node>|Promise} An array of inserted template Nodes, or a Promise which resolves with that.
*/
_interface.prototype.loadTemplate = function (templSel, destSel, conf = {}) {
assert.string(templSel, 1);
assert.string(destSel, 2);
assert.object(conf, 3);
if (conf.data !== null) {
if (conf.paged) {
assert.array(conf.data, "configuration.data (When `paged` is set to `true`)");
} else {
assert.object(conf.data, "configuration.data (When `paged` is set to `false`)");
}
}
const runConf = Object.assign({}, this.conf, conf);
if (runConf.isFile || !runConf.async) {
// If `isFile` is set to `true`, this should return a Promise. Otherwise, an array of inserted live template Nodes.
return loadTemplate(templSel, destSel, runConf);
} else if (runConf.async) {
return Promise.resolve(loadTemplate(tempSel, destSel, runConf));
}
};
/**
* loadTemplateAsync - Load a Template in a Promise.
* Asynchronous; returns a Promise Resolved with the inserted Template.
* @param {String} templSel Template QuerySelector.
* @param {String} destSel Destination QuerySelector.
* @param {Object} conf = {} Configuration.
* @return {Promise} Resolves with an Array of live inserted template Nodes.
*/
_interface.prototype.loadTemplateAsync = function (templSel, destSel, conf = {}) {
assert.object(conf, 3);
return this.loadTemplate(templSel, destSel, Object.assign({}, conf, { async: true }));
};
/**
* loadTemplateXhr - Load a Template with Ajax.
* Asynchronous; returns a Promise Resolved with the inserted Template.
* @param {String} url Template URL/URI.
* @param {String} destSel Destination QuerySelector.
* @param {Object} conf = {} Configuration.
* @return {Promise} Resolves with an Array of live inserted template Nodes.
*/
_interface.prototype.loadTemplateXhr = function (url, destSel, conf = {}) {
assert.object(conf, 3);
return this.loadTemplate(url, destSel, Object.assign({}, conf, { isFile: true }));
};
return _interface;
})();