UNPKG

22.4 kBJavaScriptView Raw
1'use strict';
2
3const path = require('path');
4const gulp = require('gulp');
5const minify = require("babel-minify");
6const Handlebars = require('handlebars');
7const jsdom = require('jsdom');
8const glob = require('glob');
9const read = require('read-file');
10const reload = require('reload');
11const jsonfile = require('jsonfile');
12const fs = require('fs');
13const crypto = require("crypto");
14const copydir = require('copy-dir');
15const request = require('request-json');
16
17const regex = /{(.*)}/gm;
18const whiteSpaceRegex = /\s{2,}/gm;
19const newLineRegex = /\n/gm;
20const subst = `:$1`;
21
22module.exports.Helper = class Helper {
23 constructor(config) {
24 this.defaultLocale = config.defaultLocale || '';
25 this.locales = [];
26 this.watchable = [];
27 this.workingDir = config.workingDir || '.';
28 this.components = config.components || {};
29 this.routes = config.routes || {};
30
31 //todo:check routes
32
33 this.layouts = config.layouts || {};
34 this.resources = {
35 translations: {},
36 };
37
38 for (let locale in this.resources.translations) {
39 if (this.resources.translations.hasOwnProperty(locale)) {
40 this.locales.push(locale)
41 }
42 }
43
44 this.componentsId = crypto.randomBytes(12).toString('hex');
45 this.regex = /\$(\w+)/g; // api url variable replacement
46 }
47
48 getLayout(name, reload) {
49 if (this.layouts.hasOwnProperty(name)) {
50 const layout = this.layouts[name];
51 if (reload) {
52 layout.template = read.sync(layout.file, 'utf8');
53 }
54 return layout
55 }
56 }
57
58 getComponent(name, reload) {
59 if (this.components.hasOwnProperty(name)) {
60 const component = this.components[name];
61 if (reload) {
62 this.createComponent(component.file)
63 }
64 return this.components[name]
65 }
66 }
67
68 createComponent(filePath, skipWrite) {
69 const component = {
70 file: filePath,
71 name: path.basename(filePath, '.html'),
72 template: '',
73 skipWrite: skipWrite,
74 render: true,
75 };
76 const template = read.sync(component.file, 'utf8');
77 const dom = new jsdom.JSDOM(template, {
78 includeNodeLocations: true
79 });
80 const body = dom.window.document.head;
81 for (let i = 0; i < body.children.length; i++) {
82 let child = body.children[i];
83 if (child.nodeName === "TEMPLATE") {
84 const loc = dom.nodeLocation(child);
85 const start = loc.startTag.endOffset;
86 const end = loc.endOffset - start - 11;
87 component.template = template.substr(start, end);
88 component.template = component.template.replace(newLineRegex, ``);
89 component.template = component.template.replace(whiteSpaceRegex, ` `);
90 component.render = child.getAttribute('render') !== 'false'
91 } else if (child.nodeName === "SCRIPT") {
92 const {code, map} = minify(child.innerHTML, {
93 mangle: {
94 keepClassName: true
95 }
96 });
97 component.minifiedScript = code;
98 component.script = child.innerHTML
99 }
100 }
101
102 Handlebars.registerPartial(component.name, "<" + component.name + ">" + (component.render ? component.template : "") + "</" + component.name + ">");
103 component.precompiledTemplate = Handlebars.precompile(component.template);
104 component.precompiled = Handlebars.precompile("<" + component.name + ">{{> _" + component.name + "}}</" + component.name + ">");
105 this.components[component.name] = component;
106 }
107
108 createLayout(filePath) {
109 const layout = {
110 file: filePath,
111 name: path.basename(filePath, '.html'),
112 };
113 layout.template = read.sync(layout.file, 'utf8');
114 layout.template = layout.template.replace(newLineRegex, ``);
115 layout.template = layout.template.replace(whiteSpaceRegex, ` `);
116 this.layouts[layout.name] = layout;
117 }
118
119 createResource(filePath) {
120 const resource = read.sync(filePath, 'utf8');
121 const jsonResource = JSON.parse(resource);
122 if (jsonResource) {
123 this.resources = Object.assign(this.resources || {}, jsonResource)
124 }
125 }
126
127 parseLayout(pattern) {
128 this.watchable.push({pattern: pattern, type: 'layout'});
129 const files = glob.sync(pattern);
130 for (let i = 0; i < files.length; i++) {
131 this.createLayout(files[i])
132 }
133 }
134
135 parse(pattern, skipWrite) {
136 this.watchable.push({pattern: pattern, type: 'component'});
137 const files = glob.sync(pattern);
138 for (let i = 0; i < files.length; i++) {
139 this.createComponent(files[i], skipWrite)
140 }
141 }
142
143 parseResource(pattern) {
144 this.watchable.push({pattern: pattern, type: 'resource'});
145 const files = glob.sync(pattern);
146 for (let i = 0; i < files.length; i++) {
147 this.createResource(files[i])
148 }
149 }
150
151 buildRoute(h, route, basePath, parents) {
152 this.routeCount += 1;
153 route.parents = parents;
154 route.id = this.routeCount;
155 let newPath = path.posix.join(basePath, route.path);
156
157 // replace {param} with :param
158 newPath = newPath.replace(regex, subst);
159
160 if (route.path === '/') {
161 newPath += '/'
162 }
163 if (!this.hasOwnProperty(newPath)) {
164 h[newPath] = [];
165 }
166 if (parents) {
167 h[newPath] = h[newPath].concat(parents)
168 }
169 if (!route.children || route.children.length === 0) {
170 h[newPath].push(route)
171 }
172 if (route.children && route.children.length > 0) {
173 let ps = parents || [];
174 ps.push(route);
175 for (let i = 0; i < route.children.length; i++) {
176 h = this.buildRoute(h, route.children[i], newPath, ps)
177 }
178 }
179 return h
180 }
181
182 changeDetected(watchable) {
183 return change => {
184 if (watchable.type === 'component') {
185 this.createComponent(change.path)
186 } else if (watchable.type === 'layout') {
187 this.createLayout(change.path)
188 } else if (watchable.type === 'resource') {
189 this.createResource(change.path)
190 }
191 if (this.reload) {
192 this.reload.reload()
193 }
194 }
195 }
196
197 setupExpress(app, opt) {
198 // definitions
199 //Handlebars.registerPartial('definitions', `<script src="/${this.componentsId}.js"></script>`);
200
201 // Serve components
202 app.use('/' + this.componentsId + '.js', (req, res, next) => {
203 const lang = req.query.lang || this.defaultLocale;
204 res.type('application/javascript');
205 res.send(this.packagedHandlebars(true));
206 //next()
207 });
208
209 // serve self-contained components
210 Object.keys(this.components).map(i => {
211 const component = this.components[i];
212 app.use('/' + component.name + '.component.js', (req, res, next) => {
213 res.type('application/javascript');
214 res.send(this.selfContainedComponent(component));
215 //next()
216 });
217 });
218
219 if (opt) {
220 if (opt.hasOwnProperty('liveReload') && opt.liveReload === true) {
221 this.reload = reload(app);
222 for (let i = 0; i < this.watchable.length; i++) {
223 const w = this.watchable[i];
224 gulp.watch(w.pattern, this.changeDetected(w))
225 }
226 }
227 }
228
229 this.routeCount = -1;
230 this.handle = {}; // map of array of routes
231 for (let i = 0; i < this.routes.length; i++) {
232 this.handle = this.buildRoute(this.handle, this.routes[i], "/", [])
233 }
234
235 for (const p in this.handle) {
236 if (this.handle.hasOwnProperty(p)) {
237 const rs = this.handle[p];
238 app.get(p, this.handleRoute(p, rs))
239 }
240 }
241
242 // Language helper
243 Handlebars.registerHelper("i18n", (locale, k) => {
244 return this.resources.translations[locale][k]
245 });
246
247 // JSON helper
248 Handlebars.registerHelper("json", k => {
249 return JSON.stringify(k)
250 });
251
252 }
253
254 handleRoute(p, rs) {
255 return (req, res) => {
256 return new Promise((resolve, reject) => {
257 let context = {};
258 if (req.params) {
259 context.query = req.params
260 }
261 context.query = Object.assign(context.query || {}, req.query);
262 if (req.query.hasOwnProperty('lang')) {
263 this.locale = req.query.lang || this.defaultLocale
264 } else {
265 this.locale = this.defaultLocale
266 }
267 let layoutName = 'index';
268
269 if (rs && rs.length > 0) {
270 const r = rs[0];
271 if (r && r.hasOwnProperty('layout') && r.layout.length > 0) {
272 layoutName = r.layout
273 }
274 }
275
276 const layout = this.getLayout(layoutName);
277 const registeredPages = [];
278 const selfContainedWaiting = [];
279 const requests = [];
280
281 let outletName = 'router-outlet';
282 for (let i = 0; i < rs.length; i++) {
283 const r = rs[i];
284 if (r.hasOwnProperty('redirect')) {
285 res.redirect(308, r.redirect);
286 return resolve()
287 }
288 if (r.hasOwnProperty('outlet') && !!r.outlet && r.outlet.length > 0) {
289 outletName = r.outlet
290 }
291 registeredPages.push(outletName);
292 const component = this.components[r.component];
293 if (component.skipWrite) {
294 selfContainedWaiting.push(component)
295 }
296 Handlebars.registerPartial(outletName, "<" + component.name + ">" + (component.render ? component.template : "") + "</" + component.name + ">");
297 if (r.hasOwnProperty('requests')) {
298 r.requests.forEach(_request => {
299 const request = {
300 url: _request.url,
301 method: _request.method,
302 headers: _request.headers,
303 body: _request.body,
304 };
305
306 if (request.hasOwnProperty('url')) {
307 /*let resolvedApiUri = request.url;*/
308 request.url = request.url.replace(this.regex, function (match, param) {
309 return match.replace('$' + param, req.params[param]);
310 });
311
312 let split = request.url.split('?');
313 request.url = split[0];
314 let resolvedQuery = '';
315 if (split.length > 1) {
316 resolvedQuery = split[1]
317 }
318
319 for (let k in req.query) {
320 if (req.query.hasOwnProperty(k)) {
321 if (resolvedQuery.length > 0) {
322 resolvedQuery += '&'
323 }
324 resolvedQuery += k + '=' + req.query[k]
325 }
326 }
327
328 request.url += '?' + resolvedQuery;
329 }
330 if (request.hasOwnProperty('method') && typeof request.method === "string") {
331 request.method = request.method.toUpperCase();
332 switch (request.method) {
333 case "GET":
334 case "POST":
335 /*case "PUT":
336 case "OPTIONS":
337 case "DELETE":
338 case "POST":*/
339 break;
340 default:
341 reject("method not allowed: " + request.method)
342 }
343 } else {
344 request.method = "GET"
345 }
346
347 requests.push(request)
348 });
349 }
350 if (r.hasOwnProperty('page')) {
351 context = Object.assign(context, r.page);
352 }
353 }
354
355 let selfContaining = "";
356 selfContainedWaiting.forEach(component => {
357 selfContaining += this.selfContainedComponent(component)
358 });
359
360 Handlebars.registerPartial('definitions', `<script id="dynamic-helper" type="application/json">${JSON.stringify(this.routes)}</script><script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.1.3/webcomponents-bundle.js"></script><script src="/${this.componentsId}.js"></script>${selfContaining.length > 0 ? `<script>${selfContaining}</script>` : ''}`);
361
362 const fn = Handlebars.compile(layout.template);
363
364 // add locale
365 context['locale'] = this.locale;
366
367 // get api data
368 if (requests.length > 0) {
369 const ps = [];
370 context.data = {};
371
372 requests.forEach((r, index) => {
373 const client = request.createClient(r.url);
374 if (r.headers) {
375 for (let key in r.headers) {
376 if (r.headers.hasOwnProperty(key)) {
377 client.headers[key] = r.headers[key]
378 }
379 }
380 }
381 switch (r.method) {
382 case "GET":
383 console.log('resolving GET ' + r.url);
384 ps.push(new Promise((resolve1, reject1) => {
385 client.get('', function (err, response, body) {
386 context.data[index] = body;
387 resolve1()
388 });
389 }));
390 break;
391 case "POST":
392 console.log('resolving POST ' + r.url);
393 ps.push(new Promise((resolve1, reject1) => {
394 client.post('', r.body, function (err, response, body) {
395 context.data[index] = body;
396 resolve1()
397 });
398 }));
399 break;
400 }
401 });
402
403 Promise.all(ps).then(() => {
404 context.json = JSON.stringify(context);
405 res.send(fn(context));
406 // remove pages from cache
407 for (let i = 0; i < registeredPages.length; i++) {
408 Handlebars.unregisterPartial(registeredPages[i])
409 }
410 Handlebars.unregisterPartial('definitions');
411 resolve()
412 })
413 } else {
414 context.json = JSON.stringify(context);
415 res.send(fn(context));
416 // remove pages from cache
417 for (let i = 0; i < registeredPages.length; i++) {
418 Handlebars.unregisterPartial(registeredPages[i])
419 }
420 Handlebars.unregisterPartial('definitions');
421 resolve()
422 }
423 })
424 }
425 }
426
427 packagedHandlebars(isLocal) {
428 const cArr = Object.keys(this.components).filter(i => {
429 if (!this.components[i].skipWrite) {
430 return this.components[i]
431 }
432 }).map(i => this.components[i]);
433
434 const resources = Object.assign({}, this.resources || {});
435 return `${handlebarsScript}(function(){'use strict';const R=${JSON.stringify(!!resources ? resources : {})};window.translations=R.translations;window.storage=R.storage;Handlebars.registerHelper("json",k=>{return JSON.stringify(k)});Handlebars.registerHelper('i18n',(l,k)=>{return R.translations[l][k]});var template=Handlebars.template,templates=Handlebars.templates=Handlebars.templates || {};${cArr.map((component) => {
436 return `Handlebars.partials['${component.name}'] = template(${component.precompiled});Handlebars.partials['_${component.name}'] = template(${component.precompiledTemplate});`
437 }).join('')};WebComponents.waitFor=WebComponents.waitFor||function(p){return Promise.all([p].map(function(fn){return fn instanceof Function ? fn() : fn;}))};WebComponents.waitFor(()=>{return new Promise((res)=>{var cc=new function(){this.define=function(name,module){if(module&&module.hasOwnProperty('exports')){module.exports.prototype.template=function(c,o){return Handlebars.partials["_"+name](c,o)};window.customElements.define(name,module.exports)}}};window['customComponents']=cc;${cArr.map((component) => {
438 return component.script ? `cc.define('${component.name}',(function(){var module={};${component.script};return module})());` : ''
439 }).join('')};res()})})${isLocal ? `;var r=document.createElement('script');r.setAttribute('src','/reload/reload.js');document.head.appendChild(r)` : ``}})();`
440 }
441
442 selfContainedComponent(component) {
443 return `(function(){'use strict';WebComponents.waitFor(()=>new Promise(n=>{var cc=new function(){this.define=function(n,e){e&&e.hasOwnProperty("exports")&&(e.exports.prototype.template=function(e,t){return Handlebars.partials[n](e,t)},window.customElements.define(n,e.exports))}};Handlebars.partials['${component.name}']=Handlebars.template(${component.precompiled});cc.define('${component.name}',(function(){const module={};${component.minifiedScript ? component.minifiedScript : ''};return module})());n()}));})();`
444 }
445
446 build(dist) {
447 mkDirByPathSync(dist);
448 mkDirByPathSync(dist + '/public');
449
450 // copy static files
451 copydir.sync(this.workingDir + '/public', dist + '/public');
452 // build manifest.json file
453
454 const componentsVersion = crypto.randomBytes(6).toString('hex');
455 const manifest = {
456 'imports': [],
457 'routes': this.routes,
458 'defaultLocale': this.defaultLocale,
459 'resources': this.resources,
460 'componentsVersion': componentsVersion,
461 };
462 for (let name in this.components) {
463 if (this.components.hasOwnProperty(name)) {
464 const component = this.components[name];
465
466 // write template
467 const templatePath = 'public/' + name + '.template.html';
468 fs.writeFileSync(dist + "/" + templatePath, component.template);
469
470 // write self-contained component
471 const componentPath = 'public/' + name + '.component.js';
472 fs.writeFileSync(dist + "/" + componentPath, this.selfContainedComponent(component));
473
474 manifest.imports.push({
475 'templatePath': templatePath,
476 'componentPath': componentPath,
477 'name': name,
478 'render': component.render,
479 })
480 }
481 }
482
483 // write singlefile packaged handlebars
484 const packagedHandlebars = 'public/' + componentsVersion + '.js';
485 fs.writeFileSync(dist + "/" + packagedHandlebars, this.packagedHandlebars());
486
487 // add definitions partial
488 fs.writeFileSync(dist + "/definitions.html", `<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.0.0/webcomponents-bundle.js"></script><script src="/${componentsVersion}.js"></script>`);
489 manifest.imports.push({
490 'templatePath': "definitions.html",
491 'name': "definitions",
492 'render': true
493 });
494
495 for (let name in this.layouts) {
496 if (this.layouts.hasOwnProperty(name)) {
497 const newFilePath = 'public/' + name + '.layout.html';
498
499 // write file
500 fs.writeFileSync(dist + '/' + newFilePath, this.layouts[name].template);
501
502 manifest.imports.push({
503 'templatePath': newFilePath,
504 'name': name,
505 'layout': true,
506 })
507 }
508 }
509
510 jsonfile.writeFileSync(dist + '/manifest.json', manifest)
511 }
512};
513
514function mkDirByPathSync(targetDir, {isRelativeToScript = false} = {}) {
515 const sep = path.sep;
516 const initDir = path.isAbsolute(targetDir) ? sep : '';
517 const baseDir = isRelativeToScript ? __dirname : '.';
518 targetDir.split(sep).reduce((parentDir, childDir) => {
519 const curDir = path.resolve(baseDir, parentDir, childDir);
520 try {
521 fs.mkdirSync(curDir);
522 } catch (err) {
523 if (err.code !== 'EEXIST') {
524 throw err;
525 }
526 }
527 return curDir;
528 },
529 initDir
530 )
531}
532
533const handlebarsScript = read.sync('./node_modules/handlebars/dist/handlebars.min.js');
\No newline at end of file