@crab-dev/wake 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ - Stabilized Windows incremental rebuild events by coalescing delayed file notifications.
6
+
7
+ ## 0.1.4
8
+
9
+ - Added the opt-in Storybook-like documentation component workbench.
10
+ - Fixed Yarn PnP packages that expose entries and subpaths through `package.json#exports`.
11
+ - Expanded TypeScript and TSX parsing for optional arrow parameters, generic JSX, generic async arrows, and indexed type queries.
12
+ - Added source module paths to build diagnostics and corrected `wake parse` source-type selection.
13
+
3
14
  ## 0.1.3
4
15
 
5
16
  - Improved the npm development-server terminal panel with Rust CLI-aligned styling, startup timing, rebuild progress, diagnostics, and color controls.
package/bin/terminal.mjs CHANGED
@@ -1,26 +1,37 @@
1
1
  const RESET = '\x1b[0m'
2
2
  const BOLD = '\x1b[1m'
3
3
  const DIM = '\x1b[2m'
4
- const RED = '\x1b[31m'
5
- const GREEN = '\x1b[32m'
6
- const YELLOW = '\x1b[33m'
7
- const CYAN = '\x1b[36m'
8
- const MAGENTA_BOLD = '\x1b[1;35m'
4
+ const MAX_ACTIVITY = 200
5
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']
9
6
 
10
7
  export function supportsColor(stream = process.stderr, env = process.env) {
11
8
  return stream.isTTY === true && !Object.hasOwn(env, 'NO_COLOR')
12
9
  }
13
10
 
14
- export function createUi(color = supportsColor()) {
15
- const wrap = (code, text) => color ? `${code}${text}${RESET}` : String(text)
11
+ export function supportsTui(input = process.stdin, output = process.stderr, env = process.env) {
12
+ return input.isTTY === true
13
+ && output.isTTY === true
14
+ && String(env.TERM || '').toLowerCase() !== 'dumb'
15
+ }
16
+
17
+ export function createUi(color = supportsColor(), env = process.env) {
18
+ const trueColor = color && /^(truecolor|24bit)$/i.test(env.COLORTERM || '')
19
+ const wrap = (indexed, rgb, text) => {
20
+ if (!color) return String(text)
21
+ const code = trueColor
22
+ ? `\x1b[38;2;${rgb.join(';')}m`
23
+ : `\x1b[38;5;${indexed}m`
24
+ return `${code}${text}${RESET}`
25
+ }
16
26
  return {
17
- accent: (text) => wrap(CYAN, text),
18
- bold: (text) => wrap(BOLD, text),
19
- brand: (text) => wrap(MAGENTA_BOLD, text),
20
- dim: (text) => wrap(DIM, text),
21
- error: (text) => wrap(RED, text),
22
- ok: (text) => wrap(GREEN, text),
23
- warn: (text) => wrap(YELLOW, text),
27
+ color,
28
+ accent: (text) => wrap(81, [34, 211, 238], text),
29
+ bold: (text) => color ? `${BOLD}${text}${RESET}` : String(text),
30
+ brand: (text) => color ? `${BOLD}${wrap(213, [217, 70, 239], text)}${RESET}` : String(text),
31
+ dim: (text) => color ? `${DIM}${text}${RESET}` : String(text),
32
+ error: (text) => wrap(204, [251, 113, 133], text),
33
+ ok: (text) => wrap(114, [74, 222, 128], text),
34
+ warn: (text) => wrap(214, [251, 191, 36], text),
24
35
  }
25
36
  }
26
37
 
@@ -32,38 +43,116 @@ export function humanDuration(durationMs) {
32
43
  return `${Math.floor(seconds / 60)}m${(seconds % 60).toFixed(1)}s`
33
44
  }
34
45
 
46
+ function moduleCount(count) {
47
+ return `${count} module${count === 1 ? '' : 's'}`
48
+ }
49
+
50
+ function cacheHitCount(count) {
51
+ return `${count} cache hit${count === 1 ? '' : 's'}`
52
+ }
53
+
54
+ export function humanRuntime(durationMs) {
55
+ const seconds = Math.max(0, Math.floor(durationMs / 1_000))
56
+ if (seconds < 60) return `${seconds}s`
57
+ if (seconds < 3_600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
58
+ return `${Math.floor(seconds / 3_600)}h${String(Math.floor((seconds % 3_600) / 60)).padStart(2, '0')}m`
59
+ }
60
+
61
+ export function humanBytes(bytes) {
62
+ const value = Number(bytes) || 0
63
+ if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`
64
+ if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
65
+ return `${value} B`
66
+ }
67
+
35
68
  export function formatBanner(ui, command, currentVersion) {
36
69
  return [
37
70
  '',
38
- ` ${ui.warn('⚡')} ${ui.brand('wake')} ${ui.dim(`v${currentVersion}`)} ${ui.dim(command)}`,
71
+ ` ${ui.warn('⚡')} ${ui.brand('WAKE')} ${ui.dim('/')} ${ui.bold(command.toUpperCase())} ${ui.dim(`v${currentVersion}`)}`,
39
72
  '',
40
73
  ]
41
74
  }
42
75
 
43
- export function formatServerReady(ui, url, durationMs) {
44
- return [
45
- ` ${ui.ok('✓')} ${ui.bold('开发服务器已就绪')} ${ui.dim('·')} ${ui.accent(humanDuration(durationMs))}`,
46
- '',
47
- ` ${ui.dim('Local')} ${ui.accent(url)}`,
48
- ` ${ui.dim('提示')} ${ui.dim('按')} ${ui.bold('Ctrl-C')} ${ui.dim('退出')}`,
76
+ export function formatBuildResult(ui, result, label = 'Built', extra = '') {
77
+ const bytes = (result.files || []).reduce((sum, file) => sum + Number(file.bytes || 0), 0)
78
+ const lines = [
79
+ ` ${ui.ok('')} ${ui.bold(label)} ${ui.accent(`in ${humanDuration(result.durationMs)}`)}`,
80
+ ` ${ui.accent(`${result.moduleCount} modules`)} ${ui.dim('·')} ${(result.files || []).length} ${ui.dim('files')} ${ui.dim('·')} ${ui.accent(humanBytes(bytes))}${extra}`,
81
+ ]
82
+ if (result.outputDir) lines.push(` ${ui.dim('Output')} ${ui.accent(result.outputDir)}`)
83
+ for (const diagnostic of result.diagnostics || []) {
84
+ lines.push(` ${ui.warn(String(diagnostic.severity || 'warning').toUpperCase())} ${diagnostic.message}`)
85
+ }
86
+ lines.push('')
87
+ return lines
88
+ }
89
+
90
+ export function formatServerReady(ui, url, metrics) {
91
+ const lines = [
92
+ ` ${ui.ok('✓')} ${ui.bold('Development server ready')}`,
93
+ ` ${ui.dim('Local')} ${ui.accent(url)}`,
94
+ ]
95
+ if (metrics) {
96
+ lines.push(
97
+ ` ${ui.accent(`${metrics.modules} modules`)} ${ui.dim('·')} ${metrics.chunks} ${ui.dim('chunks')} ${ui.dim('·')} ${metrics.assets} ${ui.dim('assets')}`,
98
+ )
99
+ }
100
+ lines.push(` ${ui.dim('Press Ctrl-C to stop')}`, '')
101
+ return lines
102
+ }
103
+
104
+ export function formatError(ui, error) {
105
+ const code = error?.code ? `[${error.code}]` : '[WAKE_INTERNAL]'
106
+ const lines = [
107
+ ` ${ui.error('✗')} ${ui.bold('Wake failed')} ${ui.error(code)}`,
108
+ ` ${error?.message || error}`,
109
+ ]
110
+ if (error?.path) lines.push(` ${ui.dim('Path')} ${ui.accent(error.path)}`)
111
+ for (const diagnostic of error?.diagnostics || []) {
112
+ const diagnosticCode = diagnostic.code ? `[${diagnostic.code}] ` : ''
113
+ lines.push(
114
+ ` ${ui.warn(String(diagnostic.severity || 'error').toUpperCase())} ${diagnosticCode}${diagnostic.message}`,
115
+ )
116
+ for (const note of diagnostic.notes || []) lines.push(` ${ui.dim('·')} ${note}`)
117
+ }
118
+ lines.push('')
119
+ return lines
120
+ }
121
+
122
+ export function formatFinalSummary(ui, state, reason, label = 'Server stopped') {
123
+ const lines = [
49
124
  '',
125
+ ` ${ui.dim('■')} ${ui.bold(label)} ${ui.dim(`(${reason})`)}`,
50
126
  ]
127
+ if (state.endpoint) {
128
+ lines.push(` ${ui.dim(state.endpointLabel)} ${ui.accent(state.endpoint)}`)
129
+ }
130
+ lines.push(
131
+ ` ${state.rebuilds} rebuilds ${ui.dim('·')} runtime ${ui.accent(humanRuntime(Date.now() - state.startedAt))}`,
132
+ '',
133
+ )
134
+ return lines
51
135
  }
52
136
 
53
137
  export function observeServer(server, ui, output = console) {
54
138
  const onRebuildStart = (event) => {
55
139
  const count = event.changedPaths?.length || 0
56
- const detail = count > 0 ? `检测到 ${count} 个文件变更,正在重建…` : '正在重建…'
57
- output.log(` ${ui.warn('')} ${ui.dim(detail)}`)
140
+ const detail = count === 1
141
+ ? 'Rebuilding after 1 file change…'
142
+ : count > 1
143
+ ? `Rebuilding after ${count} file changes…`
144
+ : 'Rebuilding…'
145
+ output.error(` ${ui.warn('↻')} ${ui.dim(detail)}`)
58
146
  }
59
147
  const onRebuilt = (event) => {
60
- output.log(
61
- ` ${ui.ok('✓')} ${ui.bold('热重建')} ${ui.dim('·')} ${ui.accent(`${event.modules} 模块`)} ${ui.dim('·')} ${ui.accent(humanDuration(event.durationMs))}`,
148
+ if (event.initial) return
149
+ output.error(
150
+ ` ${ui.ok('✓')} ${ui.bold('Updated')} ${ui.dim('·')} ${ui.accent(moduleCount(event.updatedModules))} ${ui.dim('·')} ${ui.accent(cacheHitCount(event.cachedModules))} ${ui.accent(humanDuration(event.durationMs))}`,
62
151
  )
63
152
  }
64
153
  const onDiagnostic = (diagnostic) => {
65
154
  const code = diagnostic.code ? `[${diagnostic.code}] ` : ''
66
- output.error(` ${ui.error('✗')} ${ui.bold('构建失败')} ${code}${diagnostic.message}`)
155
+ output.error(` ${ui.error('✗')} ${ui.bold('Build failed')} ${code}${diagnostic.message}`)
67
156
  }
68
157
 
69
158
  server.on('rebuildStart', onRebuildStart)
@@ -75,3 +164,296 @@ export function observeServer(server, ui, output = console) {
75
164
  server.off('diagnostic', onDiagnostic)
76
165
  }
77
166
  }
167
+
168
+ function pushActivity(state, level, message) {
169
+ if (state.activity.length === MAX_ACTIVITY) state.activity.shift()
170
+ state.activity.push({
171
+ elapsedMs: Date.now() - state.startedAt,
172
+ level,
173
+ message: String(message),
174
+ })
175
+ }
176
+
177
+ export function createDashboardState({
178
+ command,
179
+ root = '.',
180
+ endpointLabel = 'LOCAL',
181
+ watchLabel = 'HMR · source maps · watching',
182
+ }) {
183
+ const state = {
184
+ command,
185
+ root,
186
+ endpointLabel,
187
+ endpoint: '',
188
+ watchLabel,
189
+ status: 'starting',
190
+ metrics: undefined,
191
+ rebuilds: 0,
192
+ startedAt: Date.now(),
193
+ activity: [],
194
+ scrollFromBottom: 0,
195
+ }
196
+ pushActivity(state, 'info', 'Starting Wake…')
197
+ return state
198
+ }
199
+
200
+ export function applyDashboardEvent(state, event) {
201
+ if (event.type === 'rebuildStart') {
202
+ const count = event.changedPaths?.length || 0
203
+ state.status = 'rebuilding'
204
+ pushActivity(
205
+ state,
206
+ 'warning',
207
+ count === 1
208
+ ? 'Rebuilding after 1 file change…'
209
+ : count > 1
210
+ ? `Rebuilding after ${count} file changes…`
211
+ : 'Rebuilding…',
212
+ )
213
+ } else if (event.type === 'rebuilt') {
214
+ state.status = 'ready'
215
+ state.metrics = {
216
+ modules: event.modules,
217
+ updatedModules: event.updatedModules,
218
+ cachedModules: event.cachedModules,
219
+ chunks: event.chunks,
220
+ assets: event.assets,
221
+ durationMs: event.durationMs,
222
+ }
223
+ if (!event.initial) state.rebuilds += 1
224
+ pushActivity(
225
+ state,
226
+ 'success',
227
+ event.initial
228
+ ? `Initial build completed: ${event.modules} modules in ${humanDuration(event.durationMs)}`
229
+ : `Updated ${moduleCount(event.updatedModules)} · ${cacheHitCount(event.cachedModules)} in ${humanDuration(event.durationMs)}`,
230
+ )
231
+ } else if (event.type === 'diagnostic') {
232
+ state.status = 'error'
233
+ pushActivity(state, 'error', event.message)
234
+ } else if (event.type === 'closed') {
235
+ state.status = 'stopped'
236
+ pushActivity(state, 'info', 'Wake stopped')
237
+ }
238
+ }
239
+
240
+ export function setDashboardEndpoint(state, endpoint) {
241
+ state.endpoint = String(endpoint)
242
+ }
243
+
244
+ export function setDashboardStopping(state, reason) {
245
+ state.status = 'stopping'
246
+ pushActivity(state, 'info', `Stopping (${reason})…`)
247
+ }
248
+
249
+ export function setDashboardStopped(state) {
250
+ state.status = 'stopped'
251
+ pushActivity(state, 'info', 'Wake stopped')
252
+ }
253
+
254
+ function statusLabel(status) {
255
+ return {
256
+ starting: 'STARTING',
257
+ ready: 'READY',
258
+ rebuilding: 'REBUILDING',
259
+ error: 'ERROR',
260
+ stopping: 'STOPPING',
261
+ stopped: 'STOPPED',
262
+ }[status] || 'STARTING'
263
+ }
264
+
265
+ function statusSymbol(state) {
266
+ if (state.status === 'ready') return '✓'
267
+ if (state.status === 'error') return '✗'
268
+ if (state.status === 'stopping') return '◌'
269
+ if (state.status === 'stopped') return '■'
270
+ return SPINNER[Math.floor((Date.now() - state.startedAt) / 100) % SPINNER.length]
271
+ }
272
+
273
+ function elapsedStamp(durationMs) {
274
+ const seconds = Math.floor(durationMs / 1_000)
275
+ return `+${String(Math.floor(seconds / 60) % 100).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
276
+ }
277
+
278
+ function charLength(text) {
279
+ return [...stripAnsi(String(text))].length
280
+ }
281
+
282
+ function truncate(text, width) {
283
+ const chars = [...String(text)]
284
+ if (chars.length <= width) return String(text)
285
+ if (width <= 1) return chars.slice(0, width).join('')
286
+ return `${chars.slice(0, width - 1).join('')}…`
287
+ }
288
+
289
+ function pad(text, width) {
290
+ const value = truncate(text, width)
291
+ return value + ' '.repeat(Math.max(0, width - charLength(value)))
292
+ }
293
+
294
+ function boxLine(text, width) {
295
+ if (width < 4) return pad(text, width)
296
+ return `│ ${pad(text, width - 4)} │`
297
+ }
298
+
299
+ function topBorder(state, width) {
300
+ const title = ` ⚡ WAKE / ${state.command.toUpperCase()} v${state.version || ''} `
301
+ if (width < 4) return '─'.repeat(width)
302
+ const clipped = truncate(title, width - 2)
303
+ return `╭${clipped}${'─'.repeat(Math.max(0, width - 2 - charLength(clipped)))}╮`
304
+ }
305
+
306
+ function separator(width, title = '') {
307
+ if (width < 4) return '─'.repeat(width)
308
+ const center = title ? ` ${title} ` : ''
309
+ return `├${center}${'─'.repeat(Math.max(0, width - 2 - charLength(center)))}┤`
310
+ }
311
+
312
+ function bottomBorder(width) {
313
+ return width < 4 ? '─'.repeat(width) : `╰${'─'.repeat(width - 2)}╯`
314
+ }
315
+
316
+ function metricsText(state) {
317
+ const metrics = state.metrics
318
+ if (!metrics) return 'BUILD waiting for metrics…'
319
+ return `BUILD ${metrics.modules} modules · ${metrics.chunks} chunks · ${metrics.assets} assets · ${humanDuration(metrics.durationMs)}`
320
+ }
321
+
322
+ function activityRows(state, available) {
323
+ const end = Math.max(0, state.activity.length - state.scrollFromBottom)
324
+ const start = Math.max(0, end - Math.max(1, available))
325
+ return state.activity.slice(start, end).map((item) => {
326
+ const symbol = { info: '·', success: '✓', warning: '↻', error: '✗' }[item.level]
327
+ return `${elapsedStamp(item.elapsedMs)} ${symbol} ${String(item.message).replaceAll('\n', ' ')}`
328
+ })
329
+ }
330
+
331
+ function plainFrame(state, width, height) {
332
+ width = Math.max(10, width || 80)
333
+ height = Math.max(6, height || 24)
334
+ const runtime = humanRuntime(Date.now() - state.startedAt)
335
+ const header = `${statusSymbol(state)} ${statusLabel(state.status)} uptime ${runtime} ${state.rebuilds} rebuilds`
336
+ const endpoint = state.endpoint || 'waiting…'
337
+ let fixed
338
+ let activityHeight
339
+
340
+ if (width < 60 || height < 14) {
341
+ fixed = [
342
+ topBorder(state, width),
343
+ boxLine(header, width),
344
+ boxLine(state.endpoint || state.watchLabel, width),
345
+ boxLine(state.activity.at(-1)?.message || 'Starting Wake…', width),
346
+ boxLine('Resize for details · q/Ctrl-C quit', width),
347
+ ]
348
+ activityHeight = 0
349
+ } else if (width < 80 || height < 20) {
350
+ fixed = [
351
+ topBorder(state, width),
352
+ boxLine(header, width),
353
+ boxLine(`${state.endpointLabel} ${endpoint}`, width),
354
+ boxLine(metricsText(state), width),
355
+ separator(width, 'ACTIVITY'),
356
+ ]
357
+ activityHeight = Math.max(1, height - fixed.length - 2)
358
+ } else {
359
+ fixed = [
360
+ topBorder(state, width),
361
+ boxLine(header, width),
362
+ separator(width),
363
+ boxLine(`${state.endpointLabel.padEnd(7)} ${endpoint}`, width),
364
+ boxLine(`ROOT ${state.root}`, width),
365
+ boxLine(`MODE ${state.watchLabel}`, width),
366
+ separator(width),
367
+ boxLine(metricsText(state), width),
368
+ separator(width, 'ACTIVITY'),
369
+ ]
370
+ activityHeight = Math.max(1, height - fixed.length - 2)
371
+ }
372
+
373
+ for (const row of activityRows(state, activityHeight)) fixed.push(boxLine(row, width))
374
+ while (activityHeight > 0 && fixed.length < height - 2) fixed.push(boxLine('', width))
375
+ if (height >= 14) fixed.push(boxLine('↑↓/PgUp/PgDn scroll · End follow · c clear · q/Ctrl-C quit', width))
376
+ fixed.push(bottomBorder(width))
377
+ return fixed.slice(0, height)
378
+ }
379
+
380
+ function colorizeLine(line, ui) {
381
+ if (!ui.color) return line
382
+ let value = line
383
+ value = value.replace('WAKE', ui.brand('WAKE'))
384
+ value = value.replace(/(✓|■)/, (match) => ui.ok(match))
385
+ value = value.replace(/(✗)/, (match) => ui.error(match))
386
+ value = value.replace(/(↻|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|◌)/, (match) => ui.warn(match))
387
+ value = value.replace(/(https?:\/\/\S+)/, (match) => ui.accent(match))
388
+ return value
389
+ }
390
+
391
+ export function renderDashboard(state, width = 80, height = 24, ui = createUi(false)) {
392
+ return plainFrame(state, width, height).map((line) => colorizeLine(line, ui)).join('\n')
393
+ }
394
+
395
+ export function createDashboardSession(
396
+ state,
397
+ { input = process.stdin, output = process.stderr, ui = createUi() } = {},
398
+ ) {
399
+ let closed = false
400
+ let previousRaw = false
401
+ let resolveExit
402
+ const exit = new Promise((resolve) => { resolveExit = resolve })
403
+
404
+ const draw = () => {
405
+ if (closed) return
406
+ const width = output.columns || 80
407
+ const height = output.rows || 24
408
+ output.write(`\x1b[H${renderDashboard(state, width, height, ui)}\x1b[J`)
409
+ }
410
+ const requestExit = (reason) => {
411
+ if (!closed) resolveExit(reason)
412
+ }
413
+ const onData = (chunk) => {
414
+ const key = chunk.toString('utf8')
415
+ if (key === '\u0003') requestExit('SIGINT')
416
+ else if (key === 'q' || key === 'Q') requestExit('q')
417
+ else if (key === 'c' || key === 'C') {
418
+ state.activity.length = 0
419
+ state.scrollFromBottom = 0
420
+ draw()
421
+ } else if (key === '\x1b[A') state.scrollFromBottom = Math.max(0, Math.min(state.activity.length - 1, state.scrollFromBottom + 1))
422
+ else if (key === '\x1b[B') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 1)
423
+ else if (key === '\x1b[5~') state.scrollFromBottom = Math.max(0, Math.min(state.activity.length - 1, state.scrollFromBottom + 10))
424
+ else if (key === '\x1b[6~') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 10)
425
+ else if (key === '\x1b[F' || key === '\x1b[4~') state.scrollFromBottom = 0
426
+ draw()
427
+ }
428
+ const onResize = () => draw()
429
+
430
+ previousRaw = input.isRaw === true
431
+ if (typeof input.setRawMode === 'function') input.setRawMode(true)
432
+ input.resume?.()
433
+ input.on('data', onData)
434
+ output.on?.('resize', onResize)
435
+ output.write('\x1b[?1049h\x1b[?25l\x1b[2J')
436
+ const timer = setInterval(draw, 100)
437
+ timer.unref?.()
438
+ draw()
439
+
440
+ return {
441
+ draw,
442
+ exit,
443
+ requestExit,
444
+ close() {
445
+ if (closed) return
446
+ closed = true
447
+ clearInterval(timer)
448
+ input.off('data', onData)
449
+ output.off?.('resize', onResize)
450
+ if (typeof input.setRawMode === 'function') input.setRawMode(previousRaw)
451
+ if (!previousRaw) input.pause?.()
452
+ output.write('\x1b[?25h\x1b[?1049l')
453
+ },
454
+ }
455
+ }
456
+
457
+ export function stripAnsi(value) {
458
+ return String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
459
+ }
package/bin/wake.mjs CHANGED
@@ -10,26 +10,39 @@ import {
10
10
  } from '../index.mjs'
11
11
  import { parse, tokenize } from '../experimental.mjs'
12
12
  import {
13
+ applyDashboardEvent,
14
+ createDashboardSession,
15
+ createDashboardState,
13
16
  createUi,
14
17
  formatBanner,
18
+ formatBuildResult,
19
+ formatError,
20
+ formatFinalSummary,
15
21
  formatServerReady,
16
22
  observeServer,
23
+ setDashboardEndpoint,
24
+ setDashboardStopped,
25
+ setDashboardStopping,
17
26
  supportsColor,
27
+ supportsTui,
18
28
  } from './terminal.mjs'
19
29
 
20
30
  const HELP = `Wake ${version()}
21
31
 
22
32
  Usage:
33
+ wake [--ui auto|tui|plain] [--no-color] <command>
23
34
  wake build [entry] [--outdir DIR] [--cache] [--sourcemap]
24
35
  wake dev [root] [--entry FILE] [--host HOST] [--port PORT] [--open]
25
- wake docs build [root] [--outdir DIR] [--base PATH]
26
- wake docs dev [root] [--host HOST] [--port PORT] [--open]
27
- wake parse <file>
28
- wake tokenize <file>
36
+ wake docs build [root] [--mode site|components] [--outdir DIR] [--base PATH]
37
+ wake docs dev [root] [--mode site|components] [--host HOST] [--port PORT] [--open]
38
+ wake parse <file> [--format auto|human|json]
39
+ wake tokenize <file> [--format auto|human|json]
29
40
  wake --version
30
41
 
31
42
  Options:
32
- --no-color Disable terminal colors
43
+ --ui MODE Terminal UI mode for long-running commands (default: auto)
44
+ --no-color Disable terminal colors; also honors NO_COLOR
45
+ --format Human or JSON output for parse/tokenize (default: auto)
33
46
  `
34
47
 
35
48
  function takeOption(args, name) {
@@ -54,54 +67,161 @@ function commonOptions(args) {
54
67
  }
55
68
  }
56
69
 
57
- function printResult(result) {
58
- const files = result.files?.length || 0
59
- console.log(
60
- `wake: built ${result.moduleCount} modules and ${files} files in ${result.durationMs.toFixed(1)}ms`,
61
- )
62
- if (result.outputDir) console.log(`wake: output ${result.outputDir}`)
70
+ function printLines(lines, output = console.error) {
71
+ for (const line of lines) output(line)
63
72
  }
64
73
 
65
- function printLines(lines) {
66
- for (const line of lines) console.log(line)
74
+ function validateChoice(value, name, choices) {
75
+ if (!choices.includes(value)) {
76
+ throw new Error(`${name} must be one of: ${choices.join(', ')}`)
77
+ }
78
+ return value
79
+ }
80
+
81
+ function ensureStaticMode(uiMode) {
82
+ if (uiMode === 'tui') {
83
+ throw new Error('--ui tui is only available for dev and docs dev')
84
+ }
85
+ }
86
+
87
+ function resolveTui(uiMode) {
88
+ const supported = supportsTui()
89
+ if (uiMode === 'plain') return false
90
+ if (uiMode === 'tui' && !supported) {
91
+ throw new Error('--ui tui requires interactive stdin and stderr and a capable terminal')
92
+ }
93
+ return supported
94
+ }
95
+
96
+ function resolveFormat(value) {
97
+ const format = validateChoice(value || 'auto', '--format', ['auto', 'human', 'json'])
98
+ return format === 'auto' ? (process.stdout.isTTY ? 'human' : 'json') : format
99
+ }
100
+
101
+ function printResult(ui, result, label, extra = '') {
102
+ printLines(formatBuildResult(ui, result, label, extra))
67
103
  }
68
104
 
69
- async function runServer(factory, options, command, ui) {
105
+ function metricsFromEvent(event) {
106
+ return {
107
+ modules: event.modules,
108
+ updatedModules: event.updatedModules,
109
+ cachedModules: event.cachedModules,
110
+ chunks: event.chunks,
111
+ assets: event.assets,
112
+ durationMs: event.durationMs,
113
+ }
114
+ }
115
+
116
+ async function runServer(factory, options, command, root, ui, uiMode) {
70
117
  const controller = new AbortController()
71
- printLines(formatBanner(ui, command, version()))
72
- const startedAt = performance.now()
73
- const server = await factory({ ...options, signal: controller.signal })
74
- printLines(formatServerReady(ui, server.url, performance.now() - startedAt))
75
- const stopObserving = observeServer(server, ui)
76
-
77
- let stopping = false
78
- const stop = async (signal) => {
79
- if (stopping) return
80
- stopping = true
81
- controller.abort()
118
+ const useTui = resolveTui(uiMode)
119
+ const state = createDashboardState({
120
+ command,
121
+ root: root || '.',
122
+ watchLabel: command === 'docs dev'
123
+ ? 'MDX · HMR · search index · watching'
124
+ : command === 'docs components'
125
+ ? 'Demo · Controls · HMR · watching'
126
+ : 'HMR · source maps · watching',
127
+ })
128
+ state.version = version()
129
+ let dashboard
130
+ let server
131
+ let stopObserving = () => {}
132
+ let initialMetrics
133
+ let finalReason = 'server closed'
134
+
135
+ if (useTui) dashboard = createDashboardSession(state, { ui })
136
+ else printLines(formatBanner(ui, command, version()))
137
+
138
+ try {
139
+ server = await factory({ ...options, signal: controller.signal })
140
+ setDashboardEndpoint(state, server.url)
141
+
142
+ const onRebuildStart = (event) => {
143
+ applyDashboardEvent(state, event)
144
+ dashboard?.draw()
145
+ }
146
+ const onRebuilt = (event) => {
147
+ if (event.initial) initialMetrics = metricsFromEvent(event)
148
+ applyDashboardEvent(state, event)
149
+ dashboard?.draw()
150
+ }
151
+ const onDiagnostic = (diagnostic) => {
152
+ applyDashboardEvent(state, { type: 'diagnostic', message: diagnostic.message })
153
+ dashboard?.draw()
154
+ }
155
+ const onClosed = () => {
156
+ applyDashboardEvent(state, { type: 'closed' })
157
+ dashboard?.draw()
158
+ }
159
+ server.on('rebuildStart', onRebuildStart)
160
+ server.on('rebuilt', onRebuilt)
161
+ server.on('diagnostic', onDiagnostic)
162
+ server.on('closed', onClosed)
163
+
164
+ if (!useTui) stopObserving = observeServer(server, ui)
165
+ await new Promise((resolve) => setTimeout(resolve, 35))
166
+ if (!useTui) {
167
+ printLines(formatServerReady(ui, server.url, initialMetrics))
168
+ } else {
169
+ dashboard.draw()
170
+ }
171
+
172
+ let resolveSignal
173
+ const signalExit = new Promise((resolve) => { resolveSignal = resolve })
174
+ const onSigint = () => resolveSignal('SIGINT')
175
+ const onSigterm = () => resolveSignal('SIGTERM')
176
+ process.once('SIGINT', onSigint)
177
+ process.once('SIGTERM', onSigterm)
178
+
82
179
  try {
83
- await server.close()
180
+ const closed = server.waitUntilClosed().then(() => 'closed')
181
+ const reason = await Promise.race([
182
+ closed,
183
+ signalExit,
184
+ dashboard ? dashboard.exit : new Promise(() => {}),
185
+ ])
186
+ if (reason !== 'closed') {
187
+ finalReason = reason
188
+ setDashboardStopping(state, reason === 'q' ? 'q' : reason)
189
+ dashboard?.draw()
190
+ controller.abort()
191
+ await server.close()
192
+ await closed
193
+ }
194
+ setDashboardStopped(state)
195
+ dashboard?.draw()
196
+ return reason
84
197
  } finally {
85
- process.exitCode = signal === 'SIGINT' ? 130 : 143
198
+ process.off('SIGINT', onSigint)
199
+ process.off('SIGTERM', onSigterm)
200
+ server.off('rebuildStart', onRebuildStart)
201
+ server.off('rebuilt', onRebuilt)
202
+ server.off('diagnostic', onDiagnostic)
203
+ server.off('closed', onClosed)
86
204
  }
87
- }
88
- const onSigint = () => void stop('SIGINT')
89
- const onSigterm = () => void stop('SIGTERM')
90
- process.once('SIGINT', onSigint)
91
- process.once('SIGTERM', onSigterm)
92
- try {
93
- await server.waitUntilClosed()
205
+ } catch (error) {
206
+ if (dashboard) {
207
+ dashboard.close()
208
+ dashboard = undefined
209
+ printLines(formatBanner(ui, command, version()))
210
+ }
211
+ throw error
94
212
  } finally {
95
213
  stopObserving()
96
- process.off('SIGINT', onSigint)
97
- process.off('SIGTERM', onSigterm)
214
+ dashboard?.close()
215
+ if (server) printLines(formatFinalSummary(ui, state, finalReason))
98
216
  }
99
217
  }
100
218
 
101
219
  export async function runCli(argv = process.argv.slice(2)) {
102
220
  const args = [...argv]
103
221
  const noColor = takeFlag(args, '--no-color')
222
+ const uiMode = validateChoice(takeOption(args, '--ui') || 'auto', '--ui', ['auto', 'tui', 'plain'])
104
223
  const ui = createUi(!noColor && supportsColor())
224
+
105
225
  if (takeFlag(args, '--version') || takeFlag(args, '-V')) {
106
226
  console.log(version())
107
227
  return 0
@@ -113,13 +233,15 @@ export async function runCli(argv = process.argv.slice(2)) {
113
233
 
114
234
  const command = args.shift()
115
235
  if (command === 'build') {
236
+ ensureStaticMode(uiMode)
116
237
  const options = commonOptions(args)
117
238
  options.outdir = takeOption(args, '--outdir')
118
239
  options.cache = takeFlag(args, '--cache')
119
240
  options.sourceMap = takeFlag(args, '--sourcemap')
120
241
  if (args[0]) options.entry = args.shift()
121
242
  if (args.length) throw new Error(`unknown build arguments: ${args.join(' ')}`)
122
- printResult(await build(options))
243
+ printLines(formatBanner(ui, 'build', version()))
244
+ printResult(ui, await build(options), 'Built')
123
245
  return 0
124
246
  }
125
247
 
@@ -132,8 +254,10 @@ export async function runCli(argv = process.argv.slice(2)) {
132
254
  options.open = takeFlag(args, '--open')
133
255
  if (args[0]) options.cwd = args.shift()
134
256
  if (args.length) throw new Error(`unknown dev arguments: ${args.join(' ')}`)
135
- await runServer(startDevServer, options, 'dev', ui)
136
- return process.exitCode || 0
257
+ const reason = await runServer(startDevServer, options, 'dev', options.cwd, ui, uiMode)
258
+ if (reason === 'SIGINT') return 130
259
+ if (reason === 'SIGTERM') return 143
260
+ return 0
137
261
  }
138
262
 
139
263
  if (command === 'docs') {
@@ -141,6 +265,7 @@ export async function runCli(argv = process.argv.slice(2)) {
141
265
  const options = commonOptions(args)
142
266
  options.outdir = takeOption(args, '--outdir')
143
267
  options.basePath = takeOption(args, '--base')
268
+ options.mode = validateChoice(takeOption(args, '--mode') || 'site', '--mode', ['site', 'components'])
144
269
  options.host = takeOption(args, '--host')
145
270
  const port = takeOption(args, '--port')
146
271
  if (port) options.port = Number(port)
@@ -148,33 +273,64 @@ export async function runCli(argv = process.argv.slice(2)) {
148
273
  if (args[0]) options.cwd = args.shift()
149
274
  if (args.length) throw new Error(`unknown docs arguments: ${args.join(' ')}`)
150
275
  if (action === 'build') {
151
- printResult(await buildDocs(options))
276
+ ensureStaticMode(uiMode)
277
+ const components = options.mode === 'components'
278
+ printLines(formatBanner(ui, components ? 'docs components build' : 'docs build', version()))
279
+ const result = await buildDocs(options)
280
+ const count = components ? (result.demos || []).length : (result.routes || []).length
281
+ const noun = components ? 'demos' : 'routes'
282
+ printResult(ui, result, components ? 'Component workbench built' : 'Documentation built', ` ${ui.dim('·')} ${count} ${noun}`)
152
283
  return 0
153
284
  }
154
285
  if (action === 'dev') {
155
- await runServer(startDocsDevServer, options, 'docs dev', ui)
156
- return process.exitCode || 0
286
+ const commandName = options.mode === 'components' ? 'docs components' : 'docs dev'
287
+ const reason = await runServer(startDocsDevServer, options, commandName, options.cwd, ui, uiMode)
288
+ if (reason === 'SIGINT') return 130
289
+ if (reason === 'SIGTERM') return 143
290
+ return 0
157
291
  }
158
292
  throw new Error('docs requires build or dev')
159
293
  }
160
294
 
161
295
  if (command === 'parse' || command === 'tokenize') {
296
+ ensureStaticMode(uiMode)
297
+ const format = resolveFormat(takeOption(args, '--format'))
162
298
  const file = args.shift()
163
299
  if (!file || args.length) throw new Error(`${command} requires one source file`)
164
300
  const source = await readFile(file, 'utf8')
301
+ if (format === 'human') printLines(formatBanner(ui, command, version()))
302
+
165
303
  if (command === 'tokenize') {
166
- console.log(JSON.stringify(tokenize(source), null, 2))
167
- } else {
168
- const module = parse(source, {
169
- sourceType: file.endsWith('.cjs') ? 'script' : 'module',
170
- })
171
- try {
304
+ const result = tokenize(source)
305
+ if (format === 'json') {
306
+ console.log(JSON.stringify(result, null, 2))
307
+ } else {
308
+ console.log(' START..END KIND TEXT')
309
+ for (const token of result.tokens) {
310
+ const newline = token.newlineBefore ? ' ↵' : ''
311
+ console.log(
312
+ ` ${String(token.start).padStart(5)}..${String(token.end).padEnd(5)} ${String(token.kind).padEnd(18)} ${JSON.stringify(token.text)}${newline}`,
313
+ )
314
+ }
315
+ }
316
+ return result.diagnostics?.some((diagnostic) => diagnostic.severity === 'error') ? 1 : 0
317
+ }
318
+
319
+ const module = parse(source, {
320
+ sourceType: file.endsWith('.cjs') ? 'script' : 'module',
321
+ })
322
+ try {
323
+ if (format === 'json') {
172
324
  console.log(JSON.stringify(module.summary, null, 2))
173
- } finally {
174
- module.dispose()
325
+ } else {
326
+ console.log(`Parsed ${file}`)
327
+ console.log(` Statements ${String(module.summary.statementCount).padEnd(8)} Dependencies ${module.summary.dependencies}`)
328
+ console.log(` Source bytes ${module.summary.sourceBytes}`)
175
329
  }
330
+ return module.summary.diagnostics?.some((diagnostic) => diagnostic.severity === 'error') ? 1 : 0
331
+ } finally {
332
+ module.dispose()
176
333
  }
177
- return 0
178
334
  }
179
335
 
180
336
  throw new Error(`unknown command: ${command}`)
@@ -183,10 +339,8 @@ export async function runCli(argv = process.argv.slice(2)) {
183
339
  try {
184
340
  process.exitCode = await runCli()
185
341
  } catch (error) {
186
- const code = error?.code ? `[${error.code}] ` : ''
187
- console.error(`wake: ${code}${error?.message || error}`)
188
- for (const diagnostic of error?.diagnostics || []) {
189
- console.error(` ${diagnostic.severity}: ${diagnostic.message}`)
190
- }
342
+ const noColor = process.argv.includes('--no-color')
343
+ const ui = createUi(!noColor && supportsColor())
344
+ printLines(formatError(ui, error))
191
345
  process.exitCode = 1
192
346
  }
package/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface Diagnostic {
13
13
  severity: 'error' | 'warning' | 'note' | 'help'
14
14
  code?: string
15
15
  message: string
16
+ path?: string
16
17
  start?: number
17
18
  end?: number
18
19
  notes?: string[]
@@ -47,6 +48,8 @@ export interface OutputFile {
47
48
  export interface BuildResult {
48
49
  success: true
49
50
  moduleCount: number
51
+ updatedModuleCount: number
52
+ cachedModuleCount: number
50
53
  durationMs: number
51
54
  outputDir?: string
52
55
  code?: string
@@ -59,6 +62,8 @@ export interface BundleResult extends BuildResult {
59
62
  outputDir?: undefined
60
63
  }
61
64
 
65
+ export type DocsMode = 'site' | 'components'
66
+
62
67
  export interface DocsRoute {
63
68
  id: string
64
69
  file: string
@@ -72,14 +77,26 @@ export interface DocsRoute {
72
77
  draft: boolean
73
78
  headings: Array<{ depth: number; title: string; id: string }>
74
79
  }
80
+ export interface DocsDemo {
81
+ id: string
82
+ title: string
83
+ group: string
84
+ component: string
85
+ order: number
86
+ controlCount: number
87
+ warnings: string[]
88
+ }
75
89
 
76
90
  export interface DocsBuildOptions extends ProjectOptions {
77
91
  outdir?: string
78
92
  basePath?: string
93
+ mode?: DocsMode
79
94
  }
80
95
 
81
96
  export interface DocsBuildResult extends BuildResult {
82
97
  routes: DocsRoute[]
98
+ mode: DocsMode
99
+ demos: DocsDemo[]
83
100
  }
84
101
 
85
102
  export interface DevServerOptions extends ProjectOptions {
@@ -88,6 +105,9 @@ export interface DevServerOptions extends ProjectOptions {
88
105
  port?: number
89
106
  open?: boolean
90
107
  }
108
+ export interface DocsDevServerOptions extends DevServerOptions {
109
+ mode?: DocsMode
110
+ }
91
111
 
92
112
  export interface DevServerRebuildStartEvent {
93
113
  type: 'rebuildStart'
@@ -96,7 +116,12 @@ export interface DevServerRebuildStartEvent {
96
116
 
97
117
  export interface DevServerRebuiltEvent {
98
118
  type: 'rebuilt'
119
+ initial: boolean
99
120
  modules: number
121
+ updatedModules: number
122
+ cachedModules: number
123
+ chunks: number
124
+ assets: number
100
125
  durationMs: number
101
126
  }
102
127
 
@@ -126,7 +151,7 @@ export function build(options?: BuildOptions): Promise<BuildResult>
126
151
  export function createBuildContext(options?: BuildOptions): Promise<BuildContext>
127
152
  export function startDevServer(options?: DevServerOptions): Promise<DevServer>
128
153
  export function buildDocs(options?: DocsBuildOptions): Promise<DocsBuildResult>
129
- export function startDocsDevServer(options?: DevServerOptions): Promise<DevServer>
154
+ export function startDocsDevServer(options?: DocsDevServerOptions): Promise<DevServer>
130
155
 
131
156
  declare const wake: {
132
157
  BuildContext: typeof BuildContext
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crab-dev/wake",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Wake native web build tools for Node.js",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -45,11 +45,11 @@
45
45
  "node": ">=22.14 <27"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@crab-dev/wake-win32-x64-msvc": "0.1.3",
49
- "@crab-dev/wake-linux-x64-gnu": "0.1.3",
50
- "@crab-dev/wake-linux-arm64-gnu": "0.1.3",
51
- "@crab-dev/wake-darwin-x64": "0.1.3",
52
- "@crab-dev/wake-darwin-arm64": "0.1.3"
48
+ "@crab-dev/wake-darwin-arm64": "0.1.5",
49
+ "@crab-dev/wake-darwin-x64": "0.1.5",
50
+ "@crab-dev/wake-linux-arm64-gnu": "0.1.5",
51
+ "@crab-dev/wake-linux-x64-gnu": "0.1.5",
52
+ "@crab-dev/wake-win32-x64-msvc": "0.1.5"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public",