bimba-cli 0.7.30 → 0.7.32

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.js +185 -70
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.7.30",
3
+ "version": "0.7.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -3,7 +3,6 @@ import * as compiler from 'imba/compiler'
3
3
  import { mkdirSync, watch, existsSync, statSync, writeFileSync, realpathSync } from 'fs'
4
4
  import path from 'path'
5
5
  import { theme } from './utils.js'
6
- import { printerr } from './plugin.js'
7
6
 
8
7
  // ─── HMR Client (injected into browser) ──────────────────────────────────────
9
8
 
@@ -628,29 +627,69 @@ export function serve(entrypoint, flags) {
628
627
  const srcDir = path.dirname(entrypoint)
629
628
  const sockets = new Set()
630
629
 
631
- // ── Status line (prints current compile result, fades out on success) ──────
630
+ // ── Live status block (shows only the current compile state) ───────────────
632
631
 
633
- let _fadeTimers = []
634
- let _fadeId = 0
632
+ let _statusRows = 0
633
+ let _statusTimer = null
635
634
  let _statusFile = null
636
- let _statusSaved = false
637
- let _statusKind = null
635
+ let _statusInline = false
636
+ let _statusWidth = 0
637
+ let _eraseTimers = []
638
638
  const _isTTY = process.stdout.isTTY
639
639
 
640
- function cancelFade() {
641
- _fadeTimers.forEach(t => clearTimeout(t))
642
- _fadeTimers = []
640
+ function stripAnsi(text) {
641
+ return String(text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
642
+ }
643
+
644
+ function renderedRows(lines) {
645
+ const columns = Math.max(1, process.stdout.columns || 80)
646
+ return lines.reduce((total, line) => {
647
+ const length = stripAnsi(line).length
648
+ return total + Math.max(1, Math.ceil(length / columns))
649
+ }, 0)
643
650
  }
644
651
 
645
652
  function clearStatus(file) {
646
- if (file && _statusFile && _statusFile !== file) return
647
- cancelFade()
648
- if (_statusSaved) {
649
- process.stdout.write('\x1b[u\x1b[J')
650
- _statusSaved = false
653
+ if (file && _statusFile && _statusFile !== file) return false
654
+ if (_statusTimer) {
655
+ clearTimeout(_statusTimer)
656
+ _statusTimer = null
657
+ }
658
+ _eraseTimers.forEach(timer => clearTimeout(timer))
659
+ _eraseTimers = []
660
+ if (_isTTY && _statusInline) {
661
+ process.stdout.write('\r\x1b[J')
662
+ } else if (_isTTY && _statusRows) {
663
+ process.stdout.write(`\x1b[${_statusRows}A\r\x1b[J`)
651
664
  }
665
+ _statusRows = 0
652
666
  _statusFile = null
653
- _statusKind = null
667
+ _statusInline = false
668
+ _statusWidth = 0
669
+ return true
670
+ }
671
+
672
+ function formatErrorLines(errors) {
673
+ const lines = []
674
+ for (const err of errors || []) {
675
+ const message = errorMessage(err)
676
+ const line = errorLine(err)
677
+ lines.push(` ${theme.error(' ' + message + ' ')}${line != null ? theme.margin(` line ${line + 1} `) : ''}`)
678
+ const snippet = errorSnippet(err)
679
+ if (snippet && snippet !== message) {
680
+ lines.push(...String(snippet).split('\n').slice(0, 6).map(item => ` ${theme.code(item)}`))
681
+ }
682
+ lines.push('')
683
+ }
684
+ return lines
685
+ }
686
+
687
+ function statusLines(file, state, errors) {
688
+ const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
689
+ const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
690
+ const lines = [` ${theme.folder(now)} ${theme.filename(file)} ${status}`]
691
+ if (errors?.length) lines.push('', ...formatErrorLines(errors))
692
+ return lines
654
693
  }
655
694
 
656
695
  function printStatus(file, state, errors, options = {}) {
@@ -670,45 +709,46 @@ export function serve(entrypoint, flags) {
670
709
  return
671
710
  }
672
711
 
673
- cancelFade()
674
- if (_statusSaved) {
675
- process.stdout.write('\x1b[u\x1b[J')
676
- _statusSaved = false
712
+ clearStatus()
713
+ _statusFile = file
714
+ const lines = statusLines(file, state, errors)
715
+ if (options.fadeAfter && lines.length === 1) {
716
+ const plain = stripAnsi(lines[0])
717
+ process.stdout.write(lines[0])
718
+ _statusRows = 1
719
+ _statusInline = true
720
+ _statusWidth = plain.length
721
+ _statusTimer = setTimeout(() => eraseStatus(file), options.fadeAfter)
722
+ return
677
723
  }
678
- const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
679
- const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
680
724
 
681
- if (errors?.length || options.sticky) {
682
- process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}\n`)
683
- if (errors?.length) {
684
- for (const err of errors) {
685
- try { printerr(err) } catch(_) { process.stdout.write(' ' + err.message + '\n') }
686
- }
687
- }
688
- _statusFile = null
689
- _statusKind = null
725
+ process.stdout.write(lines.join('\n') + '\n')
726
+ _statusRows = renderedRows(lines)
727
+
728
+ if (options.clearAfter) _statusTimer = setTimeout(() => clearStatus(file), options.clearAfter)
729
+ }
730
+
731
+ function eraseStatus(file) {
732
+ if (file && _statusFile && _statusFile !== file) return
733
+ _statusTimer = null
734
+ if (!_isTTY || !_statusInline) {
735
+ clearStatus(file)
690
736
  return
691
737
  }
692
738
 
693
- process.stdout.write('\x1b[s')
694
- _statusSaved = true
695
- _statusFile = file
696
- _statusKind = state
697
-
698
- const myId = ++_fadeId
699
- const plainLine = ` ${now} ${file} ok `
700
- const total = plainLine.length
701
- process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}`)
739
+ const total = _statusWidth
702
740
  for (let i = 1; i <= total; i++) {
703
- _fadeTimers.push(setTimeout(() => {
704
- if (_fadeId !== myId) return
741
+ _eraseTimers.push(setTimeout(() => {
742
+ if (!_statusInline || _statusFile !== file) return
705
743
  process.stdout.write('\x1b[1D \x1b[1D')
706
744
  if (i === total) {
707
- _statusSaved = false
745
+ _statusRows = 0
708
746
  _statusFile = null
709
- _statusKind = null
747
+ _statusInline = false
748
+ _statusWidth = 0
749
+ _eraseTimers = []
710
750
  }
711
- }, 5000 + i * 22))
751
+ }, i * 22))
712
752
  }
713
753
  }
714
754
 
@@ -853,9 +893,72 @@ export function serve(entrypoint, flags) {
853
893
  .join('\n---\n')
854
894
  }
855
895
 
896
+ function renderActiveErrors() {
897
+ if (!_isTTY) return false
898
+ if (!_activeErrors.size) {
899
+ clearStatus()
900
+ return false
901
+ }
902
+
903
+ clearStatus()
904
+ const lines = []
905
+ for (const item of _activeErrors.values()) {
906
+ if (lines.length) lines.push('')
907
+ lines.push(...statusLines(item.file, 'fail', item.errors))
908
+ }
909
+ process.stdout.write(lines.join('\n') + '\n')
910
+ _statusFile = null
911
+ _statusRows = renderedRows(lines)
912
+ return true
913
+ }
914
+
915
+ function existingFileForError(file) {
916
+ for (const candidate of fileCandidates(file)) {
917
+ try {
918
+ const stat = statSync(candidate)
919
+ if (stat.isFile()) return candidate
920
+ } catch(_) {
921
+ // ignore aliases that no longer exist
922
+ }
923
+ }
924
+ return null
925
+ }
926
+
927
+ async function reconcileActiveErrors() {
928
+ if (!_activeErrors.size) return false
929
+
930
+ let changed = false
931
+ const items = Array.from(_activeErrors.values())
932
+ for (const item of items) {
933
+ const filepath = existingFileForError(item.file)
934
+ if (!filepath) {
935
+ if (takeError(item.file)) {
936
+ _terminalErrors.delete(terminalErrorKey(item.file))
937
+ broadcast({ type: 'clear-error', file: item.file })
938
+ changed = true
939
+ }
940
+ continue
941
+ }
942
+
943
+ if (!filepath.endsWith('.imba')) continue
944
+
945
+ const out = await compileFile(filepath)
946
+ if (out.errors?.length) continue
947
+
948
+ if (takeError(item.file)) {
949
+ _terminalErrors.delete(terminalErrorKey(item.file))
950
+ broadcast({ type: 'clear-error', file: item.file })
951
+ changed = true
952
+ }
953
+ }
954
+
955
+ return changed
956
+ }
957
+
856
958
  function showTrackedError(item) {
857
959
  const file = item.file
858
- printStatus(file, 'fail', item.errors)
960
+ if (_isTTY) renderActiveErrors()
961
+ else printStatus(file, 'fail', item.errors)
859
962
  broadcast({ type: 'error', file, time: item.time, errors: item.payload })
860
963
  }
861
964
 
@@ -884,9 +987,10 @@ export function serve(entrypoint, flags) {
884
987
  _activeErrors.set(key, item)
885
988
  _terminalErrors.set(terminalKey, { signature: printSignature, time: now })
886
989
 
887
- // The terminal is append-only in many real shells. Repeated reports of the
888
- // same active error update the browser overlay, but must not print again.
990
+ // Repeated reports of the same active error update the live status and
991
+ // browser overlay, but do not create another terminal entry.
889
992
  if (duplicate) {
993
+ if (_isTTY) renderActiveErrors()
890
994
  broadcast({ type: 'error', file: display, time: item.time, errors: item.payload })
891
995
  return
892
996
  }
@@ -909,25 +1013,36 @@ export function serve(entrypoint, flags) {
909
1013
  const wasStatusFile = key && _statusFile && sameFile(_statusFile, key)
910
1014
  const hadError = key ? !!takeError(key) : _activeErrors.size > 0
911
1015
 
912
- if (!key) _activeErrors.clear()
1016
+ if (!key) {
1017
+ _activeErrors.clear()
1018
+ _terminalErrors.clear()
1019
+ } else if (hadError) {
1020
+ _terminalErrors.delete(terminalErrorKey(key))
1021
+ }
913
1022
 
914
- if (!key || hadError || wasStatusFile) clearStatus(key)
1023
+ let showedNext = false
1024
+ if (_isTTY && _activeErrors.size) showedNext = renderActiveErrors()
1025
+ else if (!key || hadError || wasStatusFile) clearStatus(key)
915
1026
 
916
1027
  broadcast({ type: 'clear-error', file: key })
917
1028
 
918
- return { cleared: hadError || wasStatusFile, file: key, showedNext: false }
1029
+ return { cleared: hadError || wasStatusFile, file: key, showedNext }
919
1030
  }
920
1031
 
921
- function markSuccess(file) {
1032
+ async function markSuccess(file) {
922
1033
  const key = normalizeFile(file)
923
1034
  const result = clearError(key)
1035
+ const reconciled = await reconcileActiveErrors()
924
1036
  const active = _activeErrors.size
925
- const shouldPrint = result?.cleared && !result.showedNext
1037
+ let showedNext = false
1038
+ if (active && _isTTY) showedNext = renderActiveErrors()
1039
+ else if (_isTTY && (reconciled || result.showedNext)) clearStatus()
1040
+ else showedNext = result.showedNext
1041
+ const shouldPrint = (result?.cleared || reconciled) && !showedNext && !active
926
1042
  if (shouldPrint) {
927
- _terminalErrors.delete(terminalErrorKey(key))
928
- printStatus(key, 'ok', null, { sticky: true })
1043
+ printStatus(key, 'ok', null, { fadeAfter: 3500 })
929
1044
  }
930
- return { cleared: !!result?.cleared, printed: !!shouldPrint, showedNext: !!result?.showedNext, active }
1045
+ return { cleared: !!result?.cleared || reconciled, printed: !!shouldPrint, showedNext: !!showedNext, active }
931
1046
  }
932
1047
 
933
1048
  const _debounce = new Map()
@@ -995,12 +1110,12 @@ export function serve(entrypoint, flags) {
995
1110
  return
996
1111
  }
997
1112
 
998
- const success = markSuccess(rel)
1113
+ const success = await markSuccess(rel)
999
1114
 
1000
1115
  // No change at all — skip
1001
1116
  if (out.changeType === 'none' || out.changeType === 'cached') return
1002
1117
 
1003
- if (!success.printed && !success.showedNext && !success.active) printStatus(rel, 'ok')
1118
+ if (!success.printed && !success.showedNext && !success.active) printStatus(rel, 'ok', null, { fadeAfter: 3500 })
1004
1119
  broadcast({ type: 'update', file: rel, slots: out.slots || 'shifted' })
1005
1120
  } catch(e) {
1006
1121
  if (!isCurrentChange(file, version)) return
@@ -1038,7 +1153,7 @@ export function serve(entrypoint, flags) {
1038
1153
  const file = 'vendor:' + (specifier || pathname)
1039
1154
  const bundled = specifier ? await bundleVendor(specifier) : null
1040
1155
  if (bundled?.code) {
1041
- markSuccess(file)
1156
+ await markSuccess(file)
1042
1157
  return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
1043
1158
  }
1044
1159
 
@@ -1051,7 +1166,7 @@ export function serve(entrypoint, flags) {
1051
1166
  const file = 'html:' + normalizeFile(htmlFile)
1052
1167
  try {
1053
1168
  let html = await Bun.file(htmlFile).text()
1054
- markSuccess(file)
1169
+ await markSuccess(file)
1055
1170
  return new Response(transformHtml(html, entrypoint), {
1056
1171
  headers: { 'Content-Type': 'text/html' },
1057
1172
  })
@@ -1077,7 +1192,7 @@ export function serve(entrypoint, flags) {
1077
1192
  if (out.errors?.length) {
1078
1193
  return errorResponse(file, out.errors)
1079
1194
  }
1080
- markSuccess(file)
1195
+ await markSuccess(file)
1081
1196
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
1082
1197
  } catch(e) {
1083
1198
  if (isMissingFileError(e)) {
@@ -1099,7 +1214,7 @@ export function serve(entrypoint, flags) {
1099
1214
  try {
1100
1215
  if (cssFile && await cssFile.exists()) {
1101
1216
  if (req.headers.get('sec-fetch-dest') === 'style') {
1102
- markSuccess(file)
1217
+ await markSuccess(file)
1103
1218
  return new Response(cssFile, { headers: { 'Content-Type': 'text/css' } })
1104
1219
  }
1105
1220
 
@@ -1111,7 +1226,7 @@ export function serve(entrypoint, flags) {
1111
1226
  `if (!el) { el = document.createElement('style'); el.setAttribute('data-bimba-css', id); document.head.appendChild(el); }`,
1112
1227
  `el.textContent = ${JSON.stringify(css)};`,
1113
1228
  ].join('\n')
1114
- markSuccess(file)
1229
+ await markSuccess(file)
1115
1230
  return new Response(js, { headers: { 'Content-Type': 'application/javascript' } })
1116
1231
  }
1117
1232
  } catch (error) {
@@ -1129,7 +1244,7 @@ export function serve(entrypoint, flags) {
1129
1244
  const file = 'js:' + normalizeFile(jsFile)
1130
1245
  try {
1131
1246
  const response = await serveJavaScriptFile(jsFile)
1132
- markSuccess(file)
1247
+ await markSuccess(file)
1133
1248
  return response
1134
1249
  } catch (error) {
1135
1250
  if (isMissingFileError(error)) {
@@ -1156,7 +1271,7 @@ export function serve(entrypoint, flags) {
1156
1271
  if (out.errors?.length) {
1157
1272
  return errorResponse(file, out.errors)
1158
1273
  }
1159
- markSuccess(file)
1274
+ await markSuccess(file)
1160
1275
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
1161
1276
  }
1162
1277
 
@@ -1164,7 +1279,7 @@ export function serve(entrypoint, flags) {
1164
1279
  const file = 'vendor:' + normalizeFile(pathname)
1165
1280
  const bundled = await bundleVendor(path.resolve(resolved))
1166
1281
  if (bundled?.code) {
1167
- markSuccess(file)
1282
+ await markSuccess(file)
1168
1283
  return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
1169
1284
  }
1170
1285
  return errorResponse(file, bundled?.errors || [`Could not bundle ${pathname}`])
@@ -1176,13 +1291,13 @@ export function serve(entrypoint, flags) {
1176
1291
  const inHtmlDirPath = path.join(htmlDir, pathname)
1177
1292
  const inHtmlDir = Bun.file(inHtmlDirPath)
1178
1293
  if (await inHtmlDir.exists()) {
1179
- markSuccess('static:' + normalizeFile(inHtmlDirPath))
1294
+ await markSuccess('static:' + normalizeFile(inHtmlDirPath))
1180
1295
  return new Response(inHtmlDir)
1181
1296
  }
1182
1297
  const inRootPath = '.' + pathname
1183
1298
  const inRoot = Bun.file(inRootPath)
1184
1299
  if (await inRoot.exists()) {
1185
- markSuccess('static:' + normalizeFile(inRootPath))
1300
+ await markSuccess('static:' + normalizeFile(inRootPath))
1186
1301
  return new Response(inRoot)
1187
1302
  }
1188
1303
  } catch (error) {
@@ -1204,7 +1319,7 @@ export function serve(entrypoint, flags) {
1204
1319
  if (out.errors?.length) {
1205
1320
  return errorResponse(file, out.errors)
1206
1321
  }
1207
- markSuccess(file)
1322
+ await markSuccess(file)
1208
1323
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
1209
1324
  }
1210
1325
  for (const ext of ['.js', '.mjs']) {
@@ -1213,7 +1328,7 @@ export function serve(entrypoint, flags) {
1213
1328
  const file = 'js:' + normalizeFile(withExt)
1214
1329
  try {
1215
1330
  const response = await serveJavaScriptFile(withExt)
1216
- markSuccess(file)
1331
+ await markSuccess(file)
1217
1332
  return response
1218
1333
  } catch (error) {
1219
1334
  if (isMissingFileError(error)) {
@@ -1231,7 +1346,7 @@ export function serve(entrypoint, flags) {
1231
1346
  const file = 'html:' + normalizeFile(htmlPath)
1232
1347
  try {
1233
1348
  let html = await Bun.file(htmlPath).text()
1234
- markSuccess(file)
1349
+ await markSuccess(file)
1235
1350
  return new Response(transformHtml(html, entrypoint), {
1236
1351
  headers: { 'Content-Type': 'text/html' },
1237
1352
  })