UNPKG

8.74 kBJavaScriptView Raw
1var ansi = require('ansi-escape-sequences')
2var scrollbox = require('ansi-scrollbox')
3var pretty = require('prettier-bytes')
4var gzipSize = require('gzip-size')
5var keypress = require('keypress')
6var differ = require('ansi-diff')
7var strip = require('strip-ansi')
8var nanoraf = require('nanoraf')
9var fatalError = require('./fatal-error')
10
11var StartDelimiter = '|'
12var EndDelimiter = '|'
13var Filled = '█'
14var Empty = '░'
15var NewlineMatcher = /\n/g
16
17var VIEW_MAIN = 0
18var VIEW_LOG = 1
19
20var files = [
21 'assets',
22 'documents',
23 'scripts',
24 'styles',
25 'manifest',
26 'service-worker'
27]
28
29module.exports = createUi
30
31function createUi (compiler, state) {
32 var diff = differ()
33 alternateBuffer()
34
35 Object.assign(state, {
36 count: compiler.metadata.count,
37 files: {},
38 size: 0,
39 currentView: VIEW_MAIN,
40 log: scrollbox({
41 width: process.stdout.columns,
42 height: process.stdout.rows - 2
43 })
44 })
45
46 // tail by default
47 state.log.scroll(-1)
48
49 var render = nanoraf(onrender, raf)
50
51 var views = [
52 mainView,
53 logView
54 ]
55
56 files.forEach(function (filename) {
57 state.files[filename] = {
58 name: filename,
59 progress: 0,
60 timestamp: ' ',
61 size: 0,
62 status: 'pending',
63 done: false
64 }
65 })
66
67 compiler.on('error', function (topic, sub, err) {
68 if (err.pretty) state.error = err.pretty
69 else state.error = `${topic}:${sub} ${err.message}\n${err.stack}`
70 render()
71 })
72
73 compiler.on('ssr', render)
74
75 compiler.on('progress', function (nodeName, progress) {
76 state.error = null
77 state.files[nodeName].progress = progress
78 render()
79 })
80
81 compiler.on('change', function (nodeName, edgeName, nodeState) {
82 var node = nodeState[nodeName][edgeName]
83 var data = {
84 name: nodeName,
85 progress: 100,
86 timestamp: time(),
87 size: 0,
88 status: 'done',
89 done: true
90 }
91 state.files[nodeName] = data
92
93 // Only calculate the gzip size if there's a buffer. Apparently zipping
94 // an empty file means it'll pop out with a 20B base size.
95 if (node.buffer.length) {
96 gzipSize(node.buffer)
97 .then(function (size) { data.size = size })
98 .catch(function (size) { data.size = node.buffer.length })
99 .then(render)
100 }
101 render()
102 })
103
104 compiler.on('sse-connect', render)
105 compiler.on('sse-disconnect', render)
106
107 compiler.ssr.console.on('data', function (chunk) {
108 state.log.content += chunk.toString()
109 render()
110 })
111
112 process.stdout.on('resize', onresize)
113
114 if (process.stdin.isTTY) {
115 keypress(process.stdin)
116 process.stdin.setRawMode(true)
117 process.stdin.resume()
118 process.stdin.on('keypress', onkeypress)
119 }
120
121 return render
122
123 function onrender () {
124 var content = views[state.currentView](state)
125 process.stdout.write(diff.update(content))
126 }
127
128 function onresize () {
129 diff.resize({ width: process.stdout.columns, height: process.stdout.rows })
130 state.log.resize({ width: process.stdout.columns, height: process.stdout.rows - 2 })
131 clearScreen()
132 render()
133 }
134
135 function clearScreen () {
136 diff.update('')
137 // Ensure it's _completely_ cleared so that nothing lingers between views.
138 // Some views (*cough* log *cough*) don't use ansi-diff so we can't just rely on that.
139 process.stdout.write(ansi.erase.display(2))
140 }
141
142 function onkeypress (ch, key) {
143 if (key && key.ctrl && key.name === 'c') {
144 process.exit()
145 } else if (ch === '1') {
146 // Switch to the main view.
147 state.currentView = VIEW_MAIN
148 render()
149 } else if (ch === '2') {
150 // Switch to the main view.
151 state.currentView = VIEW_LOG
152 render()
153 } else if (ch === '3') {
154 // TODO: Switch to the stats view.
155 render()
156 } else if (state.currentView === VIEW_LOG) {
157 state.log.keypress(ch, key)
158 render()
159 }
160 }
161}
162
163function mainView (state) {
164 if (state.error) {
165 return '\x1b[33c' + state.error
166 }
167
168 var str = '\x1b[33c'
169 str += header(state)
170 str += '\n\n'
171 str += files.reduce(function (str, filename) {
172 var file = state.files[filename]
173 if (!file) return ''
174 var status = file.status
175 var count = status === 'done' ? String(state.count[filename]) : ''
176 if (status === 'done') status = clr(status, 'green')
177
178 // Make it so singular words aren't pluralized.
179 var name = count === '1'
180 ? file.name.replace(/s$/, '')
181 : file.name
182
183 str += clr(padLeft(count, 3), 'yellow') + ' '
184 str += padRight(clr(name, 'green'), 14)
185 var size = pretty(file.size).replace(' ', '')
186 str += pad(7 - size.length) + clr(size, 'magenta') + ' '
187 str += clr(file.timestamp, 'cyan') + ' '
188 str += progress(file.progress, 10) + ' '
189 str += status
190
191 return str + '\n'
192 }, '') + '\n'
193
194 var ssrState = 'Pending'
195
196 if (state.ssr) {
197 ssrState = state.ssr.success
198 ? 'Success'
199 : `Skipped - ${state.ssr.error.message} ${state.ssr.error.stack.split('\n')[1].trim()}`
200 }
201 str += 'Server Side Rendering: ' + ssrState + '\n'
202
203 var totalSize = Object.keys(state.files).reduce(function (num, filename) {
204 var file = state.files[filename]
205 return num + file.size
206 }, 0)
207 var prettySize = clr(pretty(totalSize).replace(' ', ''), 'magenta')
208 str += footer(state, `Total size: ${prettySize}`)
209
210 // pad string with newlines to ensure old rendered lines are cleared
211 var padLines = Math.max(process.stdout.rows - str.match(NewlineMatcher).length - 1, 0)
212 str += '\n'.repeat(padLines)
213
214 return str
215}
216
217function logView (state) {
218 return state.log.toString() + '\n' + footer(state)
219}
220
221// header
222function header (state) {
223 var sseStatus = state.sse > 0
224 ? clr('connected', 'green')
225 : state.port
226 ? 'ready'
227 : clr('starting', 'yellow')
228
229 var httpStatus = state.port
230 ? clr(clr('https://localhost:' + state.port, 'underline'), 'blue')
231 : clr('starting', 'yellow')
232
233 var left = `HTTP: ${httpStatus}`
234 var right = `Live Reload: ${sseStatus}`
235 return spaceBetween(left, right)
236}
237
238// footer
239function footer (state, bottomRight) {
240 var bottomLeft = tabBar(2, state.currentView)
241
242 return bottomRight ? spaceBetween(bottomLeft, bottomRight) : bottomLeft
243}
244
245function tabBar (count, curr) {
246 var str = ''
247 var tmp
248 for (var i = 0; i < count; i++) {
249 tmp = String(i + 1)
250 if (curr === i) {
251 tmp = `[ ${tmp} ]`
252 } else {
253 tmp = clr(tmp, 'gray')
254 if (i !== 0) tmp = ' ' + tmp
255 if (i !== count) tmp = tmp + ' '
256 }
257 str += tmp
258 }
259 return str
260}
261
262function clr (text, color) {
263 return process.stdout.isTTY ? ansi.format(text, color) : text
264}
265
266function padLeft (str, num, char) {
267 str = String(str)
268 var len = strip(str).length
269 return pad(num - len, char) + str
270}
271
272function padRight (str, num, char) {
273 str = String(str)
274 var len = strip(str).length
275 return str + pad(num - len, char)
276}
277
278function pad (len, char) {
279 char = String(char === undefined ? ' ' : char)
280 var res = ''
281 while (res.length < len) res += char
282 return res
283}
284
285function progress (curr, max) {
286 var filledLength = Math.floor((curr / 100) * max)
287 var emptyLength = max - filledLength
288 var i = 1 + filledLength
289 var j = i + emptyLength
290
291 var str = StartDelimiter
292 while (str.length < i) str += Filled
293 while (str.length < j) str += Empty
294 str += EndDelimiter
295 return str
296}
297
298function raf (cb) {
299 setTimeout(cb, 50)
300}
301
302function spaceBetween (left, right) {
303 var len = process.stdout.columns - strip(left).length - strip(right).length
304 var space = ''
305 for (var i = 0; i < len; i++) {
306 space += ' '
307 }
308 return left + space + right
309}
310
311function time () {
312 var date = new Date()
313 var hours = numPad(date.getHours())
314 var minutes = numPad(date.getMinutes())
315 var seconds = numPad(date.getSeconds())
316 return `${hours}:${minutes}:${seconds}`
317}
318
319function numPad (num) {
320 if (num < 10) num = '0' + num
321 return num
322}
323
324function alternateBuffer () {
325 var q = Buffer.from('q')
326 var esc = Buffer.from([0x1B])
327
328 process.stdout.write('\x1b[?1049h') // Enter alternate buffer.
329 process.stdout.write('\x1b[H') // Reset screen to top.
330 process.stdout.write('\x1b[?25l') // Hide cursor
331
332 process.on('unhandledRejection', onexit)
333 process.on('uncaughtException', onexit)
334 process.on('SIGTERM', onexit)
335 process.on('SIGINT', onexit)
336 process.on('exit', onexit)
337 process.stdin.on('data', handleKey)
338
339 function handleKey (buf) {
340 if (buf.compare(q) === 0 || buf.compare(esc) === 0) {
341 onexit()
342 }
343 }
344
345 function onexit (statusCode) {
346 process.stdout.write('\x1b[?1049l') // Enter to main buffer.
347 process.stdout.write('\x1b[?25h') // Restore cursor
348
349 if (statusCode instanceof Error) {
350 console.error(fatalError(statusCode))
351 statusCode = 1
352 }
353
354 process.exit(statusCode)
355 }
356}