jdom.js annotated source
Back to indexShortcut utility function to check if a given name is
bound to something that's an actual object (not just null)
We perform the null
check first because it's faster.
4const isObject = obj => obj !== null && typeof obj === 'object';
5
Clip the end of a given string by the length of a substring
7const clipStringEnd = (base, substr) => {
8 return base.substr(0, base.length - substr.length);
9}
10
This allows us to write HTML entities like '<' and '>' without breaking the HTML parser.
13const decodeEntity = entity => {
14 return String.fromCodePoint((+(/&#(\w+);/).exec(entity)[1]));
15}
16
Interpolate between lists of strings into a single string. Used to merge the two parts of a template tag's arguments.
19const interpolate = (tplParts, dynamicParts) => {
20 let str = tplParts[0];
21 for (let i = 1, len = dynamicParts.length; i <= len; i ++) {
22 str += dynamicParts[i - 1] + tplParts[i];
23 }
24 return str;
25}
26
The Reader
class represents a sequence of characters we can read from a template.
28class Reader {
29
30 constructor(content) {
31 this.idx = 0;
32 this.content = content;
33 this.len = content.length;
34 }
35
Returns the current character and moves the pointer one place farther.
37 next() {
38 const char = this.content[this.idx ++];
39 if (char === undefined) {
40 this.idx = this.len;
41 }
42 return char;
43 }
44
Move the pointer back one place, undoing the last character read. In practice, we never backtrack from index 0 -- we only use back() to "un-read" a character we've read. So we don't check for negative cases here.
48 back() {
49 this.idx --;
50 }
51
Read up to a specified contiguous substring, but not including the substring.
54 readUpto(substr) {
55 const nextIdx = this.content.substr(this.idx).indexOf(substr);
56 return this.toNext(nextIdx);
57 }
58
Read up to and including a contiguous substring, or read until the end of the template.
61 readUntil(substr) {
62 const nextIdx = this.content.substr(this.idx).indexOf(substr) + substr.length;
63 return this.toNext(nextIdx);
64 }
65
Abstraction used for both readUpto
and readUntil
above.
67 toNext(nextIdx) {
68 const rest = this.content.substr(this.idx);
69 if (nextIdx === -1) {
70 this.idx = this.len;
71 return rest;
72 } else {
73 const part = rest.substr(0, nextIdx);
74 this.idx += nextIdx;
75 return part;
76 }
77 }
78
Remove some substring from the end of the template, if it ends in the substring. This also returns whether the given substring was a valid ending substring.
81 clipEnd(substr) {
82 if (this.content.endsWith(substr)) {
83 this.content = clipStringEnd(this.content, substr);
84 return true;
85 }
86 return false;
87 }
88
89}
90
For converting CSS property names to their JavaScript counterparts
92const kebabToCamel = kebabStr => {
93 let result = '';
94 for (let i = 0, len = kebabStr.length; i < len; i ++) {
95 result += kebabStr[i] === '-' ? kebabStr[++ i].toUpperCase() : kebabStr[i];
96 }
97 return result;
98}
99
Pure function to parse the contents of an HTML opening tag to a JDOM stub
101const parseOpeningTagContents = content => {
102
If the opening tag is just the tag name (the most common case), take a shortcut and run a simpler algorithm.
105 content = content.trim();
106 if (content[0] === '!') {
107 // comment
108 return {
109 jdom: null,
110 selfClosing: true,
111 };
112 } else if (!content.includes(' ')) {
113 const selfClosing = content.endsWith('/');
114 return {
115 jdom: {
116 tag: selfClosing ? clipStringEnd(content, '/') : content,
117 attrs: {},
118 events: {},
119 },
120 selfClosing: selfClosing,
121 };
122 }
123
Make another reader to read the tag contents
125 const reader = new Reader(content);
126 const selfClosing = reader.clipEnd('/');
127
Read the individual characters into a list of tokens: things that may be attribute names, and values.
130 let head = '';
Are we waiting to read an attribute value?
132 let waitingForAttr = false;
Are we in a quoted attribute value?
134 let inQuotes = false;
Array of parsed tokens
136 const tokens = [];
137 const TYPE_KEY = 0;
138 const TYPE_VALUE = 1;
139
Is the next token a key or a value? This is determined by the presence
of equals signs =
, quotations, and whitespace.
142 let nextType = TYPE_KEY;
143
Commit what's currently read as a new token.
145 const commitToken = force => {
146 head = head.trim();
147 if (head !== '' || force) {
148 tokens.push({
149 type: nextType,
150 value: head,
151 });
152 waitingForAttr = false;
153 head = '';
154 }
155 }
Iterate through each read character from the reader and parse the character stream into tokens.
158 for (let next = reader.next(); next !== undefined; next = reader.next()) {
159 switch (next) {
Equals sign denotes the start of an attribute value unless in quotes
161 case '=':
162 if (inQuotes) {
163 head += next;
164 } else {
165 commitToken();
166 waitingForAttr = true;
167 nextType = TYPE_VALUE;
168 }
169 break;
Because we replaced all whitespace with spaces earlier, this catches all whitespaces. Whitespaces are only meaningful separates of values if we're not in quotes.
173 case ' ':
174 if (inQuotes) {
175 head += next;
176 } else if (!waitingForAttr) {
177 commitToken();
178 nextType = TYPE_KEY;
179 }
180 break;
Allow backslash to escape characters if we're in quotes.
182 case '\\':
183 if (inQuotes) {
184 next = reader.next();
185 head += next;
186 }
187 break;
If we're in quotes, '"' escapes quotes. Otherwise, it opens a quoted section.
190 case '"':
191 if (inQuotes) {
192 inQuotes = false;
193 commitToken(true);
194 nextType = TYPE_KEY;
195 } else if (nextType === TYPE_VALUE) {
196 inQuotes = true;
197 }
198 break;
199 default:
Append all other characters to the head
201 head += next;
202 waitingForAttr = false;
203 break;
204 }
205 }
If we haven't committed any last-read tokens, commit it now.
207 commitToken();
208
Now, we parse the tokens into tag, attribute, and events values in the JDOM.
210 let tag = '';
211 const attrs = {};
212 const events = {};
213
The tag name is always the first token
215 tag = tokens.shift().value;
216 let last = null;
217 let curr = tokens.shift();
Function to step through to the next token
219 const step = () => {
220 last = curr;
221 curr = tokens.shift();
222 }
Walk through the token list. If the token is a value token,
the previous token is its key. If the current token is a key,
the previous token is an attribute without value (like disabled
).
226 while (curr !== undefined) {
227 if (curr.type === TYPE_VALUE) {
228 const key = last.value;
229 let val = curr.value.trim();
Commit a key-value pair of string attributes to the JDOM stub. This section treats class lists and style dictionaries separately, and adds function values as event handlers.
233 if (key.startsWith('on')) {
234 events[key.substr(2)] = [val];
235 } else {
236 if (key === 'class') {
237 if (val !== '') {
238 attrs[key] = val.split(' ');
239 }
240 } else if (key === 'style') {
241 if (val.endsWith(';')) {
242 val = val.substr(0, val.length - 1);
243 }
244 const rule = {};
245 for (const pair of val.split(';')) {
246 const idx = pair.indexOf(':');
247 const first = pair.substr(0, idx);
248 const rest = pair.substr(idx + 1);
249 rule[kebabToCamel(first.trim())] = rest.trim();
250 }
251 attrs[key] = rule;
252 } else {
253 attrs[key] = val;
254 }
255 }
256 step();
257 } else if (last) {
258 attrs[last.value] = true;
259 }
260 step();
261 }
If the last value is a value-less attribute (like disabled
), commit it.
263 if (last && last.type === TYPE_KEY) {
264 attrs[last.value] = true;
265 }
266
267 return {
268 jdom: {
269 tag: tag,
270 attrs: attrs,
271 events: events,
272 },
273 selfClosing: selfClosing,
274 };
275}
276
Function to parse an entire JDOM template tree (which we vaguely call JSX here). This recursively calls itself on children elements.
279const parseTemplate = reader => {
280 const result = [];
281
The current JDOM object being worked on. Sort of an "element register"
283 let currentElement = null;
Are we reading a text node (and should ignore special characters)?
285 let inTextNode = false;
Commit currently reading element to the result list, and reset the current element
287 const commit = () => {
If the text node we're about to commit is just whitespace, don't bother
289 if (inTextNode && currentElement.trim() === '') {
290 // pass
291 } else if (currentElement) {
292 result.push(currentElement);
293 }
294 currentElement = null;
295 inTextNode = false;
296 }
297
Shortcut to handle/commit string tokens properly, which we do more than once below.
299 const handleString = next => {
300 if (inTextNode === false) {
301 commit();
302 inTextNode = true;
303 currentElement = '';
304 }
305 currentElement += next;
306 }
307
Main parsing logic. This might be confusingly recursive. In essence, the parser
recursively calls itself with its reader
if it has children to parse,
and trusts that the parser will return when it encounters the closing tag
that marks the end of the list of children. So, the parser breaks the loop
and returns if it encounters a closing tag. This cooperation between the function
and the parent function that called it recursively makes this parser work.
314 for (let next = reader.next(); next !== undefined; next = reader.next()) {
if we see the start of a tag ...
316 if (next === '<') {
... first commit any previous reads, since we're starting a new node ...
318 commit();
... it's an opening tag if the next character isn't '/'
.
320 if (reader.next() !== '/') {
321 reader.back();
Read and parse the contents of the tag up to the end of the opening tag.
324 const result = parseOpeningTagContents(reader.readUpto('>'));
325 reader.next(); // read the '>'
326 currentElement = result && result.jdom;
If the current element is a full-fledged element (and not a comment or text node), let's try to parse the children by handing the reader to a recursively call of this function.
330 if (!result.selfClosing && currentElement !== null) {
331 currentElement.children = parseTemplate(reader);
332 }
... it's a closing tag otherwise ...
334 } else {
... so finish out reading the closing tag. A top-level closing tag means it's actually closing the parent tag, so we need to stop parsing and hand the parsing flow back to the parent call in this recursive function.
339 reader.readUntil('>');
340 break;
341 }
342 } else {
If an HTML entity is encoded (e.g. < is '<'), decode it and handle it.
344 if (next === '&') {
345 handleString(decodeEntity(next + reader.readUntil(';')));
346 } else {
347 handleString(next);
348 }
349 }
350 }
351
Commit any last remaining tokens as-is
353 commit();
354
355 return result;
356}
357
Cache for jdom
, keyed by the string parts, value is a function that takes the dynamic
parts of the template as input and returns the result of parseTemplate. We make an assumption
here that the user of the template won't swap between having an element attribute being
a function once and something that isn't a function the next time. In practice this is fine.
362const JDOM_CACHE = new Map();
This HTML parsing algorithm works by replacing all the dynamic parts with a unique string,
parsing the string markup into a JSON tree, and caching that tree. On renders, we walk the tree
and replace any matching strings with their correct dynamic parts. This makes the algorithm
cache-friendly and relatively fast, despite doing a lot at runtime. JDOM_PLACEHOLDER_RE
is
the regex we use to correlate string keys to their correct dynamic parts.
368const JDOM_PLACEHOLDER_RE = /jdom_tpl_obj_\[(\d+)\]/;
This is for a performance optimization, that when we're filling out template strings, if a string in which we're searching for a placeholder is shorter than placeholder strings, we just stop searching.
372const JDOM_PLACEHOLDER_MIN_LENGTH = 14;
373
Does a given string have a placeholder for the template values?
375const hasPlaceholder = str => typeof str === 'string' && str.includes('jdom_tpl_');
376
Utility functions for walking a JSON tree and filling in placeholders The functions here that take mutable values (arrays, objects) will mutate the given value to be faster than creating new objects.
380
Given a string, replace placeholders with their correct dynamic parts and return the result as an array, so if any dynamic values are objects or HTML nodes, they are not cast to strings. Used to parse HTML children.
384const splitByPlaceholder = (str, dynamicParts) => {
385 if (hasPlaceholder(str)) {
386 const match = JDOM_PLACEHOLDER_RE.exec(str);
387 const parts = str.split(match[0]);
388 const number = match[1];
389 const processedBack = splitByPlaceholder(parts[1], dynamicParts);
390
391 let result = [];
392 if (parts[0] !== '') {
393 result.push(parts[0]);
394 }
395 if (Array.isArray(dynamicParts[number])) {
396 result = result.concat(dynamicParts[number]);
397 } else {
398 result.push(dynamicParts[number]);
399 }
400 if (processedBack.length !== 0) {
401 result = result.concat(processedBack);
402 }
403 return result;
404 } else {
405 return str !== '' ? [str] : [];
406 }
407}
408
Given an array of child JDOM elements, flatten that list of children into a flat array and parse any placeholders in it.
411const replaceChildrenToFlatArray = (children, dynamicParts) => {
412 const newChildren = [];
413 for (const childString of children) {
414 for (const child of splitByPlaceholder(childString, dynamicParts)) {
415 if (isObject(child)) {
416 replaceInObjectLiteral(child, dynamicParts);
417 }
418 newChildren.push(child);
419 }
420 }
421 const first = newChildren[0];
422 const last = newChildren[newChildren.length - 1];
423 if (typeof first === 'string' && first.trim() === '') {
424 newChildren.shift();
425 }
426 if (typeof last === 'string' && last.trim() === '') {
427 newChildren.pop();
428 }
429 return newChildren;
430}
431
Given a string, replace any placeholder values and return a new string.
433const replaceInString = (str, dynamicParts) => {
As an optimization, if the string is too short to contain placeholders, just return early.
436 if (str.length < JDOM_PLACEHOLDER_MIN_LENGTH) {
437 return str;
438 } else {
439 const match = JDOM_PLACEHOLDER_RE.exec(str);
440 if (match === null) {
441 return str;
442 } else if (str.trim() === match[0]) {
443 return dynamicParts[match[1]];
444 } else {
445 const parts = str.split(match[0]);
446 return (parts[0] + dynamicParts[match[1]]
447 + replaceInString(parts[1], dynamicParts));
448 }
449 }
450}
451
Given an array literal, replace placeholders in it and its children, recursively.
453const replaceInArrayLiteral = (arr, dynamicParts) => {
454 for (let i = 0, len = arr.length; i < len; i ++) {
455 const val = arr[i];
456 if (typeof val === 'string') {
457 arr[i] = replaceInString(val, dynamicParts);
458 } else if (Array.isArray(val)) {
459 replaceInArrayLiteral(val, dynamicParts);
460 } else { // it's an object otherwise
461 replaceInObjectLiteral(val, dynamicParts);
462 }
463 }
464}
465
Given an object, replace placeholders in it and its values.
467const replaceInObjectLiteral = (obj, dynamicParts) => {
468 if (obj instanceof Node) {
469 return;
470 }
471
472 for (const prop of Object.keys(obj)) {
473 const val = obj[prop];
474 if (typeof val === 'string') {
475 obj[prop] = replaceInString(val, dynamicParts);
476 } else if (Array.isArray(val)) {
477 if (prop === 'children') {
We need to treat children of JDOM objects differently because they need to all be flat arrays, and sometimes for API convenience they're passed in as nested arrays.
481 obj.children = replaceChildrenToFlatArray(val, dynamicParts);
482 } else {
483 replaceInArrayLiteral(val, dynamicParts);
484 }
485 } else if (isObject(val)) {
486 replaceInObjectLiteral(val, dynamicParts);
487 }
488 }
489}
490
jdom
template tag, using the JDOM parsed templates cache from above.
492const jdom = (tplParts, ...dynamicParts) => {
The key for our cache is just the string parts, joined together with a unique joiner string.
494 const cacheKey = tplParts.join('jdom_tpl_joiner');
495 try {
If we don't have the template in cache, we need to put a translator function in the cache now.
498 if (!JDOM_CACHE.has(cacheKey)) {
Generate placeholder string values for each dynamic value in the template
500 const dpPlaceholders = dynamicParts.map((_obj, i) => `jdom_tpl_obj_[${i}]`);
501
Make a new reader, interpolating the template's static and dynamic parts together.
503 const reader = new Reader(interpolate(tplParts.map(part => part.replace(/\s+/g, ' ')), dpPlaceholders));
Parse the template and take the first child, if there are more, as the element we care about.
505 const result = parseTemplate(reader)[0];
506 const resultType = typeof result;
507 const resultString = JSON.stringify(result);
508
Put a function into the cache that translates an array of the dynamic parts of a template into the full JDOM for the template.
511 JDOM_CACHE.set(cacheKey, dynamicParts => {
512 if (resultType === 'string') {
If the result of the template is just a string, replace stuff in the string
514 return replaceInString(result, dynamicParts);
515 } else if (resultType === 'object') {
Recall that the template translating functions above mutate the object passed in wherever possible. so we make a brand-new object to represent a new result.
518 const target = {};
Since the non-dynamic parts of JDOM objects are by definition completely JSON
serializable, this is a good enough way to deep-copy the cached result of parseTemplate()
.
521 const template = JSON.parse(resultString);
522 replaceInObjectLiteral(Object.assign(target, template), dynamicParts);
523 return target;
524 }
525 return null;
526 });
527 }
Now that we have a translator function in the cache, call that to get a new template result.
529 return JDOM_CACHE.get(cacheKey)(dynamicParts);
530 } catch (e) {
531 /* istanbul ignore next: haven't found error cases that trigger this, but exists just in case */
532 console.error(`jdom parse error.\ncheck for mismatched brackets, tags, quotes.\n${
533 interpolate(tplParts, dynamicParts)}\n${e.stack || e}`);
534 /* istanbul ignore next: see above */
535 return '';
536 }
537}
538
Helper to convert a string representation of a dict into a JavaScript object, CSS-style.
stringToDict
is recursive to parse nested dictionaries.
541const stringToDict = reader => {
542
Dictionary to be constructed from this step
544 const dict = {};
545
Enums for marking whether we are in a key or value section of a dictionary
547 const PROP = 0;
548 const VAL = 1;
549 let part = PROP;
550
Current key, value pair being parsed
552 let current = ['', ''];
Utility function to commit the tokens in the current read buffer (current
) to the
result dictionary and move on to the next key-value pair.
555 const commit = () => {
556 if (typeof current[VAL] === 'string') {
557 dict[current[PROP].trim()] = current[VAL].trim();
558 } else {
559 dict[current[PROP].trim()] = current[VAL];
560 }
561 current = ['', ''];
562 }
563
Begin reading the dictionary by stripping off any whitespace before the starting curlybrace.
565 reader.readUntil('{');
Loop through each character in the string being read...
567 for (let next = reader.next(); next !== undefined; next = reader.next()) {
If we encounter a closing brace, we assume it's the end of the dictionary at the current level of nesting, so we halt parsing at this step and return.
570 if (next === '}') {
571 break;
572 }
573 const p = current[PROP];
574 switch (next) {
575 case '"':
576 case '\'':
If we encounter quotes, we read blindly until the end of the quoted section, ignoring escaped quotes. This is a slightly strange but simple way to achieve that.
579 current[part] += next + reader.readUntil(next);
580 while (current[part].endsWith('\\' + next)) {
581 current[part] += reader.readUntil(next);
582 }
583 break;
584 case ':':
The colon character is ambiguous in SCSS syntax, because it is used for pseudoselectors and pseudoelements, as well as in the dict syntax. We disambiguate by looking at the preceding part of the token.
588 if (
589 p.trim() === '' // empty key is not a thing; probably pseudoselector
590 || p.includes('&') // probably part of nested SCSS selector
591 || p.includes('@') // probably part of media query
592 || p.includes(':') // probably pseudoselector/ pseudoelement selector
593 ) {
594 current[part] += next;
595 } else {
596 part = VAL;
597 }
598 break;
599 case ';':
Commit read tokens if we've reached the end of the rule
601 part = PROP;
602 commit();
603 break;
604 case '{':
If we come across {
, this means we found a nested structure.
We backtrack the reader and recursively call stringToDict
to parse the nested dict first
before moving on.
608 reader.back();
609 current[VAL] = stringToDict(reader);
610 commit();
611 break;
612 default:
For all other characters, just append it to the currently read buffer.
614 current[part] += next;
615 break;
616 }
617 }
618
Take care of any dangling CSS rules without a semicolon.
620 if (current[PROP].trim() !== '') {
621 commit();
622 }
623
624 return dict;
625}
626
Cache for CSS parser outputs
628const CSS_CACHE = new Map();
629
css
is a CSS parser that takes a string and returns CSS style objects for JDOM.
631const css = (tplParts, ...dynamicParts) => {
Parse template as a string first
633 const result = interpolate(tplParts, dynamicParts).trim();
If the CSS rule had not been parsed before (is not in the cache), parse and cache it before returning it.
636 if (!CSS_CACHE.has(result)) {
637 CSS_CACHE.set(result, stringToDict(new Reader('{' + result + '}')));
638 }
639 return CSS_CACHE.get(result);
640}
641
Expose both template tags as globals
643const exposedNames = {
644 jdom,
645 css,
646}
647
If there is a global window
object, bind API names to it.
649/* istanbul ignore else */
650if (typeof window === 'object') {
651 Object.assign(window, exposedNames);
652}
Export public APIs CommonJS-style
654/* istanbul ignore next */
655if (typeof module === 'object' && module.exports) {
656 module.exports = exposedNames;
657}
658