UNPKG

8.94 kBJavaScriptView Raw
1var waterfall = require('async-collection/waterfall')
2var mapLimit = require('async-collection/map-limit')
3var explain = require('explain-error')
4var concat = require('concat-stream')
5var resolve = require('resolve')
6var crypto = require('crypto')
7var pump = require('pump')
8var path = require('path')
9
10var critical = require('inline-critical-css')
11var documentify = require('documentify')
12var hyperstream = require('hstream')
13
14var ttyError = require('./tty-error')
15var ServerRender = require('../ssr')
16var utils = require('./utils')
17
18var WRITE_CONCURRENCY = 3
19
20module.exports = node
21
22function node (state, createEdge) {
23 var entry = utils.basefile(state.metadata.entry)
24 var self = this
25
26 var ssr = new ServerRender(entry)
27 if (ssr.error && ssr.error.isSsr) {
28 self.emit('ssr', { success: false, error: ssr.error })
29 } else if (ssr.error) {
30 ssr.error = ttyError('documents', entry, ssr.error)
31 return self.emit('error', 'documents', entry, ssr.error)
32 }
33
34 // TODO: don't pass a callback here - super hard to reason about. Find a
35 // different way instead. Perhaps a prototype with methods on it instead?
36 self.emit('ssr', { success: true, renderRoute: documentifyRoute })
37
38 var fonts = extractFonts(state.assets)
39 var list = ssr.routes
40
41 mapLimit(list, WRITE_CONCURRENCY, documentifyRoute, function (err) {
42 if (err) return self.emit(err)
43 createEdge('list', Buffer.from(list.join(',')))
44 })
45
46 function documentifyRoute (route, done) {
47 waterfall([
48 renderApp,
49 documentifyApp,
50 pushEdge
51 ], done)
52
53 function renderApp (done) {
54 ssr.render(route, function (err, content) {
55 if (err) {
56 self.emit('ssr', {success: false, error: err})
57 return done(err)
58 }
59 done(null, content)
60 })
61 }
62
63 function pushEdge (buf, done) {
64 var name = route
65 if (name === '/') name = 'index'
66 name = name + '.html'
67 createEdge(name, buf, {
68 mime: 'text/html'
69 })
70 done(null, buf)
71 }
72 }
73
74 function getTemplate (content, done) {
75 // TODO maybe change this depending on the `content.route`, so you can have different templates for different routes?
76 var name = 'index'
77
78 var dir = path.join(path.dirname(entry), name)
79 resolve('.', { basedir: dir, extensions: ['.html'] }, function (err, filename) {
80 if (err) {
81 done(dir, head(content.language))
82 } else {
83 // Only return the filename, documentify will stream it in.
84 done(filename, null)
85 }
86 })
87 }
88
89 function documentifyApp (content, done) {
90 var base = state.metadata.opts.base
91 var route = content.route
92 var title = content.title
93 var body = content.body
94 var selector = content.selector
95
96 var hasDynamicScripts = state.scripts.bundle.dynamicBundles.length > 0
97
98 getTemplate(content, ontemplate)
99
100 function ontemplate (filename, html) {
101 var d = documentify(filename, html)
102 var header = [
103 viewportTag(),
104 scriptTag({ hash: state.scripts.bundle.hash, base: base }),
105 hasDynamicScripts && dynamicScriptsTag({
106 bundleNames: state.scripts.bundle.dynamicBundles,
107 scripts: state.scripts,
108 base: base
109 }),
110 preloadTag(),
111 loadFontsTag({ fonts: fonts, base: base }),
112 manifestTag({ base: base }),
113 descriptionTag({ description: state.manifest.bundle.description }),
114 themeColorTag({ color: state.manifest.bundle.color }),
115 titleTag({ title: title })
116 ].filter(Boolean)
117 // TODO: twitter
118 // TODO: facebook
119 // TODO: apple touch icons
120 // TODO: favicons
121
122 if (state.metadata.reload) {
123 header.push(reloadTag({ bundle: state.reload.bundle.buffer }))
124 }
125
126 d.transform(addToHead, header.join(''))
127
128 d.transform(insertApp, {
129 selector: selector,
130 body: body
131 })
132
133 if (state.styles.bundle.buffer.length) {
134 d.transform(criticalTransform, { css: state.styles.bundle.buffer })
135 }
136
137 d.transform(addToHead, styleTag({ hash: state.styles.bundle.hash, base: base }))
138
139 function complete (buf) { done(null, buf) }
140
141 pump(d.bundle(), concat({ encoding: 'buffer' }, complete), function (err) {
142 if (err) return done(explain(err, 'Error in documentify while operating on ' + route))
143 })
144 }
145 }
146}
147
148function head (lang) {
149 var dir = 'ltr'
150 return `<!DOCTYPE html><html lang="${lang}" dir="${dir}"><head></head><body></body></html>`
151}
152
153// Make sure that rel=preload works in Safari.
154function preloadTag () {
155 var content = ';(function(a){"use strict";var b=function(b,c,d){function e(a){return h.body?a():void setTimeout(function(){e(a)})}function f(){i.addEventListener&&i.removeEventListener("load",f),i.media=d||"all"}var g,h=a.document,i=h.createElement("link");if(c)g=c;else{var j=(h.body||h.getElementsByTagName("head")[0]).childNodes;g=j[j.length-1]}var k=h.styleSheets;i.rel="stylesheet",i.href=b,i.media="only x",e(function(){g.parentNode.insertBefore(i,c?g:g.nextSibling)});var l=function(a){for(var b=i.href,c=k.length;c--;)if(k[c].href===b)return a();setTimeout(function(){l(a)})};return i.addEventListener&&i.addEventListener("load",f),i.onloadcssdefined=l,l(f),i};"undefined"!=typeof exports?exports.loadCSS=b:a.loadCSS=b})("undefined"!=typeof global?global:this);'
156 content += ';(function(a){if(a.loadCSS){var b=loadCSS.relpreload={};if(b.support=function(){try{return a.document.createElement("link").relList.supports("preload")}catch(b){return!1}},b.poly=function(){for(var b=a.document.getElementsByTagName("link"),c=0;c<b.length;c++){var d=b[c];"preload"===d.rel&&"style"===d.getAttribute("as")&&(a.loadCSS(d.href,d,d.getAttribute("media")),d.rel=null)}},!b.support()){b.poly();var c=a.setInterval(b.poly,300);a.addEventListener&&a.addEventListener("load",function(){b.poly(),a.clearInterval(c)}),a.attachEvent&&a.attachEvent("onload",function(){a.clearInterval(c)})}}})(this);'
157 return `<script>${content}</script>`
158}
159
160function scriptTag (opts) {
161 var hex = opts.hash.toString('hex').slice(0, 16)
162 var base64 = 'sha512-' + opts.hash.toString('base64')
163 var link = `${opts.base || ''}/${hex}/bundle.js`
164 return `<script src="${link}" defer integrity="${base64}"></script>`
165}
166
167function dynamicScriptsTag (opts) {
168 return opts.bundleNames.map(function (name) {
169 name = name.replace(/\.js$/, '')
170 var hash = opts.scripts[name].hash
171 var hex = hash.toString('hex').slice(0, 16)
172 var base64 = 'sha512-' + hash.toString('base64')
173 var link = `${opts.base || ''}/${hex}/${name}.js`
174 return `<link rel="prefetch" href="${link}" integrity="${base64}">`
175 }).join('')
176}
177
178// NOTE: in theory we should be able to add integrity checks to stylesheets too,
179// but in practice it turns out that it conflicts with preloading. So it's best
180// to disable it for now. See:
181// https://twitter.com/yoshuawuyts/status/920794607314759681
182function styleTag (opts) {
183 var hex = opts.hash.toString('hex').slice(0, 16)
184 var link = `${opts.base || ''}/${hex}/bundle.css`
185 return `<link rel="preload" as="style" href="${link}" onload="this.rel='stylesheet'">`
186}
187
188function loadFontsTag (opts) {
189 return opts.fonts.reduce(function (html, font) {
190 if (!path.isAbsolute(font)) font = (opts.base || '') + '/' + font
191 return html + `<link rel="preload" as="font" crossorigin href="${font}">`
192 }, '')
193}
194
195function manifestTag (opts) {
196 return `
197 <link rel="manifest" href="${opts.base || ''}/manifest.json">
198 `.replace(/\n +/g, '')
199}
200
201function viewportTag () {
202 return `
203 <meta charset="utf-8">
204 <meta name="viewport" content="width=device-width, initial-scale=1.0">
205 `.replace(/\n +/g, '')
206}
207
208function descriptionTag (opts) {
209 return `
210 <meta name="description" content="${opts.description}">
211 `.replace(/\n +/g, '')
212}
213
214function themeColorTag (opts) {
215 return `
216 <meta name="theme-color" content=${opts.color}>
217 `.replace(/\n +/g, '')
218}
219
220function titleTag (opts) {
221 return `
222 <title>${opts.title}</title>
223 `.replace(/\n +/g, '')
224}
225
226function criticalTransform (opts) {
227 return critical(String(opts.css))
228}
229
230function reloadTag (opts) {
231 var bundle = opts.bundle
232 var base64 = sha512(bundle)
233 return `<script integrity="${base64}">${bundle}</script>`
234}
235
236function addToHead (str) {
237 return hyperstream({
238 head: {
239 _appendHtml: str
240 }
241 })
242}
243
244function insertApp (opts) {
245 return hyperstream({
246 [opts.selector]: {
247 _replaceHtml: opts.body
248 }
249 })
250}
251
252// Specific to the document node's layout
253function extractFonts (state) {
254 var list = String(state.list.buffer).split(',')
255
256 var res = list.filter(function (font) {
257 var extname = path.extname(font)
258
259 return extname === '.woff' ||
260 extname === '.woff2' ||
261 extname === '.eot' ||
262 extname === '.ttf'
263 })
264
265 return res
266}
267
268function sha512 (buf) {
269 return 'sha512-' + crypto.createHash('sha512')
270 .update(buf)
271 .digest('base64')
272}