jdom.js annotated source

Back to index

        

Shortcut 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