UNPKG

41 kBJavaScriptView Raw
1'use strict';
2
3function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
4
5var arg = _interopDefault(require('arg'));
6var preact = require('preact');
7var path = require('path');
8var renderToString = _interopDefault(require('preact-render-to-string'));
9var Evaluate = _interopDefault(require('eval'));
10var webpack = _interopDefault(require('webpack'));
11var CACHE = require('gcache');
12var simpleFunctionalLoader = require('simple-functional-loader');
13var lodash = require('lodash');
14var globby = _interopDefault(require('globby'));
15var formatStats = _interopDefault(require('webpack-format-messages'));
16var MemoryFS = _interopDefault(require('memory-fs'));
17var Deferred = _interopDefault(require('deferral'));
18var pkgdir = _interopDefault(require('pkg-dir'));
19var fs = require('mz/fs');
20var exists = _interopDefault(require('path-exists'));
21var pathToRegexp = _interopDefault(require('path-to-regexp'));
22var WebpackDevMiddleware = _interopDefault(require('webpack-dev-middleware'));
23var WebpackHotMiddleware = _interopDefault(require('webpack-hot-middleware'));
24var Express = _interopDefault(require('express'));
25var http = _interopDefault(require('http'));
26
27/**
28 * Render a component to a VNode
29 *
30 * @param {Function} Component
31 * @param {Object<string,any>} initial
32 * @return {Promise<Object<string,any>|Error|void>} vnode
33 */
34async function renderComponent(Component, initial) {
35 if (typeof Component !== 'function') {
36 return null
37 }
38
39 // class component with a render function
40 const proto = Component.prototype;
41 if (proto && proto.render) {
42 return preact.h(Component, initial)
43 }
44
45 // stateless component or async function
46 let component;
47 try {
48 component = Component(initial);
49 } catch (err) {
50 return err
51 }
52
53 // is a promise
54 if (component.then && component.catch) {
55 return component.then(vnode => vnode).catch(err => err)
56 }
57
58 // stateless component
59 return component
60}
61
62/**
63 * Render a page to HTML
64 *
65 * @param {Object<string,any>} ctx
66 * @param {Object<string,any>} documentProps
67 * @param {Object<string,any>} page
68 * @return {Promise<string|void|Error>}
69 */
70async function renderToHTML(Component, props) {
71 // // compile the page into an HTML string
72 // // TODO: try catch, classes can throw
73 // const pageHTML = renderToString(page)
74
75 // // TODO: rewind styles
76 // const styles = []
77 // console.log(elmo)
78 // // rewind the head
79 // const heads = Head ? Head.rewind() : []
80
81 // // add new props to the document's existing props
82 // documentProps['styles'] = styles || []
83 // documentProps['heads'] = heads || []
84 // documentProps['pageHTML'] = pageHTML || []
85
86 const doc = await renderComponent(Component, props);
87 if (!doc || doc instanceof Error) {
88 return doc
89 }
90
91 // TODO: try-catch
92 const html = renderToString(doc);
93
94 return html
95}
96
97/**
98 * Evaluate the component
99 *
100 * @param {string} entry
101 * @param {string} source
102 * @param {Object<string,any>=} globals
103 * @return {Error|Function}
104 */
105function evaluate(entry, source, globals) {
106 let render;
107
108 try {
109 render = Evaluate(
110 source,
111 entry /* filename: */,
112 globals || {} /* scope: */,
113 true /* includeGlobals: */
114 );
115 } catch (err) {
116 // turn into an error in this script environment
117 // because err instanceof Error is false
118 const e = new Error(err.message);
119 e.name = err.name;
120 e.stack = err.stack;
121 return e
122 }
123
124 if (render.hasOwnProperty('default')) {
125 render = render['default'];
126 }
127
128 return render
129}
130
131/**
132 * Turn the page to a path
133 *
134 * @param {string} basedir
135 * @param {'.html'|'.js'} ext
136 * @param {string} page
137 * @return {string}
138 *
139 * e.g.
140 * pages/index.jsx => /index.js
141 * pages/about.jsx => /about.js
142 * pages/about/index.jsx => /about.js
143 */
144function pageToPath(basedir, ext, page) {
145 const rel = path.relative(basedir, page);
146 const base = path.basename(rel, path.extname(rel));
147 let dir = path.dirname(rel);
148 if (base === 'index') {
149 if (dir === 'index') dir = path.dirname(dir);
150 if (dir === '.') return base + ext
151 return path.join(dir, base + ext)
152 } else {
153 return path.join(dir, base, 'index' + ext)
154 }
155}
156
157/**
158 * Page to Route
159 *
160 * e.g.
161 * index.js => /
162 * about.js => /about
163 * movies/index.js => /movies
164 * about/another.js => /about/another
165 * index/index.js => /
166 *
167 * @param {string} root
168 * @param {string} page
169 * @return {string}
170 */
171function pageToRoute(root, path$$1) {
172 const page = path.relative(path.join(root, 'pages'), path$$1);
173 const base = path.basename(page, path.extname(page));
174 if (base === 'index') {
175 let dir = path.dirname(page);
176 while (dir === 'index') {
177 dir = path.dirname(dir);
178 }
179 return dir === '.' ? '/' : '/' + dir
180 }
181 return '/' + base
182}
183
184// automatically import preact.h(...)
185function JSXImport({ types: t }) {
186 return {
187 visitor: {
188 JSXOpeningElement(_path, { file }) {
189 file.set('hasJSX', true);
190 },
191
192 Program: {
193 enter(_path, { file }) {
194 file.set('hasJSX', false);
195 },
196
197 exit(
198 { node, scope },
199 {
200 file,
201 opts: { identifier = 'React', moduleName = 'react' }
202 }
203 ) {
204 if (!(file.get('hasJSX') && !scope.hasBinding(identifier))) {
205 return
206 }
207
208 const jsxImportDeclaration = t.importDeclaration(
209 [t.importDefaultSpecifier(t.identifier(identifier))],
210 t.stringLiteral(moduleName)
211 );
212
213 node.body.unshift(jsxImportDeclaration);
214 }
215 }
216 }
217 }
218}
219
220const META_TYPES = ['name', 'httpEquiv', 'charSet', 'itemProp'];
221
222// returns a function for filtering head child elements
223// which shouldn't be duplicated, like <title/>.
224// TODO: less fancy
225function unique() {
226 const tags = [];
227 const metaTypes = [];
228 const metaCategories = {};
229 return h => {
230 switch (h.nodeName) {
231 case 'title':
232 case 'base':
233 if (~tags.indexOf(h.nodeName)) return false
234 tags.push(h.nodeName);
235 break
236 case 'meta':
237 for (let i = 0, len = META_TYPES.length; i < len; i++) {
238 const metatype = META_TYPES[i];
239 if (!h.attributes.hasOwnProperty(metatype)) continue
240 if (metatype === 'charSet') {
241 if (~metaTypes.indexOf(metatype)) return false
242 metaTypes.push(metatype);
243 } else {
244 const category = h.attributes[metatype];
245 const categories = metaCategories[metatype] || [];
246 if (~categories.indexOf(category)) return false
247 categories.push(category);
248 metaCategories[metatype] = categories;
249 }
250 }
251 break
252 }
253 return true
254 }
255}
256
257class Head extends preact.Component {
258 render() {
259 const docProps = this.context._documentProps;
260 let children = this.props.children || [];
261
262 // Head was rendered outside of a Document.
263 // This shouldn't happen but don't break if it's not
264 if (!docProps) {
265 return preact.h('head', {}, children)
266 }
267
268 // we have <Head> children from down in the tree,
269 // add them as children to our head now
270 if (docProps.heads) {
271 children = []
272 .concat(docProps.heads)
273 .concat(children)
274 .filter(unique());
275 }
276
277 // add in the rewound style vnodes
278 if (docProps.styles) {
279 children = [].concat(children).concat(docProps.styles);
280 }
281
282 // return an empty <head> if we don't have any children
283 if (children.length == 0) {
284 return preact.h('head')
285 }
286
287 return preact.h('head', {}, children)
288 }
289}
290
291class Script extends preact.Component {
292 render() {
293 const docProps = this.context._documentProps;
294
295 // Page was rendered outside of a Document
296 // or there's no pageHTML prop
297 if (!docProps || !docProps.scripts) {
298 return null
299 }
300
301 return preact.h('div', { id: 'elmo-scripts' }, [
302 docProps.scripts.map(script => preact.h('script', { src: script }))
303 ])
304 }
305}
306
307class Page extends preact.Component {
308 render() {
309 const docProps = this.context._documentProps;
310
311 // Page was rendered outside of a Document
312 // or there's no pageHTML prop
313 if (!docProps || typeof docProps.pageHTML === 'undefined') {
314 return null
315 }
316
317 return preact.h('div', {
318 id: 'elmo',
319 dangerouslySetInnerHTML: { __html: docProps.pageHTML }
320 })
321 }
322}
323
324class Document extends preact.Component {
325 getChildContext() {
326 return { _documentProps: this.props }
327 }
328
329 // Default render implementation, you can override
330 // this by extending the Document
331 render() {
332 return preact.h('html', {}, [
333 preact.h(Head, {}),
334 preact.h('body', {}, [preact.h(Page, {}), preact.h(Script, {})])
335 ])
336 }
337}
338
339// Statics
340Document.Head = Head;
341Document.Script = Script;
342Document.Page = Page;
343
344const VALID_TYPES = {
345 title: true,
346 meta: true,
347 base: true,
348 link: true,
349 style: true,
350 script: true
351};
352const IS_BROWSER = typeof window !== 'undefined';
353const MARKER = 'elmo-head';
354const ATTR_MAP = {
355 acceptCharset: 'accept-charset',
356 className: 'class',
357 htmlFor: 'for',
358 httpEquiv: 'http-equiv'
359};
360
361// our mounted head components
362// reset on each rewind
363CACHE.set('heads', []);
364
365// only update on client-side
366function update() {
367 if (!IS_BROWSER) return
368 updateClient(CACHE.get('heads'));
369}
370
371// client updates
372function updateClient(headComponents) {
373 const vnodes = flatten(headComponents);
374 const buckets = {};
375
376 // buckets the vnodes
377 for (let i = 0; i < vnodes.length; i++) {
378 const vnode = vnodes[i];
379 const nodeName = vnode.nodeName;
380 if (typeof nodeName !== 'string') continue
381 if (!VALID_TYPES[nodeName]) continue
382 const bucket = buckets[nodeName] || [];
383 bucket.push(vnode);
384 buckets[nodeName] = bucket;
385 }
386
387 // only write the title once
388 if (buckets.title) {
389 syncTitle(buckets.title[0]);
390 }
391
392 // sync the vnodes to the DOM
393 for (let type in VALID_TYPES) {
394 if (type === 'title') continue
395 syncElements(type, buckets[type] || []);
396 }
397}
398
399// Map an array of Head components into VDOM nodes
400function flatten(headComponents) {
401 let children = [];
402
403 for (let i = 0; i < headComponents.length; i++) {
404 const head = headComponents[i];
405 if (!head.props || !head.props.children) continue
406 children = children.concat(head.props.children || []);
407 }
408
409 // TODO: look back at next.js to see why we
410 // need to do this double reversal
411 // TODO: less fancy
412 children = children
413 .reverse()
414 .filter(unique())
415 .reverse();
416
417 const results = [];
418 for (let i = 0; i < children.length; i++) {
419 const child = children[i];
420 // strings are handled natively & pass functions through
421 if (typeof child === 'string' || !('nodeName' in child)) {
422 results.push(child);
423 continue
424 }
425 // ignore invalid head tags
426 if (!VALID_TYPES[child.nodeName]) {
427 continue
428 }
429
430 // mark the classname
431 const attrs = child.attributes || {};
432 const className = attrs.className ? `${attrs.className} ${MARKER}` : MARKER;
433 results.push(preact.cloneElement(child, { className }));
434 }
435
436 return results
437}
438
439// write the title to the DOM
440function syncTitle(vnode) {
441 const title = [].concat(vnode.children).join('');
442 if (title !== document.title) document.title = title;
443}
444
445// sync elements with the DOM
446function syncElements(type, vnodes) {
447 const headElement = document.getElementsByTagName('head')[0];
448 const oldNodes = Array.prototype.slice.call(
449 headElement.querySelectorAll(type + '.' + MARKER)
450 );
451
452 const newNodes = [];
453 for (let i = 0; i < vnodes.length; i++) {
454 newNodes.push(vnodeToDOMNode(vnodes[i]));
455 }
456
457 // loop over old nodes looking for old nodes to delete
458 const dels = [];
459 for (let i = 0; i < oldNodes.length; i++) {
460 const oldNode = oldNodes[i];
461 let found = false;
462 for (let j = 0; j < newNodes.length; j++) {
463 if (oldNode.isEqualNode(newNodes[j])) {
464 found = true;
465 break
466 }
467 }
468 if (!found) {
469 dels.push(oldNode);
470 }
471 }
472
473 // loop over new nodes looking for new nodes to add
474 const adds = [];
475 for (let i = 0; i < newNodes.length; i++) {
476 const newNode = newNodes[i];
477 let found = false;
478 for (let j = 0; j < oldNodes.length; j++) {
479 if (newNode.isEqualNode(oldNodes[j])) {
480 found = true;
481 break
482 }
483 }
484 if (!found) {
485 adds.push(newNode);
486 }
487 }
488
489 // remove the old nodes
490 for (let i = 0; i < dels.length; i++) {
491 const node = dels[i];
492 if (!node.parentNode) continue
493 node.parentNode.removeChild(node);
494 }
495
496 // add the new nodes
497 for (let i = 0; i < adds.length; i++) {
498 const node = adds[i];
499 headElement.appendChild(node);
500 }
501}
502
503// vnodeToDOMNode converts a virtual node into a DOM node
504function vnodeToDOMNode(vnode) {
505 const el = document.createElement(vnode.nodeName);
506 const attrs = vnode.attributes || {};
507 const children = vnode.children;
508 for (const p in attrs) {
509 if (!attrs.hasOwnProperty(p)) continue
510 if (p === 'dangerouslySetInnerHTML') continue
511 const attr = ATTR_MAP[p] || p.toLowerCase();
512 el.setAttribute(attr, attrs[p]);
513 }
514 if (attrs['dangerouslySetInnerHTML']) {
515 el.innerHTML = attrs['dangerouslySetInnerHTML'].__html || '';
516 } else if (children) {
517 el.textContent = typeof children === 'string' ? children : children.join('');
518 }
519 return el
520}
521
522// All the heads are collected together
523class Head$1 extends preact.Component {
524 // server: this should get called before rewind
525 // client: doesn't matter where it is really
526 componentWillMount() {
527 const heads = CACHE.get('heads');
528 CACHE.set('heads', heads.concat(this));
529 update();
530 }
531
532 static rewind() {
533 const children = flatten(CACHE.get('heads'));
534 CACHE.set('heads', []);
535 return children
536 }
537
538 componentDidUpdate() {
539 update();
540 }
541
542 componentWillUnmount() {
543 const heads = CACHE.get('heads');
544 const i = heads.indexOf(this);
545 const updated = [];
546 heads.forEach((head, j) => {
547 if (j === i) return
548 updated.push(head);
549 });
550 CACHE.set('heads', updated);
551 update();
552 }
553
554 render() {
555 return null
556 }
557}
558
559/**
560 * @typedef {Object} Input
561 * @prop {string} projectRoot
562 * @prop {string} buildRoot
563 * @prop {string} env
564 * @prop {Array<string>} pages
565 *
566 * @param {Input} input
567 * @return {Object<string,any>|Error|void}
568 */
569function Prerender(input) {
570 const { projectRoot, buildRoot, env, pages } = input;
571
572 const config = {
573 mode: env,
574 name: 'prerender',
575 target: 'node',
576 context: buildRoot,
577 module: { rules: [] },
578 plugins: [],
579 devtool: false,
580 entry: {}
581 };
582
583 // setup the entries
584 config.entry = pages.reduce((obj, page) => {
585 const name = pageToPath(path.join(buildRoot, 'pages'), '.html', page);
586 obj[name] = [].concat(page);
587 return obj
588 }, {});
589
590 // add the document if we have one
591 const docpath = pages.find(p => path.basename(p, path.extname(p)) === '_document');
592 if (docpath) config.entry['_document.html'] = docpath;
593
594 // this is to ensure that we use node's actual require(...)
595 // which is needed for styled-jsx, but should probably be the
596 // default anyway.
597 config.externals = [
598 function(_parent, request, fn) {
599 // if (request === 'elmo') {
600 // return fn(null, join(elmoRoot, 'src', 'nodejs', 'elmo.js'))
601 // } else
602 if (request[0] !== '.' && request[0] !== '/') {
603 // externalize all node_modules
604 return fn(null, request)
605 } else {
606 // pass relative paths through (don't externalize)
607 return fn(null, undefined)
608 }
609 }
610 ];
611
612 // resolves
613 config.resolve = {
614 // extensions: ['.ts', '.js', '.tsx', '.jsx'],
615 extensions: ['.js', '.jsx'],
616 modules: [path.join(projectRoot, 'node_modules')]
617 // alias: {
618 // elmo: join(elmoRoot, 'src', 'nodejs', 'elmo.js')
619 // }
620 };
621
622 // output configuration
623 config.output = {
624 path: '/',
625 publicPath: '/',
626 // [name] ensures the path matches the entry key
627 filename: '[name]',
628 libraryTarget: 'umd'
629 };
630
631 // rules
632 const rules = config.module.rules;
633
634 // .jsx support
635 rules.push({
636 test: /\.jsx/,
637 exclude: /(node_modules|bower_components)/,
638 loader: 'babel-loader',
639 options: {
640 cacheDirectory: true,
641 plugins: [
642 // aliases['styled-jsx-preact/babel'],
643 '@babel/plugin-proposal-class-properties',
644 [JSXImport, { identifier: 'preact', moduleName: 'preact' }],
645 [
646 '@babel/plugin-transform-react-jsx',
647 // TODO: development flag, right now, it should give
648 // exact line numbers in stack traces.
649 // preact renders it as <div __source="[object Object]">
650 // development: environment === 'development'
651 { pragma: 'preact.createElement' }
652 ]
653 ]
654 }
655 });
656
657 // Reverse plugins, webpack goes from bottom to top
658 // but top to bottom makes more sense in a config
659 config.module.rules = rules.reverse();
660
661 // plugins
662 const plugins = config.plugins;
663
664 // // get all the environment variables
665 // let envvars = Environment()
666
667 // attach the env as the NODE_ENV if it's not there already
668 // envvars['process.env.NODE_ENV'] = JSON.stringify(
669 // envvars['process.env.NODE_ENV'] || environment
670 // )
671
672 // Define environment variables
673 // TODO: add the NODE_ENV
674 plugins.push(new webpack.EnvironmentPlugin(Object.keys(process.env)));
675
676 // Don't write files if there are errors
677 plugins.push(new webpack.NoEmitOnErrorsPlugin());
678
679 // use names instead of IDs
680 plugins.push(new webpack.NamedModulesPlugin());
681
682 // server-side
683 plugins.push(new PrerenderPlugin({}));
684
685 // setup minification & common chunks
686 config.optimization = {
687 minimize: env === 'production',
688 splitChunks: {
689 name: 'common',
690 filename: 'common.js',
691 chunks: 'initial',
692
693 // TODO: these options should be optimized.
694 // right now they make sure we always have
695 // a common.js, but sometimes it's not
696 // necessary.
697 minChunks: 1,
698 minSize: 0
699 }
700 };
701
702 // enable hot module replacement when in dev server in the browser
703 // if (action === 'serve' && environment !== 'production') {
704 // plugins.push(new Webpack.HotModuleReplacementPlugin())
705 // }
706
707 // console.log(elmoRoot)
708 // console.log(
709 // elmoRoot,
710 // require(buildRoot + 'node_modules/webpack/lib/LoaderOptionsPlugin.js')
711 // )
712
713 // minify scripts in production in the browser
714 if (env === 'production') {
715 plugins.push(
716 new webpack.LoaderOptionsPlugin({
717 minimize: true,
718 debug: false
719 })
720 );
721 }
722
723 return config
724}
725
726/**
727 * Prerender plugin is responsible for turning
728 * Javascript source into HTML
729 */
730class PrerenderPlugin {
731 constructor(options) {
732 this.options = options || {};
733 }
734
735 // webpack specific hook
736 apply(compiler) {
737 compiler.hooks.thisCompilation.tap('prerender', compilation => {
738 compilation.hooks.optimizeAssets.tapAsync('prerender', (assets, done) => {
739 this.prerender(assets)
740 .then(e => {
741 if (e) compilation.errors.push(e);
742 done();
743 })
744 .catch(e => {
745 // TODO: fix this
746 console.error('Pre-render: uncaught error (watch killed):', e.stack);
747 done(e);
748 });
749 });
750 });
751 }
752
753 // prerender plugin
754 async prerender(entries) {
755 // const { buildRoot } = this.options
756 let Document$$1 = Document;
757
758 // find the document
759 const documentPath = Object.keys(entries).find(entry => {
760 return path.basename(entry, path.extname(entry)) === '_document'
761 });
762 if (documentPath) {
763 // turn the Document source into a function
764 Document$$1 = evaluate(documentPath, entries[documentPath].source(), {
765 require
766 });
767 if (Document$$1 instanceof Error) {
768 return Document$$1
769 } else if (typeof Document$$1 !== 'function') {
770 return new Error("document doesn't have a default export")
771 }
772 // delete the document from the entries
773 delete entries[documentPath];
774 }
775
776 // turn the node files into html files
777 for (let entry in entries) {
778 // ignore HTML-bound files
779 if (path.extname(entry) !== '.html') continue
780
781 const Page = evaluate(entry, entries[entry].source(), { require });
782 if (Page instanceof Error) return Page
783 // TODO: map entry back to the original file
784 else if (typeof Page !== 'function') {
785 return new Error(`"${entry}" doesn't have a default export`)
786 }
787
788 // empty initials
789 const initial = {
790 redirect: _path => {},
791 render: _props => {},
792 url: {
793 protocol: '',
794 host: '',
795 port: 0,
796 hostname: '',
797 hash: '',
798 search: '',
799 query: {},
800 pathname: '',
801 href: ''
802 }
803 };
804
805 // mock router
806 // const router = {
807 // push: _url => {},
808 // replace: _url => {}
809 // }
810
811 // render the Page into a VNode
812 // TODO: introduce the <Provider>
813 const page = await renderComponent(Page, initial);
814 if (!page || page instanceof Error) {
815 return page
816 }
817
818 // TODO: try catch
819 const pageHTML = renderToString(page);
820
821 const props = {};
822 props['pageHTML'] = pageHTML || '';
823 props['heads'] = Head$1.rewind() || [];
824 props['scripts'] = ['/common.js', '/' + path.join(path.dirname(entry), 'index.js')];
825
826 const html = await renderToHTML(Document$$1, props);
827 if (html instanceof Error) return html
828
829 // update the entry
830 entries[entry] = {
831 source: () => html,
832 size: () => html.length
833 };
834 }
835 }
836}
837
838/**
839 * @typedef {Object} Input
840 * @prop {string} projectRoot
841 * @prop {string} environment
842 * @prop {boolean} hot
843 * @prop {Array<string>} pages
844 *
845 * @param {Input} input
846 * @return {Object<string,any>|Error|void}
847 */
848function Browser(input) {
849 const { projectRoot, buildRoot, env, pages, hot } = input;
850
851 const config = {
852 mode: env,
853 name: 'browser',
854 target: 'web',
855 context: buildRoot,
856 module: { rules: [] },
857 plugins: [],
858 devtool: false,
859 entry: {},
860
861 // don't support any node polyfills
862 node: {
863 console: false,
864 global: false,
865 process: false,
866 __filename: false,
867 __dirname: false,
868 Buffer: false,
869 setImmediate: false,
870 dns: false,
871 fs: false,
872 path: true,
873 url: false
874 },
875
876 // output configuration
877 output: {
878 path: '/',
879 publicPath: '/',
880 // [name] ensures the path matches the entry key
881 filename: '[name]',
882 libraryTarget: 'umd'
883 }
884 };
885
886 // setup the entries
887 config.entry = pages.reduce((obj, page) => {
888 const name = pageToPath(path.join(buildRoot, 'pages'), '.js', page);
889 const bundle = [];
890
891 // only include webpack hot middleware when we're
892 // not in production and using the dev server
893 // and we're building for the browser
894 if (hot) {
895 const client = 'webpack-hot-middleware/client';
896 bundle.push(`${client}?path=/__webpack_hmr&noInfo=true&name=${page}`);
897 bundle.push('preact/debug');
898 }
899
900 obj[name] = bundle.concat(page);
901 return obj
902 }, {});
903
904 // support electron renderer and web seamlessly
905 config.externals = [
906 function(_parent, request, fn) {
907 if (request === 'electron') {
908 return fn(null, request)
909 }
910 return fn(null, undefined)
911 }
912 ];
913
914 // resolves
915 config.resolve = {
916 extensions: ['.ts', '.js', '.tsx', '.jsx'],
917 modules: [path.join(projectRoot, 'node_modules')]
918 // alias: {
919 // elmo: join(elmoRoot, 'src', 'browser', 'elmo.js')
920 // }
921 };
922
923 // where to resolve loaders
924 // config.resolveLoader = {
925 // modules: [join(projectRoot, 'node_modules')]
926 // }
927
928 // rules
929 const rules = config.module.rules;
930
931 // support json files
932 rules.push({
933 test: /\.json$/,
934 use: ['json-loader']
935 });
936
937 // support loading .jsx files
938 rules.push({
939 test: /\.jsx/,
940 exclude: /(node_modules|bower_components)/,
941 loader: 'babel-loader',
942 options: {
943 cacheDirectory: true,
944 plugins: [
945 // 'styled-jsx-preact/babel',
946 '@babel/plugin-proposal-class-properties',
947 [JSXImport, { identifier: 'preact', moduleName: 'preact' }],
948 [
949 '@babel/plugin-transform-react-jsx',
950 // TODO: development flag, right now, it should give
951 // exact line numbers in stack traces.
952 // preact renders it as <div __source="[object Object]">
953 // development: environment === 'development'
954 { pragma: 'preact.createElement' }
955 ]
956 ]
957 }
958 });
959
960 // TODO: add polyfill for Promises
961 // always polyfill to find bugs faster
962 // right now just polyfilling fetch and promise
963 // rules.push({
964 // test: /\.[tj]sx?$/,
965 // include: pages.map(page => resolve(projectRoot, page)),
966 // loader: join(__dirname, '..', 'server', 'loaders', 'polyfills.js')
967 // })
968
969 // boot script for client-side, only added to the top-level pages
970 rules.push({
971 test: /\.[jt]sx?$/,
972 include: pages,
973 loader: simpleFunctionalLoader.createLoader(
974 Boot({
975 buildRoot: buildRoot,
976 routes: input.routes,
977 env: input.env,
978 target: input.target
979 })
980 )
981 });
982
983 // Reverse plugins, webpack goes from bottom to top
984 // but top to bottom makes more sense in a config
985 config.module.rules = rules.reverse();
986
987 // plugins
988 const plugins = config.plugins;
989
990 // Define environment variables
991 // TODO: add the NODE_ENV
992 plugins.push(new webpack.EnvironmentPlugin(Object.keys(process.env)));
993
994 // Don't write files if there are errors
995 plugins.push(new webpack.NoEmitOnErrorsPlugin());
996
997 // use names instead of IDs
998 plugins.push(new webpack.NamedModulesPlugin());
999
1000 // setup minification & common chunks
1001 // do this in development too to ensure no bugs
1002 // only minify in production though
1003 config.optimization = {
1004 minimize: env === 'production',
1005 splitChunks: {
1006 name: 'common',
1007 filename: 'common.js',
1008 chunks: 'initial',
1009
1010 // TODO: these options should be optimized
1011 // right now they make sure we always have
1012 // a common.js, but sometimes it's not
1013 // necessary.
1014 minChunks: 1,
1015 minSize: 0
1016 }
1017 };
1018
1019 // enable hot module replacement when in dev server in the browser
1020 if (hot) {
1021 plugins.push(new webpack.HotModuleReplacementPlugin());
1022 }
1023
1024 // minify scripts in production in the browser
1025 if (env === 'production') {
1026 plugins.push(
1027 new webpack.LoaderOptionsPlugin({
1028 minimize: true,
1029 debug: false
1030 })
1031 );
1032 }
1033
1034 return config
1035}
1036
1037/**
1038 * Boot script for client-side build files
1039 *
1040 * @this {import('webpack').loader.LoaderContext}
1041 * @param {string} source
1042 * @param {string} sourcemap
1043 */
1044function Boot(input) {
1045 return function boot(source, _sourcemap) {
1046 this.cacheable();
1047
1048 const path$$1 =
1049 '/' + pageToPath(path.join(input.buildRoot, 'pages'), '.js', this.resourcePath);
1050
1051 // This is a bit confusing because we only want to update once, but
1052 // need to satisfy 3 update conditions: 1st page load, HMR update,
1053 // and Router.push(...). We only want to update once when we load
1054 // the page and when there's an HMR update, but not when we async
1055 // re-route.
1056 //
1057 // -> only do HMR in development mode
1058 // if (module.hot) {
1059 // -> accept the hot module, this is a noop when we're booting
1060 // module.hot.accept()
1061 //
1062 // -> if we're booting, this will be "idle", "apply" occurs
1063 // -> when we've made a change, this will allow us to only
1064 // -> target HMR updates
1065 // if (module.hot.status() === 'apply') {
1066 // const Router = require('elmo/router').default
1067 // Router.update(Component, route).catch(console.error)
1068 // return
1069 // }
1070 // }
1071 //
1072 // -> This will not be triggered when we're using Router.push(...)
1073 // -> there is no corresponding <script>.
1074 // if (document.querySelector('script[src="${route}"]')) {
1075 // const Router = require('elmo/router').default
1076 // Router.update(Component, route).catch(console.error)
1077 // }
1078 const newSource = `
1079 ${source}
1080 ;(function (Component, env, path, routes) {
1081 Component = Component.default || Component
1082 const { Router, renderToDOM } = require('elmo')
1083 const parent = document.getElementById('elmo')
1084
1085 const router = new Router(routes, (Component, ctx) => {
1086 if (!parent) return
1087 // TODO: add redirect and render to ctx
1088 // TODO: wrap the component in a Provider
1089 return renderToDOM(Component, ctx, parent)
1090 })
1091
1092 if (module.hot) {
1093 module.hot.accept()
1094
1095 // this should only occur when we are using
1096 // HMR, never when we boot or when we use Router.push(...)
1097 if (module.hot.status() === 'apply') {
1098 router.start(Component, path).catch(console.error)
1099 return
1100 }
1101 }
1102
1103 // this should only get rendered on boot, never
1104 // during HMR or when we use Router.push(...)
1105 if (document.querySelector('script[src="'+ path +'"]')) {
1106 router.start(Component, path)
1107 .then(function () {
1108 // this will signal to prerender.io compatible
1109 // clients that the page has rendered successfully
1110 window.prerenderReady = true
1111 })
1112 .catch(console.error)
1113 }
1114 })(
1115 require('${this.resourcePath}'),
1116 ${JSON.stringify(input.env)},
1117 ${JSON.stringify(path$$1)},
1118 ${JSON.stringify(input.routes || {})}
1119 )
1120 `;
1121 this.callback(null, newSource, undefined);
1122 }
1123}
1124
1125/**
1126 * Glob with some common default settings
1127 *
1128 * @param {string} root
1129 * @param {...string} patterns
1130 * @return {Promise<string[]>}
1131 */
1132async function Glob(root, ...patterns) {
1133 const filegroups = await Promise.all(
1134 patterns.map(pattern => {
1135 // TODO: will globby throw?
1136 return globby(pattern, {
1137 absolute: true,
1138 cwd: root,
1139 dot: false,
1140 nodir: true,
1141 ignore: ['**/node_modules/**']
1142 })
1143 })
1144 );
1145
1146 const files = lodash.uniq(lodash.flatten(filegroups)).sort();
1147
1148 // filter any file that has an underscore
1149 // TODO: test this
1150 return files.filter(file => {
1151 const parts = file.split(path.sep);
1152 for (let i = 0; i < parts.length; i++) {
1153 const part = parts[i];
1154 if (part[0] === '_') {
1155 return false
1156 }
1157 }
1158 return true
1159 })
1160}
1161
1162class Webpack {
1163 constructor(...configs) {
1164 this.webpack = webpack(configs || []);
1165 }
1166
1167 /**
1168 * Run our webpack configuration and return
1169 * a list of files
1170 *
1171 * @return {Promise<Error|Array<Object<string,string>>>}
1172 */
1173 async run() {
1174 const deferred = new Deferred();
1175
1176 // set the output system
1177 const outputFS = new MemoryFS();
1178 this.webpack.outputFileSystem = outputFS;
1179
1180 this.webpack.run((err, stats) => {
1181 // TODO: turn into a custom error
1182 if (err) {
1183 if (err instanceof Error) {
1184 return deferred.resolve(err)
1185 }
1186
1187 // handle "missing module" and maybe more
1188 const nerr = new Error(err.message || err);
1189 nerr.stack = err.stack || '';
1190 return deferred.resolve(nerr)
1191 }
1192
1193 // TODO: turn into a custom error
1194 const serr = this._checkError(stats);
1195 if (serr) {
1196 return deferred.resolve(serr)
1197 }
1198
1199 return deferred.resolve(stats)
1200 });
1201
1202 const stats = await deferred.wait();
1203 if (stats instanceof Error) return stats
1204
1205 const files = this._statsToFiles(outputFS, stats);
1206 if (files instanceof Error) return files
1207 return files
1208 }
1209
1210 async watch() {}
1211
1212 /**
1213 * check the stats for any errors
1214 *
1215 * @param {import('webpack').Stats} stats
1216 * @return {Error|null}
1217 */
1218 _checkError(stats) {
1219 const messages = formatStats(stats);
1220
1221 if (messages.errors.length) {
1222 const errs = messages.errors.join('');
1223 return new Error(`webpack: failed to compile.\n\n${errs}`)
1224 }
1225
1226 if (messages.warnings.length) {
1227 const warnings = messages.warnings.join('');
1228 return new Error(`webpack: compiled with warnings.\n\n${warnings}`)
1229 }
1230
1231 return null
1232 }
1233
1234 /**
1235 * Turn webpack stats back into in-memory files
1236 *
1237 * @param {import("memory-fs").MemoryFS} fs
1238 * @param {import("webpack").Stats} stats
1239 * @return {Error | Array<{path:string, contents:string}>}
1240 */
1241 _statsToFiles(fs$$1, stats) {
1242 const json = stats.toJson({ assets: true, chunks: true });
1243 if (!json) return new Error('webkpack: no json emitted')
1244
1245 const files = [];
1246
1247 for (let i = 0; i < json.children.length; i++) {
1248 const child = json.children[i];
1249 const assets = child.assets;
1250
1251 if (!Array.isArray(assets)) continue
1252
1253 for (let j = 0; j < assets.length; j++) {
1254 const asset = assets[j];
1255
1256 const path$$1 = asset.name;
1257 const contents = fs$$1.readFileSync(path.resolve('/', path$$1));
1258
1259 files.push({ path: path$$1, contents });
1260 }
1261 }
1262 return files
1263 }
1264}
1265
1266/**
1267 * _redirects provider
1268 *
1269 * @param {string} projectRoot
1270 * @return {() => Promise<Object<string,string>|Error>}
1271 */
1272function Redirects(projectRoot) {
1273 const filepath = path.join(projectRoot, '_redirects');
1274 return async function provider() {
1275 if (!(await exists(filepath))) return {}
1276 const file = await fs.readFile(filepath, 'utf8');
1277 const lines = file.split(/\n+/);
1278 const out = {};
1279 for (let i = 0; i < lines.length; i++) {
1280 const line = lines[i].trim();
1281 if (line === '' || line[0] === '#') {
1282 continue
1283 }
1284 const [route, redirect] = line.split(/\s+/);
1285 if (!route || !redirect) continue
1286 out[route] = redirect;
1287 }
1288 return out
1289 }
1290}
1291
1292/**
1293 * Package provider
1294 *
1295 * @param {string} projectRoot
1296 * @return {() => Promise<Object<string,string>|Error>}
1297 */
1298function Package(projectRoot) {
1299 const filepath = path.join(projectRoot, 'package.json');
1300 return async function() {
1301 if (!(await exists(filepath))) return {}
1302 const file = await fs.readFile(filepath, 'utf8');
1303 const pkg = tryParse(file);
1304 return get(pkg, 'elmo.routes') || {}
1305 }
1306}
1307
1308function tryParse(contents) {
1309 try {
1310 return JSON.parse(contents)
1311 } catch (err) {
1312 return {}
1313 }
1314}
1315
1316// Thanks Jason!
1317// https://github.com/developit/dlv
1318function get(obj, key, def, p) {
1319 p = 0;
1320 key = key.split ? key.split('.') : key;
1321 while (obj && p < key.length) obj = obj[key[p++]];
1322 return obj === undefined || p < key.length ? def : obj
1323}
1324
1325/**
1326 * Page provider
1327 *
1328 * This provider will be used if there is
1329 * no other routemap provider provided
1330 */
1331function Page$1(buildRoot) {
1332 return async function provider() {
1333 const pages = await Glob(buildRoot, path.join('pages', '**'));
1334 const routes = {};
1335 for (let i = 0; i < pages.length; i++) {
1336 const route = pageToRoute(buildRoot, pages[i]);
1337 const path$$1 = pageToPath(path.join(buildRoot, 'pages'), '.js', pages[i]);
1338 routes[route] = '/' + path$$1;
1339 }
1340 return routes
1341 }
1342}
1343
1344/**
1345 * Create a routemap
1346 *
1347 * @typedef {Function} Provider
1348 *
1349 * @param {...Provider} providers
1350 * @return {() => Promise<Route[]|Error>}
1351 */
1352function Routes(...providers) {
1353 return async function load() {
1354 const routemap = {};
1355 for (let i = 0; i < providers.length; i++) {
1356 const routes = await providers[i]();
1357 for (let route in routes) {
1358 routemap[route] = routes[route];
1359 }
1360 }
1361 return compile(routemap)
1362 }
1363}
1364
1365/**
1366 * Compile the routes
1367 *
1368 * @param {Object<string,string>} map
1369 * @return {Route[]}
1370 */
1371function compile(map) {
1372 const routes = [];
1373 for (let k in map) {
1374 const params = [];
1375 const regexp = pathToRegexp(k, params);
1376 routes.push({
1377 path: map[k],
1378 regexp: regexp.source,
1379 params: params.map(p => p.name)
1380 });
1381 }
1382 return routes
1383}
1384
1385/**
1386 * Bundle an elmo project
1387 *
1388 * @typedef {Object} Input
1389 * @property {string} env
1390 * @property {string} root
1391 * @property {Object<string,string>} routes
1392 *
1393 * @param {Input} input
1394 * @return {Promise<Error|string|void>}
1395 */
1396async function Bundle(input) {
1397 const env = input.env || 'production';
1398
1399 const buildRoot = path.resolve(process.cwd(), input.root || './');
1400 const projectRoot = await pkgdir(buildRoot);
1401
1402 const pages = await Glob(buildRoot, path.join('pages', '**'));
1403 const routes =
1404 input.routes ||
1405 (await Routes(
1406 Package(projectRoot),
1407 Redirects(projectRoot),
1408 Page$1(buildRoot)
1409 )());
1410
1411 const prerender = Prerender({
1412 projectRoot: projectRoot,
1413 buildRoot: buildRoot,
1414 routes: routes,
1415 pages: pages,
1416 env: env
1417 });
1418 const browser = Browser({
1419 projectRoot: projectRoot,
1420 buildRoot: buildRoot,
1421 routes: routes,
1422 pages: pages,
1423 env: env
1424 });
1425 const webpack$$1 = new Webpack(prerender, browser);
1426 const result = await webpack$$1.run();
1427 if (result instanceof Error) {
1428 return result
1429 }
1430 return result
1431}
1432
1433/**
1434 * Regexp cache for routing
1435 */
1436const regexps = {};
1437
1438/**
1439 * Is the terminal
1440 */
1441const isTTY = !!process.stdout.isTTY;
1442
1443/**
1444 * Serve an elmo project
1445 *
1446 * @typedef {Object} Input
1447 * @property {string} env
1448 * @property {string} root
1449 * @property {Object<string,string>} routes
1450 *
1451 * @param {Input} input
1452 * @return {Promise<import('http').Server|Error>}
1453 */
1454async function Serve(input) {
1455 const env = input.env || 'production';
1456
1457 const buildRoot = path.resolve(process.cwd(), input.root || './');
1458 const projectRoot = await pkgdir(buildRoot);
1459 const pages = await Glob(buildRoot, path.join('pages', '**'));
1460
1461 const routes =
1462 input.routes ||
1463 (await Routes(
1464 Package(projectRoot),
1465 Redirects(projectRoot),
1466 Page$1(buildRoot)
1467 )());
1468
1469 const app = Express();
1470
1471 const prerender = Prerender({
1472 projectRoot: projectRoot,
1473 buildRoot: buildRoot,
1474 routes: routes,
1475 pages: pages,
1476 env: env
1477 });
1478 const browser = Browser({
1479 projectRoot: projectRoot,
1480 buildRoot: buildRoot,
1481 routes: routes,
1482 pages: pages,
1483 env: env
1484 });
1485
1486 const webpack$$1 = webpack([prerender, browser]);
1487
1488 // TODO: fix this, this is hacky
1489 // apply the plugin to the multicompiler
1490 // right now hooks is not on the @types/webpack multicompiler
1491 // new Messages({
1492 // isTTY: isTTY,
1493 // onFirstSuccess: () => {
1494 // console.log(`\n> Ready on http://localhost:${params.port}`)
1495 // }
1496 // }).apply(webpack)
1497
1498 const dev = WebpackDevMiddleware(webpack$$1, {
1499 publicPath: '/',
1500 logLevel: 'silent'
1501 });
1502
1503 app.use(dev);
1504
1505 const hot = WebpackHotMiddleware(webpack$$1, {
1506 log: false,
1507 path: '/__webpack_hmr',
1508 heartbeat: 10 * 1000
1509 });
1510
1511 app.use(hot);
1512
1513 // support custom routing
1514 if (routes.length) {
1515 // rehydrate regexps
1516 app.use(function(req, res, next) {
1517 if (req.method !== 'GET') return next()
1518 // TODO: remove me
1519 // this will disable an annoying console warning about sourcemaps
1520 // from dependencies that link to source maps (like unfetch)
1521 if (path.extname(req.path) === '.map') return next()
1522 if (!req.accepts('html')) return next()
1523
1524 for (let i = 0, len = routes.length; i < len; i++) {
1525 const route = routes[i];
1526 const regexp = (regexps[route.regexp] =
1527 regexps[route.regexp] || new RegExp(`${route.regexp}`));
1528 if (!regexp.test(req.path)) {
1529 continue
1530 }
1531 req.url = route.path.replace(/\/+$/, '') + '/';
1532 return dev(req, res, next)
1533 }
1534 return next()
1535 });
1536 }
1537
1538 const def = new Deferred();
1539 dev.waitUntilValid(_stats => def.resolve());
1540 await def.wait();
1541
1542 return http.createServer(app)
1543}
1544
1545const cli = `
1546 🦁 Elmo – Dynamic web applications on static infrastructure.
1547
1548 Usage:
1549
1550 elmo [options] [command] [arguments...]
1551
1552 Commands:
1553
1554 new: scaffold a new project
1555 serve: start the development server
1556 build: compile a server-rendered build
1557 bundle: compile a static build
1558 analyze: print details about the project
1559
1560 Global Options:
1561
1562 --dir <dir> Working directory
1563 --dev Development build
1564 --h, --help Print this screen
1565 --version Get the version
1566`;
1567
1568const spec = {
1569 // help
1570 '--help': Boolean,
1571 '-h': '--help',
1572
1573 // working directory
1574 '--dir': String,
1575 '-d': String,
1576
1577 // port
1578 '--port': Number,
1579 '-p': Number
1580};
1581
1582/**
1583 * @param {...string} args
1584 * @return {Promise<Error|string|void>}
1585 */
1586async function Run(args) {
1587 const argv = arg(spec, { permissive: false, argv: args });
1588 const action = argv._.shift() || 'serve';
1589
1590 if (argv['--help'] || argv['-h']) {
1591 return cli
1592 }
1593
1594 switch (action) {
1595 case 'init':
1596 return Init()
1597 case 'build':
1598 return Build({
1599 root: argv['--dir'] || argv['-d']
1600 })
1601 case 'bundle':
1602 return Bundle({
1603 root: argv['--dir'] || argv['-d']
1604 })
1605 case 'serve':
1606 return Serve()
1607 default:
1608 return cli
1609 }
1610}
1611
1612module.exports = Run;