UNPKG

electrode-react-webapp

Version:

Hapi plugin that provides a default React web app template

413 lines (349 loc) 11.9 kB
"use strict"; /* eslint-disable max-params, max-statements, no-constant-condition, no-magic-numbers */ const assert = require("assert"); const Fs = require("fs"); const RenderContext = require("./render-context"); const loadHandler = require("./load-handler"); const Renderer = require("./renderer"); const { resolvePath } = require("./utils"); const Token = require("./token"); const stringArray = require("string-array"); const _ = require("lodash"); const Path = require("path"); const xaa = require("xaa"); const { TEMPLATE_DIR } = require("./symbols"); const tokenTags = { "<!--%{": { // for tokens in html open: "<!--[ \n]*%{", close: new RegExp("}[ \\n]*-->") }, "/*--%{": { // for tokens in script and style open: "\\/\\*--[ \n]*%{", close: new RegExp("}--\\*/") } }; const tokenOpenTagRegex = new RegExp( Object.keys(tokenTags) .map(x => `(${tokenTags[x].open})`) .join("|") ); class AsyncTemplate { constructor(options) { this._options = options; this._tokenHandlers = [].concat(this._options.tokenHandlers).filter(x => x); this._handlersMap = {}; // the same context that gets passed to each token handler's setup function this._handlerContext = _.merge( { user: { // set routeOptions in user also for consistency routeOptions: options.routeOptions } }, options ); this._initializeTemplate(options.htmlFile); } initializeRenderer(reset) { if (reset || !this._renderer) { this._initializeTokenHandlers(this._tokenHandlers); this._applyTokenLoad(); this._renderer = new Renderer({ insertTokenIds: this._options.insertTokenIds, htmlTokens: this._tokens, tokenHandlers: this._tokenHandlers }); } } get tokens() { return this._tokens; } get handlersMap() { return this._handlersMap; } render(options) { const context = new RenderContext(options, this); return xaa .each(this._beforeRenders, r => r.beforeRender(context)) .then(() => { return this._renderer.render(context); }) .then(result => { return xaa .each(this._afterRenders, r => r.afterRender(context)) .then(() => { context.result = context.isVoidStop ? context.voidResult : result; return context; }); }); } _findTokenIndex(id, str, index, instance = 0, msg = "AsyncTemplate._findTokenIndex") { let found; if (id) { found = this.findTokensById(id, instance + 1); } else if (str) { found = this.findTokensByStr(str, instance + 1); } else if (!Number.isInteger(index)) { throw new Error(`${msg}: invalid id, str, and index`); } else if (index < 0 || index >= this._tokens.length) { throw new Error(`${msg}: index ${index} is out of range.`); } else { return index; } if (found.length === 0) return false; return found[instance].index; } // // add tokens at first|last position of the tokens, // or add tokens before|after token at {id}[instance] or {index} // ^^^ {insert} // - Note that item indexes will change after add // // returns: // - number of tokens removed // - false if nothing was removed // throws: // - if id and index are invalid // - if {insert} is invalid // addTokens({ insert = "after", id, index, str, instance = 0, tokens }) { const create = tk => { return new Token( tk.token, -1, typeof tk.props === "string" ? this._parseTokenProps(tk.props) : tk.props ); }; if (insert === "first") { this._tokens.unshift(...tokens.map(create)); return 0; } if (insert === "last") { const x = this._tokens.length; this._tokens.push(...tokens.map(create)); return x; } index = this._findTokenIndex(id, str, index, instance, "AsyncTemplate.addTokens"); if (index === false) return false; if (insert === "before") { this._tokens.splice(index, 0, ...tokens.map(create)); return index; } if (insert === "after") { index++; this._tokens.splice(index, 0, ...tokens.map(create)); return index; } throw new Error( `AsyncTemplate.addTokens: insert "${insert}" is not valid, must be first|before|after|last` ); } // // remove {count} tokens before|after token at {id}[instance] or {index} // ^^^ {remove} // - if removeSelf is true then the token at {id}[instance] or {index} is included for removal // returns: // - array of tokens removed // throws: // - if id and index are invalid // - if {remove} is invalid // removeTokens({ remove = "after", removeSelf = true, id, str, index, instance = 0, count = 1 }) { assert(count > 0, `AsyncTemplate.removeTokens: count ${count} must be > 0`); index = this._findTokenIndex(id, str, index, instance, "AsyncTemplate.removeTokens"); if (index === false) return false; const offset = removeSelf ? 0 : 1; if (remove === "before") { let newIndex = index + 1 - count - offset; if (newIndex < 0) { newIndex = 0; count = index + 1 - offset; } return this._tokens.splice(newIndex, count); } else if (remove === "after") { return this._tokens.splice(index + offset, count); } else { throw new Error(`AsyncTemplate.removeTokens: remove "${remove}" must be before|after`); } } findTokensById(id, count = Infinity) { if (!Number.isInteger(count)) count = this._tokens.length; const found = []; for (let index = 0; index < this._tokens.length && found.length < count; index++) { const token = this._tokens[index]; if (token.id === id) { found.push({ index, token }); } } return found; } findTokensByStr(matcher, count = Infinity) { if (!Number.isInteger(count)) count = this._tokens.length; const found = []; let match; if (typeof matcher === "string") { match = str => str.indexOf(matcher) >= 0; } else if (matcher && matcher.constructor.name === "RegExp") { match = str => str.match(matcher); } else { throw new Error("AsyncTemplate.findTokensByStr: matcher must be a string or RegExp"); } for (let index = 0; index < this._tokens.length && found.length < count; index++) { const token = this._tokens[index]; if (token.hasOwnProperty("str") && match(token.str)) { found.push({ index, token }); } } return found; } /* * break up the template into a list of literal strings and the tokens between them * * - each item is of the form: * * { str: "literal string" } * * or a Token object */ _parseTemplate(template, filepath) { const tokens = []; const templateDir = Path.dirname(filepath); let pos = 0; const parseFail = msg => { const lineCount = [].concat(template.substring(0, pos).match(/\n/g)).length + 1; const lastNLIx = template.lastIndexOf("\n", pos); const lineCol = pos - lastNLIx; msg = msg.replace(/\n/g, "\\n"); const pfx = `electrode-react-webapp: ${filepath}: at line ${lineCount} col ${lineCol}`; throw new Error(`${pfx} - ${msg}`); }; let subTmpl = template; while (true) { const openMatch = subTmpl.match(tokenOpenTagRegex); if (openMatch) { pos += openMatch.index; if (openMatch.index > 0) { const str = subTmpl.substring(0, openMatch.index).trim(); // if there are text between a close tag and an open tag, then consider // that as plain HTML string if (str) tokens.push({ str }); } const tokenOpenTag = openMatch[0].replace(/[ \n]/g, ""); const tokenCloseTag = tokenTags[tokenOpenTag].close; subTmpl = subTmpl.substring(openMatch.index + openMatch[0].length); const closeMatch = subTmpl.match(tokenCloseTag); if (!closeMatch) { parseFail(`Can't find token close tag for '${openMatch[0]}'`); } const tokenBody = subTmpl .substring(0, closeMatch.index) .trim() .split("\n") .map(x => x.trim()) // remove empty and comment lines that start with "//" .filter(x => x && !x.startsWith("//")) .join(" "); const consumedCount = closeMatch.index + closeMatch[0].length; subTmpl = subTmpl.substring(consumedCount); const token = tokenBody.split(" ", 1)[0]; if (!token) { parseFail(`empty token body`); } const tokenProps = tokenBody.substring(token.length).trim(); try { const props = this._parseTokenProps(tokenProps); props[TEMPLATE_DIR] = templateDir; tokens.push(new Token(token, pos, props)); pos += openMatch[0].length + consumedCount; } catch (e) { parseFail(`'${tokenBody}' has malformed prop: ${e.message};`); } } else { const str = subTmpl.trim(); if (str) tokens.push({ str }); break; } } return tokens; } _parseTokenProps(str) { // check if it's JSON object by looking for "{" if (str[0] === "{") { return JSON.parse(str); } const props = {}; while (str) { const m1 = str.match(/([\w]+)=(.)/); assert(m1 && m1[1], "name must be name=Val"); const name = m1[1]; if (m1[2] === `[`) { // treat as name=[str1, str2] str = str.substring(m1[0].length - 1); const r = stringArray.parse(str, true); props[name] = r.array; str = r.remain.trim(); } else if (m1[2] === `'` || m1[2] === `"` || m1[2] === "`") { str = str.substring(m1[0].length); const m2 = str.match(new RegExp(`([^${m1[2]}]+)${m1[2]}`)); assert(m2, `mismatch quote ${m1[2]}`); props[name] = m2[1]; str = str.substring(m2[0].length).trim(); } else if (m1[2] === " ") { // empty props[name] = ""; str = str.substring(m1[0].length).trim(); } else { str = str.substring(m1[0].length - 1); const m2 = str.match(/([^ ]*)/); // matching name=Prop props[name] = JSON.parse(m2[1]); str = str.substring(m2[0].length).trim(); } } return props; } _initializeTemplate(filename) { const filepath = resolvePath(filename); const html = Fs.readFileSync(filepath).toString(); this._tokens = this._parseTemplate(html, filepath); } _loadTokenHandler(path) { const mod = loadHandler(path); return mod(this._handlerContext, this); } _applyTokenLoad() { this._tokens.forEach(x => { if (x.load) { x.load(this._options, this); } }); } _initializeTokenHandlers(filenames) { this._tokenHandlers = filenames.map(fname => { let handler; if (typeof fname === "string") { handler = this._loadTokenHandler(fname); } else { handler = fname; assert(handler.name, "electrode-react-webapp AsyncTemplate token handler missing name"); } if (!handler.name) { handler = { name: fname, tokens: handler }; } assert(handler.tokens, "electrode-react-webapp AsyncTemplate token handler missing tokens"); assert( !this._handlersMap.hasOwnProperty(handler.name), `electrode-react-webapp AsyncTemplate token handlers map already contains ${handler.name}` ); this._handlersMap[handler.name] = handler; return handler; }); this._beforeRenders = this._tokenHandlers.filter(x => x.beforeRender); this._afterRenders = this._tokenHandlers.filter(x => x.afterRender); } } module.exports = AsyncTemplate;