@leftium/gg 0.0.26 → 0.0.28

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/README.md CHANGED
@@ -20,7 +20,67 @@ npm add @leftium/gg
20
20
 
21
21
  ## Usage
22
22
 
23
- _Coming soon..._
23
+ ### Basic Logging
24
+
25
+ ```javascript
26
+ import { gg } from '@leftium/gg';
27
+
28
+ // Simple logging
29
+ gg('Hello world');
30
+
31
+ // Log expressions (returns first argument)
32
+ const result = gg(someFunction());
33
+
34
+ // Multiple arguments
35
+ gg('User:', user, 'Status:', status);
36
+ ```
37
+
38
+ ### Color Support (ANSI)
39
+
40
+ Color your logs for better visual distinction using `fg()` (foreground/text) and `bg()` (background):
41
+
42
+ ```javascript
43
+ import { gg, fg, bg } from '@leftium/gg';
44
+
45
+ // Simple foreground/background colors
46
+ gg(fg('red')`Error occurred`);
47
+ gg(bg('yellow')`Warning message`);
48
+
49
+ // Method chaining (order doesn't matter!)
50
+ gg(fg('white').bg('red')`Critical error!`);
51
+ gg(bg('green').fg('white')`Success message`);
52
+
53
+ // Define reusable color schemes
54
+ const input = fg('blue').bg('yellow');
55
+ const transcript = bg('green').fg('white');
56
+ const error = fg('white').bg('red');
57
+
58
+ gg(input`User input message`);
59
+ gg(transcript`AI transcript response`);
60
+ gg(error`Something went wrong`);
61
+
62
+ // Mix colored and normal text
63
+ gg(fg('red')`Error: ` + bg('yellow')`warning` + ' normal text');
64
+
65
+ // Custom hex colors with chaining
66
+ gg(fg('#ff6347').bg('#98fb98')`Custom colors`);
67
+
68
+ // RGB colors
69
+ gg(fg('rgb(255,99,71)')`Tomato text`);
70
+ ```
71
+
72
+ **Supported color formats:**
73
+
74
+ - Named colors: `'red'`, `'green'`, `'blue'`, `'cyan'`, `'magenta'`, `'yellow'`, `'white'`, `'black'`, `'gray'`, `'orange'`, `'purple'`, `'pink'`
75
+ - Hex codes: `'#ff0000'`, `'#f00'`
76
+ - RGB: `'rgb(255,0,0)'`, `'rgba(255,0,0,0.5)'`
77
+
78
+ **Where colors work:**
79
+
80
+ - ✅ Native browser console (Chrome DevTools, Firefox, etc.)
81
+ - ✅ Eruda GG panel (mobile debugging)
82
+ - ✅ Node.js terminal
83
+ - ✅ All environments that support ANSI escape codes
24
84
 
25
85
  ## Technical Details
26
86
 
@@ -62,6 +62,17 @@ export async function loadEruda(options) {
62
62
  // Dynamic import of Eruda
63
63
  const erudaModule = await import('eruda');
64
64
  const eruda = erudaModule.default;
65
+ // Clear Eruda position state to prevent icon from being stuck in wrong position
66
+ // Eruda stores draggable icon position in localStorage which can get corrupted
67
+ // This ensures the icon always appears in the default bottom-right corner
68
+ try {
69
+ // Eruda uses keys like 'eruda-entry-button' for position state
70
+ const positionKeys = ['eruda-entry-button', 'eruda-position'];
71
+ positionKeys.forEach((key) => localStorage.removeItem(key));
72
+ }
73
+ catch {
74
+ // localStorage might not be available
75
+ }
65
76
  // Initialize Eruda
66
77
  eruda.init({
67
78
  ...options.erudaOptions,
@@ -81,13 +92,15 @@ export async function loadEruda(options) {
81
92
  }
82
93
  // Register gg plugin
83
94
  // Import gg and pass it to the plugin directly
84
- const { gg } = await import('../gg.js');
95
+ const { gg, runGgDiagnostics } = await import('../gg.js');
85
96
  const { createGgPlugin } = await import('./plugin.js');
86
97
  const ggPlugin = createGgPlugin(options, gg);
87
98
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
99
  eruda.add(ggPlugin);
89
100
  // Make GG tab the default selected tab
90
101
  eruda.show('GG');
102
+ // Run diagnostics after Eruda is ready so they appear in Console tab
103
+ await runGgDiagnostics();
91
104
  }
92
105
  catch (error) {
93
106
  console.error('[gg] Failed to load Eruda:', error);
@@ -172,29 +172,37 @@ export function createGgPlugin(options, gg) {
172
172
  }
173
173
  function gridColumns() {
174
174
  const ns = nsColWidth !== null ? `${nsColWidth}px` : 'auto';
175
- if (filterExpanded) {
176
- // [×] | diff | ns | handle | content
177
- return `24px auto ${ns} 4px 1fr`;
178
- }
179
- else {
180
- // diff | ns | handle | content
181
- return `auto ${ns} 4px 1fr`;
182
- }
175
+ // diff | ns | handle | content (× is now inside ns)
176
+ return `auto ${ns} 4px 1fr`;
183
177
  }
184
178
  function buildHTML() {
185
179
  return `
186
180
  <style>
187
- .gg-log-grid {
188
- display: grid;
189
- grid-template-columns: ${gridColumns()};
190
- column-gap: 0;
191
- align-items: start !important;
192
- }
193
- .gg-log-grid > * {
194
- min-width: 0;
195
- border-top: 1px solid rgba(0,0,0,0.05);
196
- align-self: start !important;
197
- }
181
+ .gg-log-grid {
182
+ display: grid;
183
+ grid-template-columns: ${gridColumns()};
184
+ column-gap: 0;
185
+ align-items: start !important;
186
+ }
187
+ /* Desktop: hide wrapper divs, show direct children */
188
+ .gg-log-entry {
189
+ display: contents;
190
+ }
191
+ .gg-log-header {
192
+ display: contents;
193
+ }
194
+ .gg-log-diff,
195
+ .gg-log-ns,
196
+ .gg-log-handle,
197
+ .gg-log-content {
198
+ min-width: 0;
199
+ align-self: start !important;
200
+ border-top: 1px solid rgba(0,0,0,0.05);
201
+ }
202
+ .gg-details {
203
+ grid-column: 1 / -1;
204
+ border-top: none;
205
+ }
198
206
  .gg-details {
199
207
  align-self: stretch !important;
200
208
  border-bottom: none;
@@ -234,19 +242,15 @@ export function createGgPlugin(options, gg) {
234
242
  word-break: break-word;
235
243
  padding: 4px 0;
236
244
  }
237
- .gg-row-filter {
238
- text-align: center;
239
- padding: 4px 8px 4px 0;
240
- cursor: pointer;
241
- user-select: none;
242
- opacity: 0.6;
243
- font-size: 14px;
244
- align-self: start;
245
- }
246
- .gg-row-filter:hover {
247
- opacity: 1;
248
- background: rgba(0,0,0,0.05);
249
- }
245
+ /* Make header clickable for filtering when filters are expanded */
246
+ .gg-log-header.clickable {
247
+ cursor: pointer;
248
+ }
249
+ /* Desktop: highlight child elements since header has display: contents */
250
+ .gg-log-header.clickable:hover .gg-log-diff,
251
+ .gg-log-header.clickable:hover .gg-log-ns {
252
+ background: rgba(0,0,0,0.05);
253
+ }
250
254
  .gg-filter-panel {
251
255
  background: #f5f5f5;
252
256
  padding: 10px;
@@ -262,7 +266,7 @@ export function createGgPlugin(options, gg) {
262
266
  width: 100%;
263
267
  padding: 4px 8px;
264
268
  font-family: monospace;
265
- font-size: 12px;
269
+ font-size: 16px;
266
270
  margin-bottom: 8px;
267
271
  }
268
272
  .gg-filter-checkboxes {
@@ -281,16 +285,125 @@ export function createGgPlugin(options, gg) {
281
285
  font-family: monospace;
282
286
  white-space: nowrap;
283
287
  }
288
+ /* Mobile responsive styles */
289
+ .gg-toolbar {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 8px;
293
+ margin-bottom: 8px;
294
+ flex-shrink: 0;
295
+ overflow-x: auto;
296
+ -webkit-overflow-scrolling: touch;
297
+ }
298
+ .gg-toolbar button {
299
+ padding: 4px 10px;
300
+ cursor: pointer;
301
+ flex-shrink: 0;
302
+ }
303
+ .gg-btn-text {
304
+ display: inline;
305
+ }
306
+ .gg-btn-icon {
307
+ display: none;
308
+ }
309
+ @media (max-width: 640px) {
310
+ .gg-btn-text {
311
+ display: none;
312
+ }
313
+ .gg-btn-icon {
314
+ display: inline;
315
+ }
316
+ .gg-toolbar button {
317
+ padding: 4px 8px;
318
+ min-width: 32px;
319
+ }
320
+ .gg-filter-btn {
321
+ font-family: monospace;
322
+ font-size: 12px;
323
+ }
324
+ /* Stack log entries vertically on mobile */
325
+ .gg-log-grid {
326
+ display: block;
327
+ }
328
+ .gg-log-entry {
329
+ display: block;
330
+ padding: 8px 0;
331
+ }
332
+ /* Remove double borders on mobile - only border on entry wrapper */
333
+ .gg-log-entry:not(:first-child) {
334
+ border-top: 1px solid rgba(0,0,0,0.05);
335
+ }
336
+ .gg-log-diff,
337
+ .gg-log-ns,
338
+ .gg-log-handle,
339
+ .gg-log-content,
340
+ .gg-details {
341
+ border-top: none !important;
342
+ }
343
+ .gg-log-header {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 8px;
347
+ margin-bottom: 4px;
348
+ min-width: 0;
349
+ }
350
+ /* Mobile: hover on container since it's not display: contents */
351
+ .gg-log-header.clickable {
352
+ padding: 2px 0;
353
+ }
354
+ .gg-log-header.clickable:hover {
355
+ background: rgba(0,0,0,0.05);
356
+ }
357
+ /* Override desktop child hover on mobile */
358
+ .gg-log-header.clickable:hover .gg-log-diff,
359
+ .gg-log-header.clickable:hover .gg-log-ns {
360
+ background: transparent;
361
+ }
362
+ .gg-log-diff {
363
+ padding: 0;
364
+ text-align: left;
365
+ flex-shrink: 0;
366
+ white-space: nowrap;
367
+ }
368
+ .gg-log-ns {
369
+ padding: 0;
370
+ flex: 1;
371
+ min-width: 0;
372
+ overflow: hidden;
373
+ text-overflow: ellipsis;
374
+ white-space: nowrap;
375
+ }
376
+ .gg-log-handle {
377
+ display: none;
378
+ }
379
+ .gg-log-content {
380
+ padding: 0;
381
+ padding-left: 0;
382
+ }
383
+ .gg-details {
384
+ margin-top: 4px;
385
+ }
386
+ }
284
387
  </style>
285
- <div class="eruda-gg" style="padding: 10px; height: 100%; display: flex; flex-direction: column; font-size: 14px;">
286
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-shrink: 0;">
287
- <button class="gg-clear-btn" style="padding: 4px 10px; cursor: pointer;">Clear</button>
288
- <button class="gg-copy-btn" style="padding: 4px 10px; cursor: pointer;">Copy</button>
289
- <button class="gg-filter-btn" style="padding: 4px 10px; cursor: pointer; flex: 1; min-width: 0; text-align: left; font-family: monospace; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">⚙️ Filters: <span class="gg-filter-summary"></span></button>
290
- <span class="gg-count" style="opacity: 0.6; white-space: nowrap;"></span>
291
- </div>
388
+ <div class="eruda-gg" style="padding: 10px; height: 100%; display: flex; flex-direction: column; font-size: 14px; touch-action: none; overscroll-behavior: contain;">
389
+ <div class="gg-toolbar">
390
+ <button class="gg-copy-btn">
391
+ <span class="gg-btn-text">Copy</span>
392
+ <span class="gg-btn-icon" title="Copy">📋</span>
393
+ </button>
394
+ <button class="gg-filter-btn" style="text-align: left; white-space: nowrap;">
395
+ <span class="gg-btn-text">Namespaces: </span>
396
+ <span class="gg-btn-icon">NS: </span>
397
+ <span class="gg-filter-summary"></span>
398
+ </button>
399
+ <span class="gg-count" style="opacity: 0.6; white-space: nowrap; flex: 1; text-align: right;"></span>
400
+ <button class="gg-clear-btn">
401
+ <span class="gg-btn-text">Clear</span>
402
+ <span class="gg-btn-icon" title="Clear">⊘</span>
403
+ </button>
404
+ </div>
292
405
  <div class="gg-filter-panel"></div>
293
- <div class="gg-log-container" style="flex: 1; overflow-y: auto; font-family: monospace; font-size: 12px;"></div>
406
+ <div class="gg-log-container" style="flex: 1; overflow-y: auto; font-family: monospace; font-size: 12px; touch-action: pan-y; overscroll-behavior: contain;"></div>
294
407
  </div>
295
408
  `;
296
409
  }
@@ -340,6 +453,27 @@ export function createGgPlugin(options, gg) {
340
453
  // Wire up checkboxes
341
454
  filterPanel.addEventListener('change', (e) => {
342
455
  const target = e.target;
456
+ // Handle ALL checkbox
457
+ if (target.classList.contains('gg-all-checkbox')) {
458
+ const allNamespaces = getAllCapturedNamespaces();
459
+ if (target.checked) {
460
+ // Select all
461
+ filterPattern = 'gg:*';
462
+ enabledNamespaces.clear();
463
+ allNamespaces.forEach((ns) => enabledNamespaces.add(ns));
464
+ }
465
+ else {
466
+ // Deselect all
467
+ const exclusions = allNamespaces.map((ns) => `-${ns}`).join(',');
468
+ filterPattern = `gg:*,${exclusions}`;
469
+ enabledNamespaces.clear();
470
+ }
471
+ localStorage.setItem('debug', filterPattern);
472
+ renderFilterUI();
473
+ renderLogs();
474
+ return;
475
+ }
476
+ // Handle individual namespace checkboxes
343
477
  if (target.classList.contains('gg-ns-checkbox')) {
344
478
  const namespace = target.getAttribute('data-namespace');
345
479
  if (!namespace)
@@ -355,11 +489,13 @@ export function createGgPlugin(options, gg) {
355
489
  function renderFilterUI() {
356
490
  if (!$el)
357
491
  return;
358
- // Update button summary
492
+ const allNamespaces = getAllCapturedNamespaces();
493
+ const enabledCount = enabledNamespaces.size;
494
+ const totalCount = allNamespaces.length;
495
+ // Update button summary with count of enabled namespaces
359
496
  const filterSummary = $el.find('.gg-filter-summary').get(0);
360
497
  if (filterSummary) {
361
- const summary = filterPattern || 'gg:*';
362
- filterSummary.textContent = summary;
498
+ filterSummary.textContent = `${enabledCount}/${totalCount}`;
363
499
  }
364
500
  // Update panel
365
501
  const filterPanel = $el.find('.gg-filter-panel').get(0);
@@ -374,32 +510,37 @@ export function createGgPlugin(options, gg) {
374
510
  const effectivePattern = filterPattern || 'gg:*';
375
511
  let checkboxesHTML = '';
376
512
  if (simple && allNamespaces.length > 0) {
513
+ const allChecked = enabledCount === totalCount;
377
514
  checkboxesHTML = `
378
- <div class="gg-filter-checkboxes">
379
- ${allNamespaces
515
+ <div class="gg-filter-checkboxes">
516
+ <label class="gg-filter-checkbox" style="font-weight: bold;">
517
+ <input type="checkbox" class="gg-all-checkbox" ${allChecked ? 'checked' : ''}>
518
+ <span>ALL</span>
519
+ </label>
520
+ ${allNamespaces
380
521
  .map((ns) => {
381
522
  // Check if namespace matches the current pattern
382
523
  const checked = namespaceMatchesPattern(ns, effectivePattern);
383
524
  return `
384
- <label class="gg-filter-checkbox">
385
- <input type="checkbox" class="gg-ns-checkbox" data-namespace="${escapeHtml(ns)}" ${checked ? 'checked' : ''}>
386
- <span>${escapeHtml(ns)}</span>
387
- </label>
388
- `;
525
+ <label class="gg-filter-checkbox">
526
+ <input type="checkbox" class="gg-ns-checkbox" data-namespace="${escapeHtml(ns)}" ${checked ? 'checked' : ''}>
527
+ <span>${escapeHtml(ns)}</span>
528
+ </label>
529
+ `;
389
530
  })
390
531
  .join('')}
391
- </div>
392
- `;
532
+ </div>
533
+ `;
393
534
  }
394
535
  else if (!simple) {
395
536
  checkboxesHTML = `<div style="opacity: 0.6; font-size: 11px; margin: 8px 0;">⚠️ Complex pattern - edit manually (quick filters disabled)</div>`;
396
537
  }
397
538
  filterPanel.innerHTML = `
398
- <div style="margin-bottom: 8px;">
399
- <input type="text" class="gg-filter-pattern" value="${escapeHtml(filterPattern)}" placeholder="gg:*" style="width: 100%;">
400
- </div>
401
- ${checkboxesHTML}
402
- `;
539
+ <div style="margin-bottom: 8px;">
540
+ <input type="text" class="gg-filter-pattern" value="${escapeHtml(filterPattern)}" placeholder="gg:*" style="width: 100%;">
541
+ </div>
542
+ ${checkboxesHTML}
543
+ `;
403
544
  }
404
545
  else {
405
546
  // Hide panel
@@ -429,7 +570,8 @@ export function createGgPlugin(options, gg) {
429
570
  if (typeof arg === 'object' && arg !== null) {
430
571
  return JSON.stringify(arg);
431
572
  }
432
- return String(arg);
573
+ // Strip ANSI escape codes from string args
574
+ return stripAnsi(String(arg));
433
575
  })
434
576
  .join(' ');
435
577
  return `${time} ${ns} ${argsStr}`;
@@ -470,9 +612,12 @@ export function createGgPlugin(options, gg) {
470
612
  }
471
613
  return;
472
614
  }
473
- // Handle row filter button
474
- if (target?.classList?.contains('gg-row-filter')) {
475
- const namespace = target.getAttribute('data-namespace');
615
+ // Handle clickable header (when filters expanded)
616
+ // Skip if clicking on resize handle
617
+ if (!target?.classList?.contains('gg-log-handle') &&
618
+ target?.closest('.gg-log-header.clickable')) {
619
+ const header = target.closest('.gg-log-header.clickable');
620
+ const namespace = header.getAttribute('data-namespace');
476
621
  if (!namespace)
477
622
  return;
478
623
  // Toggle this namespace off
@@ -567,25 +712,40 @@ export function createGgPlugin(options, gg) {
567
712
  const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
568
713
  const uniqueId = `${index}-${argIdx}`;
569
714
  // Store details separately to render after the row
570
- detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; grid-column: 1 / -1; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;"><pre style="margin: 0;">${jsonStr}</pre></div>`;
715
+ detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;"><pre style="margin: 0;">${jsonStr}</pre></div>`;
571
716
  return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}">${preview}</span>`;
572
717
  }
573
718
  else {
574
- return `<span>${escapeHtml(String(arg))}</span>`;
719
+ // Parse ANSI codes first, then convert URLs to clickable links
720
+ const argStr = String(arg);
721
+ const parsedAnsi = parseAnsiToHtml(argStr);
722
+ // Note: URL linking happens after ANSI parsing, so links work inside colored text
723
+ // This is a simple approach - URLs inside ANSI codes won't be linkified
724
+ // For more complex parsing, we'd need to track ANSI state while matching URLs
725
+ return `<span>${parsedAnsi}</span>`;
575
726
  }
576
727
  })
577
728
  .join(' ');
578
729
  }
579
- // Add filter button if expanded
580
- const filterBtn = filterExpanded
581
- ? `<div class="gg-row-filter" data-namespace="${ns}" title="Hide this namespace">×</div>`
730
+ // Make header clickable when filters expanded
731
+ const headerClass = filterExpanded ? 'gg-log-header clickable' : 'gg-log-header';
732
+ const headerAttrs = filterExpanded
733
+ ? ` data-namespace="${ns}" title="Click to hide this namespace"`
582
734
  : '';
583
- return (filterBtn +
584
- `<div class="gg-log-diff" style="color: ${color};">${diff}</div>` +
735
+ // Add × at start of diff when filters expanded (bold, darker)
736
+ const filterIcon = filterExpanded
737
+ ? '<span style="font-weight: bold; color: #000; opacity: 0.6;">× </span>'
738
+ : '';
739
+ // Desktop: grid layout, Mobile: stacked layout
740
+ return (`<div class="gg-log-entry">` +
741
+ `<div class="${headerClass}"${headerAttrs}>` +
742
+ `<div class="gg-log-diff" style="color: ${color};">${filterIcon}${diff}</div>` +
585
743
  `<div class="gg-log-ns" style="color: ${color};">${ns}</div>` +
586
744
  `<div class="gg-log-handle"></div>` +
745
+ `</div>` +
587
746
  `<div class="gg-log-content">${argsHTML}</div>` +
588
- detailsHTML);
747
+ detailsHTML +
748
+ `</div>`);
589
749
  })
590
750
  .join('')}</div>`;
591
751
  logContainer.html(logsHTML);
@@ -614,5 +774,72 @@ export function createGgPlugin(options, gg) {
614
774
  div.textContent = text;
615
775
  return div.innerHTML;
616
776
  }
777
+ /**
778
+ * Strip ANSI escape codes from text
779
+ * Removes all ANSI escape sequences like \x1b[...m
780
+ */
781
+ function stripAnsi(text) {
782
+ // Remove all ANSI escape codes
783
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
784
+ }
785
+ /**
786
+ * Parse ANSI escape codes and convert to HTML with inline styles
787
+ * Supports:
788
+ * - 24-bit RGB: \x1b[38;2;r;g;bm (foreground), \x1b[48;2;r;g;bm (background)
789
+ * - Reset: \x1b[0m
790
+ */
791
+ function parseAnsiToHtml(text) {
792
+ // ANSI escape sequence regex
793
+ // Matches: \x1b[38;2;r;g;bm, \x1b[48;2;r;g;bm, \x1b[0m
794
+ const ansiRegex = /\x1b\[([0-9;]+)m/g;
795
+ let html = '';
796
+ let lastIndex = 0;
797
+ let currentFg = null;
798
+ let currentBg = null;
799
+ let match;
800
+ while ((match = ansiRegex.exec(text)) !== null) {
801
+ // Add text before this code (with current styling)
802
+ const textBefore = text.slice(lastIndex, match.index);
803
+ if (textBefore) {
804
+ html += wrapWithStyle(escapeHtml(textBefore), currentFg, currentBg);
805
+ }
806
+ // Parse the ANSI code
807
+ const code = match[1];
808
+ const parts = code.split(';').map(Number);
809
+ if (parts[0] === 0) {
810
+ // Reset
811
+ currentFg = null;
812
+ currentBg = null;
813
+ }
814
+ else if (parts[0] === 38 && parts[1] === 2 && parts.length >= 5) {
815
+ // Foreground RGB: 38;2;r;g;b
816
+ currentFg = `rgb(${parts[2]},${parts[3]},${parts[4]})`;
817
+ }
818
+ else if (parts[0] === 48 && parts[1] === 2 && parts.length >= 5) {
819
+ // Background RGB: 48;2;r;g;b
820
+ currentBg = `rgb(${parts[2]},${parts[3]},${parts[4]})`;
821
+ }
822
+ lastIndex = ansiRegex.lastIndex;
823
+ }
824
+ // Add remaining text
825
+ const remaining = text.slice(lastIndex);
826
+ if (remaining) {
827
+ html += wrapWithStyle(escapeHtml(remaining), currentFg, currentBg);
828
+ }
829
+ return html || escapeHtml(text);
830
+ }
831
+ /**
832
+ * Wrap text with inline color styles
833
+ */
834
+ function wrapWithStyle(text, fg, bg) {
835
+ if (!fg && !bg)
836
+ return text;
837
+ const styles = [];
838
+ if (fg)
839
+ styles.push(`color: ${fg}`);
840
+ if (bg)
841
+ styles.push(`background-color: ${bg}`);
842
+ return `<span style="${styles.join('; ')}">${text}</span>`;
843
+ }
617
844
  return plugin;
618
845
  }
@@ -0,0 +1,31 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface GgTagPluginOptions {
3
+ /**
4
+ * Pattern to strip from file paths to produce short callpoints.
5
+ * Should match up to and including the source root folder.
6
+ *
7
+ * Default: /.*?(\/(?:src|chunks)\/)/ which strips everything up to "src/" or "chunks/",
8
+ * matching the dev-mode behavior of gg().
9
+ *
10
+ * Example: "/Users/me/project/src/routes/+page.svelte" → "routes/+page.svelte"
11
+ */
12
+ srcRootPattern?: string;
13
+ }
14
+ /**
15
+ * Vite plugin that rewrites bare `gg(...)` calls to `gg.ns('callpoint', ...)`
16
+ * at build time. This gives each call site a unique namespace with zero runtime
17
+ * cost — no stack trace parsing needed.
18
+ *
19
+ * Works in both dev and prod. When the plugin is installed, `gg.ns()` is called
20
+ * with the callpoint baked in as a string literal. Without the plugin, gg()
21
+ * falls back to runtime stack parsing in dev and bare `gg:` in prod.
22
+ *
23
+ * @example
24
+ * // vite.config.ts
25
+ * import { ggTagPlugin } from '@leftium/gg';
26
+ *
27
+ * export default defineConfig({
28
+ * plugins: [ggTagPlugin()]
29
+ * });
30
+ */
31
+ export default function ggTagPlugin(options?: GgTagPluginOptions): Plugin;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Vite plugin that rewrites bare `gg(...)` calls to `gg.ns('callpoint', ...)`
3
+ * at build time. This gives each call site a unique namespace with zero runtime
4
+ * cost — no stack trace parsing needed.
5
+ *
6
+ * Works in both dev and prod. When the plugin is installed, `gg.ns()` is called
7
+ * with the callpoint baked in as a string literal. Without the plugin, gg()
8
+ * falls back to runtime stack parsing in dev and bare `gg:` in prod.
9
+ *
10
+ * @example
11
+ * // vite.config.ts
12
+ * import { ggTagPlugin } from '@leftium/gg';
13
+ *
14
+ * export default defineConfig({
15
+ * plugins: [ggTagPlugin()]
16
+ * });
17
+ */
18
+ export default function ggTagPlugin(options = {}) {
19
+ const srcRootPattern = options.srcRootPattern ?? '.*?(/(?:src|chunks)/)';
20
+ const srcRootRegex = new RegExp(srcRootPattern, 'i');
21
+ return {
22
+ name: 'gg-tag',
23
+ transform(code, id) {
24
+ // Only process JS/TS/Svelte files
25
+ if (!/\.(js|ts|svelte|jsx|tsx|mjs|mts)(\?.*)?$/.test(id))
26
+ return null;
27
+ // Quick bail: no gg calls in this file
28
+ if (!code.includes('gg('))
29
+ return null;
30
+ // Don't transform gg's own source files
31
+ if (id.includes('/lib/gg.') || id.includes('/lib/debug'))
32
+ return null;
33
+ // Build the short callpoint from the file path
34
+ // e.g. "/Users/me/project/src/routes/+page.svelte" → "routes/+page.svelte"
35
+ const shortPath = id.replace(srcRootRegex, '');
36
+ return transformGgCalls(code, shortPath);
37
+ }
38
+ };
39
+ }
40
+ /**
41
+ * Find the enclosing function name for a given position in source code.
42
+ * Scans backwards from the position looking for function/method declarations.
43
+ */
44
+ function findEnclosingFunction(code, position) {
45
+ // Look backwards from the gg( call for the nearest function declaration
46
+ const before = code.slice(0, position);
47
+ // Try several patterns, take the closest (last) match
48
+ // Named function: function handleClick(
49
+ // Arrow in variable: const handleClick = (...) =>
50
+ // Arrow in variable: let handleClick = (...) =>
51
+ // Method shorthand: handleClick() {
52
+ // Method: handleClick: function(
53
+ // Class method: async handleClick(
54
+ const patterns = [
55
+ // function declarations: function foo(
56
+ /function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g,
57
+ // const/let/var assignment to arrow or function: const foo =
58
+ /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g,
59
+ // object method shorthand: foo() { or async foo() {
60
+ /(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)\s*\{/g,
61
+ // object property function: foo: function
62
+ /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*(?:async\s+)?function/g
63
+ ];
64
+ let closestName = '';
65
+ let closestPos = -1;
66
+ for (const pattern of patterns) {
67
+ let match;
68
+ while ((match = pattern.exec(before)) !== null) {
69
+ const name = match[1];
70
+ // Skip common false positives
71
+ if ([
72
+ 'if',
73
+ 'for',
74
+ 'while',
75
+ 'switch',
76
+ 'catch',
77
+ 'return',
78
+ 'import',
79
+ 'export',
80
+ 'from',
81
+ 'new',
82
+ 'typeof',
83
+ 'instanceof',
84
+ 'void',
85
+ 'delete',
86
+ 'throw',
87
+ 'case',
88
+ 'else',
89
+ 'in',
90
+ 'of',
91
+ 'do',
92
+ 'try',
93
+ 'class',
94
+ 'super',
95
+ 'this',
96
+ 'with',
97
+ 'yield',
98
+ 'await',
99
+ 'debugger',
100
+ 'default'
101
+ ].includes(name)) {
102
+ continue;
103
+ }
104
+ if (match.index > closestPos) {
105
+ closestPos = match.index;
106
+ closestName = name;
107
+ }
108
+ }
109
+ }
110
+ return closestName;
111
+ }
112
+ /**
113
+ * Transform gg() calls in source code to gg.ns('callpoint', ...) calls.
114
+ *
115
+ * Handles:
116
+ * - bare gg(...) → gg.ns('callpoint', ...)
117
+ * - gg.ns(...) → left untouched (user-specified namespace)
118
+ * - gg.enable, gg.disable, gg.clearPersist, gg._onLog → left untouched
119
+ * - gg inside strings and comments → left untouched
120
+ */
121
+ function transformGgCalls(code, shortPath) {
122
+ // Match gg( that is:
123
+ // - not preceded by a dot (would be obj.gg() — not our function)
124
+ // - not preceded by a word char (would be dogg() or something)
125
+ // - not followed by a dot before the paren (gg.ns, gg.enable, etc.)
126
+ //
127
+ // We use a manual scan approach to correctly handle strings and comments.
128
+ const result = [];
129
+ let lastIndex = 0;
130
+ let modified = false;
131
+ // States for string/comment tracking
132
+ let i = 0;
133
+ while (i < code.length) {
134
+ // Skip single-line comments
135
+ if (code[i] === '/' && code[i + 1] === '/') {
136
+ const end = code.indexOf('\n', i);
137
+ i = end === -1 ? code.length : end + 1;
138
+ continue;
139
+ }
140
+ // Skip multi-line comments
141
+ if (code[i] === '/' && code[i + 1] === '*') {
142
+ const end = code.indexOf('*/', i + 2);
143
+ i = end === -1 ? code.length : end + 2;
144
+ continue;
145
+ }
146
+ // Skip template literals (backticks)
147
+ if (code[i] === '`') {
148
+ i++;
149
+ let depth = 0;
150
+ while (i < code.length) {
151
+ if (code[i] === '\\') {
152
+ i += 2;
153
+ continue;
154
+ }
155
+ if (code[i] === '$' && code[i + 1] === '{') {
156
+ depth++;
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (code[i] === '}' && depth > 0) {
161
+ depth--;
162
+ i++;
163
+ continue;
164
+ }
165
+ if (code[i] === '`' && depth === 0) {
166
+ i++;
167
+ break;
168
+ }
169
+ i++;
170
+ }
171
+ continue;
172
+ }
173
+ // Skip strings (single and double quotes)
174
+ if (code[i] === '"' || code[i] === "'") {
175
+ const quote = code[i];
176
+ i++;
177
+ while (i < code.length) {
178
+ if (code[i] === '\\') {
179
+ i += 2;
180
+ continue;
181
+ }
182
+ if (code[i] === quote) {
183
+ i++;
184
+ break;
185
+ }
186
+ i++;
187
+ }
188
+ continue;
189
+ }
190
+ // Look for 'gg(' pattern
191
+ if (code[i] === 'g' && code[i + 1] === 'g' && code[i + 2] === '(') {
192
+ // Check preceding character: must not be a word char or dot
193
+ const prevChar = i > 0 ? code[i - 1] : '';
194
+ if (prevChar && /[a-zA-Z0-9_$.]/.test(prevChar)) {
195
+ i++;
196
+ continue;
197
+ }
198
+ // Check it's not gg.something (gg.ns, gg.enable, etc.)
199
+ // At this point we know code[i..i+2] is "gg(" — it's a bare call
200
+ // Find the enclosing function
201
+ const fnName = findEnclosingFunction(code, i);
202
+ const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
203
+ const escaped = callpoint.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
204
+ // Emit everything before this match
205
+ result.push(code.slice(lastIndex, i));
206
+ // Replace gg( with gg.ns('callpoint',
207
+ // Need to handle gg() with no args → gg.ns('callpoint')
208
+ // and gg(x) → gg.ns('callpoint', x)
209
+ // Peek ahead to check if it's gg() with no args
210
+ const afterParen = code.indexOf(')', i + 3);
211
+ const betweenParens = code.slice(i + 3, afterParen);
212
+ const isNoArgs = betweenParens.trim() === '';
213
+ if (isNoArgs && afterParen !== -1 && !betweenParens.includes('(')) {
214
+ // gg() → gg.ns('callpoint')
215
+ result.push(`gg.ns('${escaped}')`);
216
+ lastIndex = afterParen + 1;
217
+ i = afterParen + 1;
218
+ }
219
+ else {
220
+ // gg(args...) → gg.ns('callpoint', args...)
221
+ result.push(`gg.ns('${escaped}', `);
222
+ lastIndex = i + 3; // skip past "gg("
223
+ i = i + 3;
224
+ }
225
+ modified = true;
226
+ continue;
227
+ }
228
+ i++;
229
+ }
230
+ if (!modified)
231
+ return null;
232
+ result.push(code.slice(lastIndex));
233
+ return { code: result.join(''), map: null };
234
+ }
package/dist/gg.d.ts CHANGED
@@ -22,6 +22,73 @@ export declare namespace gg {
22
22
  var disable: () => void;
23
23
  var enable: (namespaces: string) => void;
24
24
  var clearPersist: () => void;
25
- var _onLog: OnLogCallback | null;
26
25
  }
26
+ /**
27
+ * ANSI Color Helpers for gg()
28
+ *
29
+ * Create reusable color schemes with foreground (fg) and background (bg) colors.
30
+ * Works in both native console and Eruda plugin.
31
+ *
32
+ * @example
33
+ * // Method chaining (order doesn't matter)
34
+ * gg(fg('white').bg('red')`Critical error!`);
35
+ * gg(bg('green').fg('white')`Success!`);
36
+ *
37
+ * @example
38
+ * // Define color schemes once, reuse everywhere
39
+ * const input = fg('blue').bg('yellow');
40
+ * const transcript = bg('green').fg('white');
41
+ * const error = fg('white').bg('red');
42
+ *
43
+ * gg(input`User said: hello`);
44
+ * gg(transcript`AI responded: hi`);
45
+ * gg(error`Something broke!`);
46
+ *
47
+ * @example
48
+ * // Mix colored and normal text inline
49
+ * gg(fg('red')`Error: ` + bg('yellow')`warning` + ' normal text');
50
+ *
51
+ * @example
52
+ * // Custom colors (hex, rgb, or named)
53
+ * gg(fg('#ff6347').bg('#98fb98')`Custom colors`);
54
+ *
55
+ * @example
56
+ * // Just foreground or background
57
+ * gg(fg('cyan')`Cyan text`);
58
+ * gg(bg('magenta')`Magenta background`);
59
+ */
60
+ type ColorTagFunction = (strings: TemplateStringsArray, ...values: unknown[]) => string;
61
+ interface ChainableColorFn extends ColorTagFunction {
62
+ fg: (color: string) => ChainableColorFn;
63
+ bg: (color: string) => ChainableColorFn;
64
+ }
65
+ /**
66
+ * Foreground (text) color helper
67
+ * Can be used directly or chained with .bg()
68
+ *
69
+ * @param color - Named color, hex (#rrggbb), or rgb(r,g,b)
70
+ * @example
71
+ * gg(fg('red')`Error`);
72
+ * gg(fg('white').bg('red')`Critical!`);
73
+ */
74
+ export declare function fg(color: string): ChainableColorFn;
75
+ /**
76
+ * Background color helper
77
+ * Can be used directly or chained with .fg()
78
+ *
79
+ * @param color - Named color, hex (#rrggbb), or rgb(r,g,b)
80
+ * @example
81
+ * gg(bg('yellow')`Warning`);
82
+ * gg(bg('green').fg('white')`Success!`);
83
+ */
84
+ export declare function bg(color: string): ChainableColorFn;
85
+ export declare namespace gg {
86
+ let _onLog: OnLogCallback | null;
87
+ let ns: (nsLabel: string, ...args: unknown[]) => unknown;
88
+ }
89
+ /**
90
+ * Run gg diagnostics and log configuration status
91
+ * Can be called immediately or delayed (e.g., after Eruda loads)
92
+ */
93
+ export declare function runGgDiagnostics(): Promise<void>;
27
94
  export {};
package/dist/gg.js CHANGED
@@ -209,38 +209,117 @@ export function gg(...args) {
209
209
  }
210
210
  const ggLogFunction = namespaceToLogFunction.get(namespace) ||
211
211
  namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
212
+ // Prepare args for logging
213
+ let logArgs;
214
+ let returnValue;
212
215
  if (!args.length) {
213
- ggLogFunction(` 📝📝 ${url} 👀👀`);
214
- return {
216
+ // No arguments: log editor link
217
+ logArgs = [` 📝📝 ${url} 👀👀`];
218
+ returnValue = {
215
219
  fileName,
216
220
  functionName,
217
221
  url,
218
222
  stack
219
223
  };
220
224
  }
221
- // Handle the case where args might be empty or have any number of arguments
222
- if (args.length === 1) {
223
- ggLogFunction(args[0]);
225
+ else if (args.length === 1) {
226
+ logArgs = [args[0]];
227
+ returnValue = args[0];
224
228
  }
225
229
  else {
226
- ggLogFunction(args[0], ...args.slice(1));
230
+ logArgs = [args[0], ...args.slice(1)];
231
+ returnValue = args[0];
232
+ }
233
+ // Log to console via debug
234
+ if (logArgs.length === 1) {
235
+ ggLogFunction(logArgs[0]);
236
+ }
237
+ else {
238
+ ggLogFunction(logArgs[0], ...logArgs.slice(1));
227
239
  }
228
240
  // Call capture hook if registered (for Eruda plugin)
229
- if (gg._onLog) {
230
- // Don't stringify args - keep them as-is for expandable objects
231
- const message = args.length === 1 ? String(args[0]) : args.map(String).join(' ');
232
- // Use debug's native color (now deterministic since namespace has no padding)
233
- gg._onLog({
234
- namespace,
235
- color: ggLogFunction.color,
236
- diff: ggLogFunction.diff || 0, // Millisecond diff from debug library
237
- message,
238
- args, // Keep raw args for object inspection
239
- timestamp: Date.now()
240
- });
241
+ const entry = {
242
+ namespace,
243
+ color: ggLogFunction.color,
244
+ diff: ggLogFunction.diff || 0, // Millisecond diff from debug library
245
+ message: logArgs.length === 1 ? String(logArgs[0]) : logArgs.map(String).join(' '),
246
+ args: logArgs, // Keep raw args for object inspection
247
+ timestamp: Date.now()
248
+ };
249
+ if (_onLogCallback) {
250
+ _onLogCallback(entry);
251
+ }
252
+ else {
253
+ // Buffer early logs before Eruda initializes
254
+ earlyLogBuffer.push(entry);
241
255
  }
242
- return args[0];
256
+ return returnValue;
243
257
  }
258
+ /**
259
+ * gg.ns() - Log with an explicit namespace (callpoint label).
260
+ *
261
+ * In production builds, the ggTagPlugin Vite plugin rewrites bare gg() calls
262
+ * to gg.ns('callpoint', ...) so each call site gets a unique namespace even
263
+ * after minification. Users can also call gg.ns() directly to set a meaningful
264
+ * label that survives across builds.
265
+ *
266
+ * @param nsLabel - The namespace label (appears as gg:<nsLabel> in output)
267
+ * @param args - Same arguments as gg()
268
+ * @returns Same as gg() - the first arg, or call-site info if no args
269
+ *
270
+ * @example
271
+ * gg.ns("auth", "login failed") // logs under namespace "gg:auth"
272
+ * gg.ns("cart", item, quantity) // logs under namespace "gg:cart"
273
+ */
274
+ gg.ns = function (nsLabel, ...args) {
275
+ if (!ggConfig.enabled || isCloudflareWorker()) {
276
+ return args.length ? args[0] : { url: '', stack: [] };
277
+ }
278
+ const namespace = `gg:${nsLabel}`;
279
+ if (nsLabel.length < 80 && nsLabel.length > maxCallpointLength) {
280
+ maxCallpointLength = nsLabel.length;
281
+ }
282
+ const ggLogFunction = namespaceToLogFunction.get(namespace) ||
283
+ namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
284
+ // Prepare args for logging
285
+ let logArgs;
286
+ let returnValue;
287
+ if (!args.length) {
288
+ logArgs = [` 📝 ${nsLabel}`];
289
+ returnValue = { fileName: '', functionName: '', url: '', stack: [] };
290
+ }
291
+ else if (args.length === 1) {
292
+ logArgs = [args[0]];
293
+ returnValue = args[0];
294
+ }
295
+ else {
296
+ logArgs = [args[0], ...args.slice(1)];
297
+ returnValue = args[0];
298
+ }
299
+ // Log to console via debug
300
+ if (logArgs.length === 1) {
301
+ ggLogFunction(logArgs[0]);
302
+ }
303
+ else {
304
+ ggLogFunction(logArgs[0], ...logArgs.slice(1));
305
+ }
306
+ // Call capture hook if registered (for Eruda plugin)
307
+ const entry = {
308
+ namespace,
309
+ color: ggLogFunction.color,
310
+ diff: ggLogFunction.diff || 0,
311
+ message: logArgs.length === 1 ? String(logArgs[0]) : logArgs.map(String).join(' '),
312
+ args: logArgs,
313
+ timestamp: Date.now()
314
+ };
315
+ if (_onLogCallback) {
316
+ _onLogCallback(entry);
317
+ }
318
+ else {
319
+ earlyLogBuffer.push(entry);
320
+ }
321
+ return returnValue;
322
+ };
244
323
  gg.disable = isCloudflareWorker() ? () => { } : debugFactory.disable;
245
324
  gg.enable = isCloudflareWorker() ? () => { } : debugFactory.enable;
246
325
  /**
@@ -258,25 +337,165 @@ gg.clearPersist = () => {
258
337
  }
259
338
  }
260
339
  };
340
+ /**
341
+ * Parse color string to RGB values
342
+ * Accepts: named colors, hex (#rgb, #rrggbb), rgb(r,g,b), rgba(r,g,b,a)
343
+ */
344
+ function parseColor(color) {
345
+ // Named colors map (basic ANSI colors + common web colors)
346
+ const namedColors = {
347
+ black: '#000000',
348
+ red: '#ff0000',
349
+ green: '#00ff00',
350
+ yellow: '#ffff00',
351
+ blue: '#0000ff',
352
+ magenta: '#ff00ff',
353
+ cyan: '#00ffff',
354
+ white: '#ffffff',
355
+ // Bright variants
356
+ brightBlack: '#808080',
357
+ brightRed: '#ff6666',
358
+ brightGreen: '#66ff66',
359
+ brightYellow: '#ffff66',
360
+ brightBlue: '#6666ff',
361
+ brightMagenta: '#ff66ff',
362
+ brightCyan: '#66ffff',
363
+ brightWhite: '#ffffff',
364
+ // Common aliases
365
+ gray: '#808080',
366
+ grey: '#808080',
367
+ orange: '#ffa500',
368
+ purple: '#800080',
369
+ pink: '#ffc0cb'
370
+ };
371
+ // Check named colors first
372
+ const normalized = color.toLowerCase().trim();
373
+ if (namedColors[normalized]) {
374
+ color = namedColors[normalized];
375
+ }
376
+ // Parse hex color
377
+ const hexMatch = color.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
378
+ if (hexMatch) {
379
+ return {
380
+ r: parseInt(hexMatch[1], 16),
381
+ g: parseInt(hexMatch[2], 16),
382
+ b: parseInt(hexMatch[3], 16)
383
+ };
384
+ }
385
+ // Parse short hex (#rgb)
386
+ const shortHexMatch = color.match(/^#?([a-f\d])([a-f\d])([a-f\d])$/i);
387
+ if (shortHexMatch) {
388
+ return {
389
+ r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16),
390
+ g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16),
391
+ b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16)
392
+ };
393
+ }
394
+ // Parse rgb(r,g,b) or rgba(r,g,b,a)
395
+ const rgbMatch = color.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
396
+ if (rgbMatch) {
397
+ return {
398
+ r: parseInt(rgbMatch[1]),
399
+ g: parseInt(rgbMatch[2]),
400
+ b: parseInt(rgbMatch[3])
401
+ };
402
+ }
403
+ return null;
404
+ }
405
+ /**
406
+ * Internal helper to create chainable color function with method chaining
407
+ */
408
+ function createColorFunction(fgCode = '', bgCode = '') {
409
+ const tagFn = function (strings, ...values) {
410
+ const text = strings.reduce((acc, str, i) => acc + str + (values[i] !== undefined ? String(values[i]) : ''), '');
411
+ return fgCode + bgCode + text + '\x1b[0m';
412
+ };
413
+ // Add method chaining
414
+ tagFn.fg = (color) => {
415
+ const rgb = parseColor(color);
416
+ const newFgCode = rgb ? `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m` : '';
417
+ return createColorFunction(newFgCode, bgCode);
418
+ };
419
+ tagFn.bg = (color) => {
420
+ const rgb = parseColor(color);
421
+ const newBgCode = rgb ? `\x1b[48;2;${rgb.r};${rgb.g};${rgb.b}m` : '';
422
+ return createColorFunction(fgCode, newBgCode);
423
+ };
424
+ return tagFn;
425
+ }
426
+ /**
427
+ * Foreground (text) color helper
428
+ * Can be used directly or chained with .bg()
429
+ *
430
+ * @param color - Named color, hex (#rrggbb), or rgb(r,g,b)
431
+ * @example
432
+ * gg(fg('red')`Error`);
433
+ * gg(fg('white').bg('red')`Critical!`);
434
+ */
435
+ export function fg(color) {
436
+ const rgb = parseColor(color);
437
+ const fgCode = rgb ? `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m` : '';
438
+ return createColorFunction(fgCode, '');
439
+ }
440
+ /**
441
+ * Background color helper
442
+ * Can be used directly or chained with .fg()
443
+ *
444
+ * @param color - Named color, hex (#rrggbb), or rgb(r,g,b)
445
+ * @example
446
+ * gg(bg('yellow')`Warning`);
447
+ * gg(bg('green').fg('white')`Success!`);
448
+ */
449
+ export function bg(color) {
450
+ const rgb = parseColor(color);
451
+ const bgCode = rgb ? `\x1b[48;2;${rgb.r};${rgb.g};${rgb.b}m` : '';
452
+ return createColorFunction('', bgCode);
453
+ }
261
454
  /**
262
455
  * Hook for capturing gg() output (used by Eruda plugin)
263
456
  * Set this to a callback function to receive log entries
264
457
  */
265
- gg._onLog = null;
266
- // Log some gg info to the JS console/terminal:
267
- if (ggConfig.showHints && !isCloudflareWorker()) {
458
+ // Buffer for capturing early logs before Eruda initializes
459
+ const earlyLogBuffer = [];
460
+ let _onLogCallback = null;
461
+ // Proxy property that replays buffered logs when hook is registered
462
+ Object.defineProperty(gg, '_onLog', {
463
+ get() {
464
+ return _onLogCallback;
465
+ },
466
+ set(callback) {
467
+ _onLogCallback = callback;
468
+ // Replay buffered logs when callback is first registered
469
+ if (callback && earlyLogBuffer.length > 0) {
470
+ earlyLogBuffer.forEach((entry) => callback(entry));
471
+ earlyLogBuffer.length = 0; // Clear buffer after replay
472
+ }
473
+ }
474
+ });
475
+ // Namespace for adding properties to the gg function
476
+ // eslint-disable-next-line @typescript-eslint/no-namespace
477
+ (function (gg) {
478
+ })(gg || (gg = {}));
479
+ /**
480
+ * Run gg diagnostics and log configuration status
481
+ * Can be called immediately or delayed (e.g., after Eruda loads)
482
+ */
483
+ export async function runGgDiagnostics() {
484
+ if (!ggConfig.showHints || isCloudflareWorker())
485
+ return;
268
486
  const ggLogTest = debugFactory('gg:TEST');
269
487
  let ggMessage = '\n';
270
488
  // Utilities for forming ggMessage:
271
489
  const message = (s) => (ggMessage += `${s}\n`);
272
490
  const checkbox = (test) => (test ? '✅' : '❌');
273
491
  const makeHint = (test, ifTrue, ifFalse = '') => (test ? ifTrue : ifFalse);
492
+ // Use plain console.log for diagnostics - appears in Eruda's Console tab
274
493
  console.log(`Loaded gg module. Checking configuration...`);
275
494
  if (ggConfig.enabled && ggLogTest.enabled) {
276
495
  gg('If you can see this logg, gg configured correctly!');
277
496
  message(`No problems detected:`);
278
497
  if (BROWSER) {
279
- message(`ℹ️ If gg output still not visible above, enable "Verbose" log level in browser DevTools.`);
498
+ message(`ℹ️ If gg output not visible: enable "Verbose" log level in DevTools, or check Eruda's GG tab.`);
280
499
  }
281
500
  }
282
501
  else {
@@ -307,8 +526,18 @@ if (ggConfig.showHints && !isCloudflareWorker()) {
307
526
  }
308
527
  message(`${checkbox(ggLogTest.enabled)} DEBUG env variable: ${process?.env?.DEBUG}${hint}`);
309
528
  }
529
+ // Use plain console.log for diagnostics - appears in Eruda's Console tab
310
530
  console.log(ggMessage);
311
531
  // Reset namespace width after configuration check
312
532
  // This prevents the long callpoint from the config check from affecting subsequent logs
313
533
  resetNamespaceWidth();
314
534
  }
535
+ // Run diagnostics immediately on module load if Eruda is not being used
536
+ // (If Eruda will load, the loader will call runGgDiagnostics after Eruda is ready)
537
+ if (ggConfig.showHints && !isCloudflareWorker()) {
538
+ // Only run immediately if we're not in a context where Eruda might load
539
+ // In browser dev mode, assume Eruda might load and skip immediate diagnostics
540
+ if (!BROWSER || !DEV) {
541
+ runGgDiagnostics();
542
+ }
543
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- import { gg } from './gg.js';
1
+ import { gg, fg, bg } from './gg.js';
2
2
  import openInEditorPlugin from './open-in-editor.js';
3
- export { gg, openInEditorPlugin };
3
+ import ggTagPlugin from './gg-tag-plugin.js';
4
+ export { gg, fg, bg, openInEditorPlugin, ggTagPlugin };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Reexport your entry components here
2
- import { gg } from './gg.js';
2
+ import { gg, fg, bg } from './gg.js';
3
3
  import openInEditorPlugin from './open-in-editor.js';
4
- export { gg, openInEditorPlugin };
4
+ import ggTagPlugin from './gg-tag-plugin.js';
5
+ export { gg, fg, bg, openInEditorPlugin, ggTagPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftium/gg",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"