UNPKG

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