@leftium/gg 0.0.33 → 0.0.35

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/dist/gg.js CHANGED
@@ -1,4 +1,4 @@
1
- import debugFactory from './debug.js';
1
+ import debugFactory, {} from './debug/index.js';
2
2
  import { BROWSER, DEV } from 'esm-env';
3
3
  import { toWordTuple } from './words.js';
4
4
  const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_PLUGIN__ : false;
@@ -8,23 +8,21 @@ const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_P
8
8
  */
9
9
  function createGgDebugger(namespace) {
10
10
  const dbg = debugFactory(namespace);
11
- // Store the original formatArgs (if it exists)
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ // Store the original formatArgs
13
12
  const originalFormatArgs = dbg.formatArgs;
14
13
  // Override formatArgs to add padding to the namespace display
15
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
14
  dbg.formatArgs = function (args) {
17
15
  // Call original formatArgs first
18
16
  if (originalFormatArgs) {
19
- originalFormatArgs.call(this, args);
17
+ originalFormatArgs.call(dbg, args);
20
18
  }
21
19
  // Extract the callpoint from namespace (strip 'gg:' prefix and any URL suffix)
22
- const nsMatch = this.namespace.match(/^gg:([^h]+?)(?:http|$)/);
23
- const callpoint = nsMatch ? nsMatch[1] : this.namespace.replace(/^gg:/, '');
20
+ const nsMatch = dbg.namespace.match(/^gg:([^h]+?)(?:http|$)/);
21
+ const callpoint = nsMatch ? nsMatch[1] : dbg.namespace.replace(/^gg:/, '');
24
22
  const paddedCallpoint = callpoint.padEnd(maxCallpointLength, ' ');
25
23
  // Replace the namespace in the formatted string with padded version
26
24
  if (typeof args[0] === 'string') {
27
- args[0] = args[0].replace(this.namespace, `gg:${paddedCallpoint}`);
25
+ args[0] = args[0].replace(dbg.namespace, `gg:${paddedCallpoint}`);
28
26
  }
29
27
  };
30
28
  return dbg;
@@ -167,6 +165,23 @@ const namespaceToLogFunction = new Map();
167
165
  let maxCallpointLength = 0;
168
166
  // Cache: raw stack line → word tuple (avoids re-hashing the same call site)
169
167
  const stackLineCache = new Map();
168
+ /**
169
+ * Resolve the callpoint for the caller at the given stack depth.
170
+ * depth=2 → caller of gg(), depth=3 → caller of gg.ns() (extra frame).
171
+ */
172
+ function resolveCallpoint(depth) {
173
+ const rawStack = new Error().stack || '';
174
+ const callerLine = rawStack.split('\n')[depth] || rawStack;
175
+ const callerKey = callerLine.replace(/:\d+:\d+\)?$/, '').trim();
176
+ const callpoint = stackLineCache.get(callerKey) ?? toWordTuple(callerKey);
177
+ if (!stackLineCache.has(callerKey)) {
178
+ stackLineCache.set(callerKey, callpoint);
179
+ }
180
+ if (callpoint.length < 80 && callpoint.length > maxCallpointLength) {
181
+ maxCallpointLength = callpoint.length;
182
+ }
183
+ return callpoint;
184
+ }
170
185
  /**
171
186
  * Reset the namespace width tracking.
172
187
  * Useful after configuration checks that may have long callpoint paths.
@@ -190,69 +205,9 @@ export function gg(...args) {
190
205
  // When the plugin IS installed, all gg() calls are rewritten to gg._ns() at build time,
191
206
  // so this code path only runs for un-transformed calls (i.e. plugin not installed).
192
207
  // Same call site always produces the same word pair (e.g. "calm-fox").
193
- const rawStack = new Error().stack || '';
194
- // Stack line [2]: skip "Error" header [0] and gg() frame [1]
195
- const callerLine = rawStack.split('\n')[2] || rawStack;
196
- // Strip line:col numbers so all gg() calls within the same function
197
- // hash to the same word tuple. In minified builds, multiple gg() calls
198
- // in one function differ only by column offset — we want them grouped.
199
- // Chrome: "at handleClick (chunk-abc.js:1:45892)" → "at handleClick (chunk-abc.js)"
200
- // Firefox: "handleClick@https://...:1:45892" → "handleClick@https://..."
201
- const callerKey = callerLine.replace(/:\d+:\d+\)?$/, '').trim();
202
- const callpoint = stackLineCache.get(callerKey) ?? toWordTuple(callerKey);
203
- if (!stackLineCache.has(callerKey)) {
204
- stackLineCache.set(callerKey, callpoint);
205
- }
206
- if (callpoint.length < 80 && callpoint.length > maxCallpointLength) {
207
- maxCallpointLength = callpoint.length;
208
- }
209
- const namespace = `gg:${callpoint}`;
210
- const ggLogFunction = namespaceToLogFunction.get(namespace) ||
211
- namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
212
- // Prepare args for logging
213
- let logArgs;
214
- let returnValue;
215
- if (!args.length) {
216
- // No arguments: return stub call-site info (no open-in-editor without plugin)
217
- logArgs = [` 📝 ${callpoint} (install gg-call-sites-plugin for editor links)`];
218
- returnValue = {
219
- fileName: callpoint,
220
- functionName: '',
221
- url: ''
222
- };
223
- }
224
- else if (args.length === 1) {
225
- logArgs = [args[0]];
226
- returnValue = args[0];
227
- }
228
- else {
229
- logArgs = [args[0], ...args.slice(1)];
230
- returnValue = args[0];
231
- }
232
- // Log to console via debug
233
- if (logArgs.length === 1) {
234
- ggLogFunction(logArgs[0]);
235
- }
236
- else {
237
- ggLogFunction(logArgs[0], ...logArgs.slice(1));
238
- }
239
- // Call capture hook if registered (for Eruda plugin)
240
- const entry = {
241
- namespace,
242
- color: ggLogFunction.color,
243
- diff: ggLogFunction.diff || 0, // Millisecond diff from debug library
244
- message: logArgs.length === 1 ? String(logArgs[0]) : logArgs.map(String).join(' '),
245
- args: logArgs, // Keep raw args for object inspection
246
- timestamp: Date.now()
247
- };
248
- if (_onLogCallback) {
249
- _onLogCallback(entry);
250
- }
251
- else {
252
- // Buffer early logs before Eruda initializes
253
- earlyLogBuffer.push(entry);
254
- }
255
- return returnValue;
208
+ // depth=2: skip "Error" header [0] and gg() frame [1]
209
+ const callpoint = resolveCallpoint(2);
210
+ return ggLog({ ns: callpoint }, ...args);
256
211
  }
257
212
  /**
258
213
  * gg.ns() - Log with an explicit namespace (callpoint label).
@@ -261,31 +216,43 @@ export function gg(...args) {
261
216
  * across builds. For the internal plugin-generated version with file
262
217
  * metadata, see gg._ns().
263
218
  *
219
+ * The label supports template variables (substituted by the vite plugin
220
+ * at build time, or at runtime for $NS):
221
+ * $NS - auto-generated callpoint (file@fn with plugin, word-tuple without)
222
+ * $FN - enclosing function name (plugin only, empty without)
223
+ * $FILE - short file path (plugin only, empty without)
224
+ * $LINE - line number (plugin only, empty without)
225
+ * $COL - column number (plugin only, empty without)
226
+ *
264
227
  * @param nsLabel - The namespace label (appears as gg:<nsLabel> in output)
265
228
  * @param args - Same arguments as gg()
266
229
  * @returns Same as gg() - the first arg, or call-site info if no args
267
230
  *
268
231
  * @example
269
- * gg.ns("auth", "login failed") // logs under namespace "gg:auth"
270
- * gg.ns("cart", item, quantity) // logs under namespace "gg:cart"
232
+ * gg.ns("auth", "login failed") // gg:auth
233
+ * gg.ns("ERROR:$NS", msg) // gg:ERROR:routes/+page.svelte@handleClick (with plugin)
234
+ * // → gg:ERROR:calm-fox (without plugin)
235
+ * gg.ns("$NS:validation", fieldName) // → gg:routes/+page.svelte@handleClick:validation
271
236
  */
272
237
  gg.ns = function (nsLabel, ...args) {
238
+ // Resolve $NS at runtime (word-tuple fallback when plugin isn't installed).
239
+ // With the plugin, $NS is already substituted at build time before this runs.
240
+ // depth=3: skip "Error" [0], resolveCallpoint [1], gg.ns [2] → caller [3]
241
+ if (nsLabel.includes('$NS')) {
242
+ const callpoint = resolveCallpoint(3);
243
+ nsLabel = nsLabel.replace(/\$NS/g, callpoint);
244
+ }
273
245
  return gg._ns({ ns: nsLabel }, ...args);
274
246
  };
275
247
  /**
276
- * gg._ns() - Internal: log with namespace and source file metadata.
248
+ * Core logging function shared by all gg methods.
277
249
  *
278
- * Called by the ggCallSitesPlugin Vite plugin, which rewrites both bare gg()
279
- * calls and manual gg.ns() calls to gg._ns({ns, file, line, col}, ...) at
280
- * build time. This gives each call site a unique namespace plus the source
281
- * location for open-in-editor support.
282
- *
283
- * @param options - { ns: string; file?: string; line?: number; col?: number }
284
- * @param args - Same arguments as gg()
285
- * @returns Same as gg() - the first arg, or call-site info if no args
250
+ * All public methods (gg, gg.ns, gg.warn, gg.error, gg.table, etc.)
251
+ * funnel through this function. It handles namespace resolution,
252
+ * debug output, capture hook, and passthrough return.
286
253
  */
287
- gg._ns = function (options, ...args) {
288
- const { ns: nsLabel, file, line, col, src } = options;
254
+ function ggLog(options, ...args) {
255
+ const { ns: nsLabel, file, line, col, src, level, stack, tableData } = options;
289
256
  if (!ggConfig.enabled || isCloudflareWorker()) {
290
257
  return args.length ? args[0] : { fileName: '', functionName: '', url: '' };
291
258
  }
@@ -315,6 +282,13 @@ gg._ns = function (options, ...args) {
315
282
  logArgs = [args[0], ...args.slice(1)];
316
283
  returnValue = args[0];
317
284
  }
285
+ // Add level prefix emoji for warn/error
286
+ if (level === 'warn') {
287
+ logArgs[0] = `⚠️ ${logArgs[0]}`;
288
+ }
289
+ else if (level === 'error') {
290
+ logArgs[0] = `⛔ ${logArgs[0]}`;
291
+ }
318
292
  // Log to console via debug
319
293
  if (logArgs.length === 1) {
320
294
  ggLogFunction(logArgs[0]);
@@ -333,7 +307,10 @@ gg._ns = function (options, ...args) {
333
307
  file,
334
308
  line,
335
309
  col,
336
- src
310
+ src,
311
+ level,
312
+ stack,
313
+ tableData
337
314
  };
338
315
  if (_onLogCallback) {
339
316
  _onLogCallback(entry);
@@ -342,6 +319,33 @@ gg._ns = function (options, ...args) {
342
319
  earlyLogBuffer.push(entry);
343
320
  }
344
321
  return returnValue;
322
+ }
323
+ /**
324
+ * gg._ns() - Internal: log with namespace and source file metadata.
325
+ *
326
+ * Called by the ggCallSitesPlugin Vite plugin, which rewrites both bare gg()
327
+ * calls and manual gg.ns() calls to gg._ns({ns, file, line, col}, ...) at
328
+ * build time. This gives each call site a unique namespace plus the source
329
+ * location for open-in-editor support.
330
+ *
331
+ * @param options - { ns: string; file?: string; line?: number; col?: number }
332
+ * @param args - Same arguments as gg()
333
+ * @returns Same as gg() - the first arg, or call-site info if no args
334
+ */
335
+ gg._ns = function (options, ...args) {
336
+ return ggLog(options, ...args);
337
+ };
338
+ /**
339
+ * gg._o() - Internal: build options object for gg._ns() without object literal syntax.
340
+ *
341
+ * Used by the vite plugin to transform gg() calls in Svelte template markup,
342
+ * where object literals ({...}) would break Svelte's template parser.
343
+ *
344
+ * In <script> blocks: gg._ns({ns:'...', file:'...', line:1, col:1}, args)
345
+ * In template markup: gg._ns(gg._o('...','...',1,1), args)
346
+ */
347
+ gg._o = function (ns, file, line, col, src) {
348
+ return { ns, file, line, col, src };
345
349
  };
346
350
  gg.disable = isCloudflareWorker() ? () => { } : debugFactory.disable;
347
351
  gg.enable = isCloudflareWorker() ? () => { } : debugFactory.enable;
@@ -360,6 +364,357 @@ gg.clearPersist = () => {
360
364
  }
361
365
  }
362
366
  };
367
+ // ── Console-like methods ───────────────────────────────────────────────
368
+ // Each public method (gg.warn, gg.error, etc.) has a corresponding internal
369
+ // method (gg._warn, gg._error, etc.) that accepts call-site metadata from
370
+ // the Vite plugin. The public methods use runtime stack-based callpoints
371
+ // as a fallback when the plugin isn't installed.
372
+ /**
373
+ * Capture a cleaned-up stack trace, stripping internal gg frames.
374
+ * @param skipFrames - Number of internal frames to strip from the top
375
+ */
376
+ function captureStack(skipFrames) {
377
+ let stack = new Error().stack || undefined;
378
+ if (stack) {
379
+ const lines = stack.split('\n');
380
+ stack = lines.slice(skipFrames).join('\n');
381
+ }
382
+ return stack;
383
+ }
384
+ /**
385
+ * Get stack from an Error arg or capture a fresh one.
386
+ */
387
+ function getErrorStack(firstArg, skipFrames) {
388
+ if (firstArg instanceof Error && firstArg.stack) {
389
+ return firstArg.stack;
390
+ }
391
+ return captureStack(skipFrames);
392
+ }
393
+ /**
394
+ * gg.warn() - Log at warning level.
395
+ *
396
+ * Passthrough: returns the first argument.
397
+ * In Eruda, entries are styled with a yellow/warning indicator.
398
+ *
399
+ * @example
400
+ * gg.warn('deprecated API used');
401
+ * const result = gg.warn(computeValue(), 'might be slow');
402
+ */
403
+ gg.warn = function (...args) {
404
+ if (!ggConfig.enabled || isCloudflareWorker()) {
405
+ return args.length ? args[0] : undefined;
406
+ }
407
+ const callpoint = resolveCallpoint(3);
408
+ return ggLog({ ns: callpoint, level: 'warn' }, ...args);
409
+ };
410
+ /**
411
+ * gg._warn() - Internal: warn with call-site metadata from Vite plugin.
412
+ */
413
+ gg._warn = function (options, ...args) {
414
+ return ggLog({ ...options, level: 'warn' }, ...args);
415
+ };
416
+ /**
417
+ * gg.error() - Log at error level.
418
+ *
419
+ * Passthrough: returns the first argument.
420
+ * Captures a stack trace silently — visible in Eruda via a collapsible toggle.
421
+ * If the first argument is an Error object, its .stack is used instead.
422
+ *
423
+ * @example
424
+ * gg.error('connection failed');
425
+ * gg.error(new Error('timeout'));
426
+ * const val = gg.error(response, 'unexpected status');
427
+ */
428
+ gg.error = function (...args) {
429
+ if (!ggConfig.enabled || isCloudflareWorker()) {
430
+ return args.length ? args[0] : undefined;
431
+ }
432
+ const callpoint = resolveCallpoint(3);
433
+ const stack = getErrorStack(args[0], 4);
434
+ return ggLog({ ns: callpoint, level: 'error', stack }, ...args);
435
+ };
436
+ /**
437
+ * gg._error() - Internal: error with call-site metadata from Vite plugin.
438
+ */
439
+ gg._error = function (options, ...args) {
440
+ const stack = getErrorStack(args[0], 3);
441
+ return ggLog({ ...options, level: 'error', stack }, ...args);
442
+ };
443
+ /**
444
+ * gg.assert() - Log only if condition is false.
445
+ *
446
+ * Like console.assert: if the first argument is falsy, logs the remaining
447
+ * arguments at error level. If the condition is truthy, does nothing.
448
+ * Passthrough: always returns the condition value.
449
+ *
450
+ * @example
451
+ * gg.assert(user != null, 'user should exist');
452
+ * gg.assert(list.length > 0, 'list is empty', list);
453
+ */
454
+ gg.assert = function (condition, ...args) {
455
+ if (!condition) {
456
+ if (!ggConfig.enabled || isCloudflareWorker())
457
+ return condition;
458
+ const callpoint = resolveCallpoint(3);
459
+ const stack = captureStack(4);
460
+ const assertArgs = args.length > 0 ? args : ['Assertion failed'];
461
+ ggLog({ ns: callpoint, level: 'error', stack }, ...assertArgs);
462
+ }
463
+ return condition;
464
+ };
465
+ /**
466
+ * gg._assert() - Internal: assert with call-site metadata from Vite plugin.
467
+ */
468
+ gg._assert = function (options, condition, ...args) {
469
+ if (!condition) {
470
+ if (!ggConfig.enabled || isCloudflareWorker())
471
+ return condition;
472
+ const stack = captureStack(3);
473
+ const assertArgs = args.length > 0 ? args : ['Assertion failed'];
474
+ ggLog({ ...options, level: 'error', stack }, ...assertArgs);
475
+ }
476
+ return condition;
477
+ };
478
+ /**
479
+ * gg.table() - Log tabular data.
480
+ *
481
+ * Formats an array of objects (or an object of objects) as an ASCII table.
482
+ * Passthrough: returns the data argument.
483
+ *
484
+ * @example
485
+ * gg.table([{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]);
486
+ * gg.table({a: {x: 1}, b: {x: 2}});
487
+ */
488
+ gg.table = function (data, columns) {
489
+ if (!ggConfig.enabled || isCloudflareWorker())
490
+ return data;
491
+ const callpoint = resolveCallpoint(3);
492
+ const { keys, rows } = formatTable(data, columns);
493
+ ggLog({ ns: callpoint, tableData: { keys, rows } }, '(table)');
494
+ // Also emit a native console.table for proper rendering in browser/Node consoles
495
+ if (columns) {
496
+ console.table(data, columns);
497
+ }
498
+ else {
499
+ console.table(data);
500
+ }
501
+ return data;
502
+ };
503
+ /**
504
+ * gg._table() - Internal: table with call-site metadata from Vite plugin.
505
+ */
506
+ gg._table = function (options, data, columns) {
507
+ if (!ggConfig.enabled || isCloudflareWorker())
508
+ return data;
509
+ const { keys, rows } = formatTable(data, columns);
510
+ ggLog({ ...options, tableData: { keys, rows } }, '(table)');
511
+ if (columns) {
512
+ console.table(data, columns);
513
+ }
514
+ else {
515
+ console.table(data);
516
+ }
517
+ return data;
518
+ };
519
+ // Timer storage for gg.time / gg.timeEnd / gg.timeLog
520
+ const timers = new Map();
521
+ /**
522
+ * gg.time() - Start a named timer.
523
+ *
524
+ * @example
525
+ * gg.time('fetch');
526
+ * const data = await fetchData();
527
+ * gg.timeEnd('fetch'); // logs "+123ms fetch: 456ms"
528
+ */
529
+ gg.time = function (label = 'default') {
530
+ if (!ggConfig.enabled || isCloudflareWorker())
531
+ return;
532
+ timers.set(label, performance.now());
533
+ };
534
+ /** gg._time() - Internal: time with call-site metadata from Vite plugin. */
535
+ gg._time = function (_options, label = 'default') {
536
+ if (!ggConfig.enabled || isCloudflareWorker())
537
+ return;
538
+ timers.set(label, performance.now());
539
+ };
540
+ /**
541
+ * gg.timeLog() - Log the current elapsed time without stopping the timer.
542
+ *
543
+ * @example
544
+ * gg.time('process');
545
+ * // ... step 1 ...
546
+ * gg.timeLog('process', 'step 1 done');
547
+ * // ... step 2 ...
548
+ * gg.timeEnd('process');
549
+ */
550
+ gg.timeLog = function (label = 'default', ...args) {
551
+ if (!ggConfig.enabled || isCloudflareWorker())
552
+ return;
553
+ const start = timers.get(label);
554
+ if (start === undefined) {
555
+ const callpoint = resolveCallpoint(3);
556
+ ggLog({ ns: callpoint, level: 'warn' }, `Timer '${label}' does not exist`);
557
+ return;
558
+ }
559
+ const elapsed = performance.now() - start;
560
+ const callpoint = resolveCallpoint(3);
561
+ ggLog({ ns: callpoint }, `${label}: ${formatElapsed(elapsed)}`, ...args);
562
+ };
563
+ /** gg._timeLog() - Internal: timeLog with call-site metadata from Vite plugin. */
564
+ gg._timeLog = function (options, label = 'default', ...args) {
565
+ if (!ggConfig.enabled || isCloudflareWorker())
566
+ return;
567
+ const start = timers.get(label);
568
+ if (start === undefined) {
569
+ ggLog({ ...options, level: 'warn' }, `Timer '${label}' does not exist`);
570
+ return;
571
+ }
572
+ const elapsed = performance.now() - start;
573
+ ggLog(options, `${label}: ${formatElapsed(elapsed)}`, ...args);
574
+ };
575
+ /**
576
+ * gg.timeEnd() - Stop a named timer and log the elapsed time.
577
+ *
578
+ * @example
579
+ * gg.time('fetch');
580
+ * const data = await fetchData();
581
+ * gg.timeEnd('fetch'); // logs "fetch: 456.12ms"
582
+ */
583
+ gg.timeEnd = function (label = 'default') {
584
+ if (!ggConfig.enabled || isCloudflareWorker())
585
+ return;
586
+ const start = timers.get(label);
587
+ if (start === undefined) {
588
+ const callpoint = resolveCallpoint(3);
589
+ ggLog({ ns: callpoint, level: 'warn' }, `Timer '${label}' does not exist`);
590
+ return;
591
+ }
592
+ const elapsed = performance.now() - start;
593
+ timers.delete(label);
594
+ const callpoint = resolveCallpoint(3);
595
+ ggLog({ ns: callpoint }, `${label}: ${formatElapsed(elapsed)}`);
596
+ };
597
+ /** gg._timeEnd() - Internal: timeEnd with call-site metadata from Vite plugin. */
598
+ gg._timeEnd = function (options, label = 'default') {
599
+ if (!ggConfig.enabled || isCloudflareWorker())
600
+ return;
601
+ const start = timers.get(label);
602
+ if (start === undefined) {
603
+ ggLog({ ...options, level: 'warn' }, `Timer '${label}' does not exist`);
604
+ return;
605
+ }
606
+ const elapsed = performance.now() - start;
607
+ timers.delete(label);
608
+ ggLog(options, `${label}: ${formatElapsed(elapsed)}`);
609
+ };
610
+ /**
611
+ * gg.trace() - Log with a stack trace.
612
+ *
613
+ * Like console.trace: logs the arguments plus a full stack trace.
614
+ * Passthrough: returns the first argument.
615
+ *
616
+ * @example
617
+ * gg.trace('how did we get here?');
618
+ * const val = gg.trace(result, 'call path');
619
+ */
620
+ gg.trace = function (...args) {
621
+ if (!ggConfig.enabled || isCloudflareWorker()) {
622
+ return args.length ? args[0] : undefined;
623
+ }
624
+ const callpoint = resolveCallpoint(3);
625
+ const stack = captureStack(4);
626
+ const traceArgs = args.length > 0 ? args : ['Trace'];
627
+ return ggLog({ ns: callpoint, stack }, ...traceArgs);
628
+ };
629
+ /**
630
+ * gg._trace() - Internal: trace with call-site metadata from Vite plugin.
631
+ */
632
+ gg._trace = function (options, ...args) {
633
+ if (!ggConfig.enabled || isCloudflareWorker()) {
634
+ return args.length ? args[0] : undefined;
635
+ }
636
+ const stack = captureStack(3);
637
+ const traceArgs = args.length > 0 ? args : ['Trace'];
638
+ return ggLog({ ...options, stack }, ...traceArgs);
639
+ };
640
+ /**
641
+ * Format elapsed time with appropriate precision.
642
+ * < 1s → "123.45ms", >= 1s → "1.23s", >= 60s → "1m 2.3s"
643
+ */
644
+ function formatElapsed(ms) {
645
+ if (ms < 1000)
646
+ return `${ms.toFixed(2)}ms`;
647
+ if (ms < 60000)
648
+ return `${(ms / 1000).toFixed(2)}s`;
649
+ const minutes = Math.floor(ms / 60000);
650
+ const seconds = (ms % 60000) / 1000;
651
+ return `${minutes}m ${seconds.toFixed(1)}s`;
652
+ }
653
+ /**
654
+ * Normalize data into structured keys + rows for table rendering.
655
+ * Used by both Eruda (HTML table) and console.table() delegation.
656
+ * Supports arrays of objects, arrays of primitives, and objects of objects.
657
+ */
658
+ function formatTable(data, columns) {
659
+ if (data === null || data === undefined || typeof data !== 'object') {
660
+ return { keys: [], rows: [] };
661
+ }
662
+ // Normalize to rows: [{key, ...values}]
663
+ let rows;
664
+ let allKeys;
665
+ if (Array.isArray(data)) {
666
+ if (data.length === 0)
667
+ return { keys: [], rows: [] };
668
+ // Array of primitives
669
+ if (typeof data[0] !== 'object' || data[0] === null) {
670
+ allKeys = ['(index)', 'Value'];
671
+ rows = data.map((v, i) => ({ '(index)': i, Value: v }));
672
+ }
673
+ else {
674
+ // Array of objects
675
+ const keySet = new Set();
676
+ keySet.add('(index)');
677
+ for (const item of data) {
678
+ if (item && typeof item === 'object') {
679
+ Object.keys(item).forEach((k) => keySet.add(k));
680
+ }
681
+ }
682
+ allKeys = Array.from(keySet);
683
+ rows = data.map((item, i) => ({
684
+ '(index)': i,
685
+ ...(item && typeof item === 'object' ? item : { Value: item })
686
+ }));
687
+ }
688
+ }
689
+ else {
690
+ // Object of objects/values
691
+ const entries = Object.entries(data);
692
+ if (entries.length === 0)
693
+ return { keys: [], rows: [] };
694
+ const keySet = new Set();
695
+ keySet.add('(index)');
696
+ for (const [, val] of entries) {
697
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
698
+ Object.keys(val).forEach((k) => keySet.add(k));
699
+ }
700
+ else {
701
+ keySet.add('Value');
702
+ }
703
+ }
704
+ allKeys = Array.from(keySet);
705
+ rows = entries.map(([key, val]) => ({
706
+ '(index)': key,
707
+ ...(val && typeof val === 'object' && !Array.isArray(val)
708
+ ? val
709
+ : { Value: val })
710
+ }));
711
+ }
712
+ // Apply column filter
713
+ if (columns && columns.length > 0) {
714
+ allKeys = ['(index)', ...columns.filter((c) => allKeys.includes(c))];
715
+ }
716
+ return { keys: allKeys, rows };
717
+ }
363
718
  /**
364
719
  * Parse color string to RGB values
365
720
  * Accepts: named colors, hex (#rgb, #rrggbb), rgb(r,g,b), rgba(r,g,b,a)
@@ -499,26 +854,28 @@ Object.defineProperty(gg, '_onLog', {
499
854
  // eslint-disable-next-line @typescript-eslint/no-namespace
500
855
  (function (gg) {
501
856
  })(gg || (gg = {}));
857
+ // Track if diagnostics have already run to prevent double execution
858
+ let diagnosticsRan = false;
502
859
  /**
503
860
  * Run gg diagnostics and log configuration status
504
861
  * Can be called immediately or delayed (e.g., after Eruda loads)
505
862
  */
506
863
  export async function runGgDiagnostics() {
507
- if (!ggConfig.showHints || isCloudflareWorker())
864
+ if (!ggConfig.showHints || isCloudflareWorker() || diagnosticsRan)
508
865
  return;
866
+ diagnosticsRan = true;
867
+ // Create test debugger for server-side enabled check
509
868
  const ggLogTest = debugFactory('gg:TEST');
510
869
  let ggMessage = '\n';
511
- // Utilities for forming ggMessage:
512
870
  const message = (s) => (ggMessage += `${s}\n`);
513
871
  const checkbox = (test) => (test ? '✅' : '❌');
514
872
  const makeHint = (test, ifTrue, ifFalse = '') => (test ? ifTrue : ifFalse);
515
- // Use plain console.log for diagnostics - appears in Eruda's Console tab
516
873
  console.log(`Loaded gg module. Checking configuration...`);
517
- if (ggConfig.enabled && ggLogTest.enabled) {
518
- gg('If you can see this logg, gg configured correctly!');
874
+ const configOk = BROWSER ? ggConfig.enabled : ggConfig.enabled && ggLogTest.enabled;
875
+ if (configOk) {
519
876
  message(`No problems detected:`);
520
877
  if (BROWSER) {
521
- message(`ℹ️ If gg output not visible: enable "Verbose" log level in DevTools, or check Eruda's GG tab.`);
878
+ message(`ℹ️ gg messages appear in the Eruda GG panel. Use Settings > Native Console to also show in browser console.`);
522
879
  }
523
880
  }
524
881
  else {
@@ -534,35 +891,27 @@ export async function runGgDiagnostics() {
534
891
  }
535
892
  }
536
893
  message(`${checkbox(ggConfig.enabled)} gg enabled: ${ggConfig.enabled}${enableHint}`);
537
- if (BROWSER) {
538
- const hint = makeHint(!ggLogTest.enabled, " (Try `localStorage.debug = 'gg:*'`)");
539
- message(`${checkbox(ggLogTest.enabled)} localStorage.debug: ${localStorage?.debug}${hint}`);
540
- }
541
- else {
542
- const hint = makeHint(!ggLogTest.enabled, ' (Try `DEBUG=gg:* npm dev`)');
894
+ if (!BROWSER) {
895
+ // Server-side: check DEBUG env var (the only output path on the server)
896
+ const hint = makeHint(!ggLogTest.enabled, ' (Try `DEBUG=gg:* npm run dev`)');
543
897
  if (dotenvModule) {
544
- dotenvModule.config(); // Load the environment variables
898
+ dotenvModule.config();
545
899
  }
546
900
  message(`${checkbox(ggLogTest.enabled)} DEBUG env variable: ${process?.env?.DEBUG}${hint}`);
547
901
  }
548
- // Optional plugin diagnostics listed last
902
+ // Optional plugin diagnostics
549
903
  message(makeHint(_ggCallSitesPlugin, `✅ gg-call-sites vite plugin detected! Call-site namespaces and open-in-editor links baked in at build time.`, `⚠️ gg-call-sites vite plugin not detected. Add ggCallSitesPlugin() to vite.config.ts for file:line call-site namespaces and open-in-editor links. Without plugin, using word-tuple names (e.g. calm-fox) as call-site identifiers.`));
550
904
  if (BROWSER && DEV) {
551
905
  const { status } = await fetch('/__open-in-editor?file=+');
552
906
  message(makeHint(status === 222, `✅ (optional) open-in-editor vite plugin detected! (status code: ${status}) Clickable links open source files in editor.`, `⚠️ (optional) open-in-editor vite plugin not detected. (status code: ${status}) Add openInEditorPlugin() to vite.config.ts for clickable links that open source files in editor`));
553
907
  }
554
- // Use plain console.log for diagnostics - appears in Eruda's Console tab
555
908
  console.log(ggMessage);
556
- // Reset namespace width after configuration check
557
- // This prevents the long callpoint from the config check from affecting subsequent logs
558
909
  resetNamespaceWidth();
559
910
  }
560
- // Run diagnostics immediately on module load if Eruda is not being used
561
- // (If Eruda will load, the loader will call runGgDiagnostics after Eruda is ready)
562
- if (ggConfig.showHints && !isCloudflareWorker()) {
563
- // Only run immediately if we're not in a context where Eruda might load
564
- // In browser dev mode, assume Eruda might load and skip immediate diagnostics
565
- if (!BROWSER || !DEV) {
566
- runGgDiagnostics();
567
- }
911
+ // Run diagnostics immediately on module load ONLY in Node.js environments
912
+ // In browser, the Eruda loader (if configured) will call runGgDiagnostics()
913
+ // after Eruda is ready. If Eruda is not configured, diagnostics won't run
914
+ // in browser (user must manually check console or call runGgDiagnostics()).
915
+ if (ggConfig.showHints && !isCloudflareWorker() && !BROWSER) {
916
+ runGgDiagnostics();
568
917
  }