@leftium/gg 0.0.34 → 0.0.36

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,27 @@ npm add @leftium/gg
20
20
 
21
21
  ## SvelteKit Quick Start
22
22
 
23
- ### 1. Add Vite plugins
23
+ ### 1. Use `gg()` anywhere
24
+
25
+ ```svelte
26
+ <script>
27
+ import { gg } from '@leftium/gg';
28
+
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
+ </script>
37
+ ```
38
+
39
+ That's it! Output appears in the browser dev console and terminal. The following optional steps are highly recommended to unlock the full experience:
40
+
41
+ ### 2. Add Vite plugins (optional, recommended)
42
+
43
+ Without plugins, namespaces are random word-tuples. With plugins, you get real file/function callpoints, open-in-editor links, and icecream-style source expressions.
24
44
 
25
45
  ```ts
26
46
  // vite.config.ts
@@ -39,7 +59,9 @@ export default defineConfig({
39
59
  - **Open-in-editor plugin** -- adds dev server middleware for click-to-open
40
60
  - **Automatic `es2022` target** -- required for top-level await
41
61
 
42
- ### 2. Add the debug console
62
+ ### 3. Add the debug console (optional, recommended)
63
+
64
+ An in-browser debug console (powered by Eruda) with a dedicated GG tab for filtering and inspecting logs — especially useful on mobile.
43
65
 
44
66
  ```svelte
45
67
  <!-- src/routes/+layout.svelte -->
@@ -51,23 +73,7 @@ export default defineConfig({
51
73
  {@render children()}
52
74
  ```
53
75
 
54
- ### 3. Use `gg()` anywhere
55
-
56
- ```svelte
57
- <script>
58
- import { gg } from '@leftium/gg';
59
-
60
- gg('Hello world');
61
-
62
- // Log expressions (returns first argument)
63
- const result = gg(someFunction());
64
-
65
- // Multiple arguments
66
- gg('User:', user, 'Status:', status);
67
- </script>
68
- ```
69
-
70
- That's it! In development, a debug console appears automatically.
76
+ In development, the debug console appears automatically.
71
77
  In production, add `?gg` to the URL or use a 5-tap gesture to activate.
72
78
 
73
79
  ## GgConsole Options
@@ -1,22 +1,32 @@
1
1
  <script lang="ts">
2
2
  import { dev } from '$app/environment';
3
3
 
4
+ type GgCallSiteInfo = { fileName: string; functionName: string; url: string };
5
+
4
6
  let {
5
- url,
6
- fileName,
7
- title = fileName
8
- }: { url: string; fileName: string; title?: string } = $props();
7
+ gg,
8
+ url = gg?.url,
9
+ fileName = gg?.fileName,
10
+ title = gg ? `${gg.fileName}@${gg.functionName}` : fileName
11
+ }: {
12
+ gg?: GgCallSiteInfo;
13
+ url?: string;
14
+ fileName?: string;
15
+ title?: string;
16
+ } = $props();
9
17
 
10
18
  // svelte-ignore non_reactive_update
11
19
  let iframeElement: HTMLIFrameElement;
12
20
 
13
21
  function onclick(event: MouseEvent) {
14
- iframeElement.src = url;
15
- event.preventDefault();
22
+ if (url) {
23
+ iframeElement.src = url;
24
+ event.preventDefault();
25
+ }
16
26
  }
17
27
  </script>
18
28
 
19
- {#if dev}
29
+ {#if dev && fileName}
20
30
  [📝<a {onclick} href={url} {title} target="_open-in-editor" class="open-in-editor-link">
21
31
  {fileName}
22
32
  </a>
@@ -1,6 +1,12 @@
1
- type $$ComponentProps = {
2
- url: string;
1
+ type GgCallSiteInfo = {
3
2
  fileName: string;
3
+ functionName: string;
4
+ url: string;
5
+ };
6
+ type $$ComponentProps = {
7
+ gg?: GgCallSiteInfo;
8
+ url?: string;
9
+ fileName?: string;
4
10
  title?: string;
5
11
  };
6
12
  declare const OpenInEditorLink: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -521,7 +521,62 @@ export function createGgPlugin(options, gg) {
521
521
  padding-bottom: 4px;
522
522
  border-bottom: 1px solid #ddd;
523
523
  }
524
- .gg-filter-panel {
524
+ /* Level-based styling for info/warn/error entries */
525
+ .gg-level-info .gg-log-diff,
526
+ .gg-level-info .gg-log-ns,
527
+ .gg-level-info .gg-log-content {
528
+ background: rgba(23, 162, 184, 0.08);
529
+ }
530
+ .gg-level-info .gg-log-content {
531
+ border-left: 3px solid #17a2b8;
532
+ padding-left: 6px;
533
+ }
534
+ .gg-level-warn .gg-log-diff,
535
+ .gg-level-warn .gg-log-ns,
536
+ .gg-level-warn .gg-log-content {
537
+ background: rgba(255, 200, 0, 0.08);
538
+ }
539
+ .gg-level-warn .gg-log-content {
540
+ border-left: 3px solid #e6a700;
541
+ padding-left: 6px;
542
+ }
543
+ .gg-level-error .gg-log-diff,
544
+ .gg-level-error .gg-log-ns,
545
+ .gg-level-error .gg-log-content {
546
+ background: rgba(255, 50, 50, 0.08);
547
+ }
548
+ .gg-level-error .gg-log-content {
549
+ border-left: 3px solid #cc0000;
550
+ padding-left: 6px;
551
+ }
552
+ /* Stack trace toggle */
553
+ .gg-stack-toggle {
554
+ cursor: pointer;
555
+ font-size: 11px;
556
+ opacity: 0.6;
557
+ margin-left: 8px;
558
+ user-select: none;
559
+ }
560
+ .gg-stack-toggle:hover {
561
+ opacity: 1;
562
+ }
563
+ .gg-stack-content {
564
+ display: none;
565
+ font-size: 11px;
566
+ font-family: monospace;
567
+ white-space: pre;
568
+ padding: 6px 8px;
569
+ margin-top: 4px;
570
+ background: #f0f0f0;
571
+ border-radius: 3px;
572
+ overflow-x: auto;
573
+ color: #666;
574
+ line-height: 1.4;
575
+ }
576
+ .gg-stack-content.expanded {
577
+ display: block;
578
+ }
579
+ .gg-filter-panel {
525
580
  background: #f5f5f5;
526
581
  padding: 10px;
527
582
  margin-bottom: 8px;
@@ -1214,6 +1269,19 @@ export function createGgPlugin(options, gg) {
1214
1269
  }
1215
1270
  return;
1216
1271
  }
1272
+ // Handle stack trace toggle
1273
+ if (target?.classList?.contains('gg-stack-toggle')) {
1274
+ const stackId = target.getAttribute('data-stack-id');
1275
+ if (!stackId)
1276
+ return;
1277
+ const stackEl = containerEl.querySelector(`.gg-stack-content[data-stack-id="${stackId}"]`);
1278
+ if (stackEl) {
1279
+ const isExpanded = stackEl.classList.contains('expanded');
1280
+ stackEl.classList.toggle('expanded');
1281
+ target.textContent = isExpanded ? '▶ stack' : '▼ stack';
1282
+ }
1283
+ return;
1284
+ }
1217
1285
  // Handle clicking namespace to open in editor (when filter collapsed)
1218
1286
  if (target?.classList?.contains('gg-log-ns') &&
1219
1287
  target.hasAttribute('data-file') &&
@@ -1404,7 +1472,27 @@ export function createGgPlugin(options, gg) {
1404
1472
  let detailsHTML = '';
1405
1473
  // Source expression for this entry (used in hover tooltips and expanded details)
1406
1474
  const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
1407
- if (entry.args.length > 0) {
1475
+ // HTML table rendering for gg.table() entries
1476
+ if (entry.tableData && entry.tableData.keys.length > 0) {
1477
+ const { keys, rows: tableRows } = entry.tableData;
1478
+ const headerCells = keys
1479
+ .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
1480
+ .join('');
1481
+ const bodyRowsHtml = tableRows
1482
+ .map((row) => {
1483
+ const cells = keys
1484
+ .map((k) => {
1485
+ const val = row[k];
1486
+ const display = val === undefined ? '' : escapeHtml(String(val));
1487
+ return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
1488
+ })
1489
+ .join('');
1490
+ return `<tr>${cells}</tr>`;
1491
+ })
1492
+ .join('');
1493
+ argsHTML = `<table style="border-collapse: collapse; margin: 2px 0; font-family: monospace;"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRowsHtml}</tbody></table>`;
1494
+ }
1495
+ else if (entry.args.length > 0) {
1408
1496
  argsHTML = entry.args
1409
1497
  .map((arg, argIdx) => {
1410
1498
  if (typeof arg === 'object' && arg !== null) {
@@ -1460,15 +1548,31 @@ export function createGgPlugin(options, gg) {
1460
1548
  }
1461
1549
  }
1462
1550
  const fileTitle = fileTitleText ? ` title="${escapeHtml(fileTitleText)}"` : '';
1551
+ // Level class for info/warn/error styling
1552
+ const levelClass = entry.level === 'info'
1553
+ ? ' gg-level-info'
1554
+ : entry.level === 'warn'
1555
+ ? ' gg-level-warn'
1556
+ : entry.level === 'error'
1557
+ ? ' gg-level-error'
1558
+ : '';
1559
+ // Stack trace toggle (for error/trace entries with captured stacks)
1560
+ let stackHTML = '';
1561
+ if (entry.stack) {
1562
+ const stackId = `stack-${index}`;
1563
+ stackHTML =
1564
+ `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
1565
+ `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
1566
+ }
1463
1567
  // Desktop: grid layout, Mobile: stacked layout
1464
- return (`<div class="gg-log-entry">` +
1568
+ return (`<div class="gg-log-entry${levelClass}">` +
1465
1569
  `<div class="gg-log-header">` +
1466
1570
  iconsCol +
1467
1571
  `<div class="gg-log-diff${soloClass}" style="color: ${color};"${soloAttr}>${diff}</div>` +
1468
1572
  `<div class="gg-log-ns${soloClass}" style="color: ${color};"${soloAttr}${fileAttr}${lineAttr}${colAttr}${fileTitle}>${ns}</div>` +
1469
1573
  `<div class="gg-log-handle"></div>` +
1470
1574
  `</div>` +
1471
- `<div class="gg-log-content"${entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}</div>` +
1575
+ `<div class="gg-log-content"${!entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${stackHTML}</div>` +
1472
1576
  detailsHTML +
1473
1577
  `</div>`);
1474
1578
  })
@@ -18,6 +18,8 @@ export interface GgErudaOptions {
18
18
  */
19
19
  erudaOptions?: Record<string, unknown>;
20
20
  }
21
+ /** Log severity level */
22
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
21
23
  /**
22
24
  * A captured log entry from gg()
23
25
  */
@@ -42,6 +44,15 @@ export interface CapturedEntry {
42
44
  col?: number;
43
45
  /** Source expression text for icecream-style display (e.g., "user.name") */
44
46
  src?: string;
47
+ /** Log severity level (default: 'debug') */
48
+ level?: LogLevel;
49
+ /** Stack trace string (captured for error/trace calls) */
50
+ stack?: string;
51
+ /** Structured table data for gg.table() — Eruda renders as HTML table */
52
+ tableData?: {
53
+ keys: string[];
54
+ rows: Array<Record<string, unknown>>;
55
+ };
45
56
  }
46
57
  /**
47
58
  * Eruda plugin interface
@@ -39,7 +39,17 @@ export default function ggCallSitesPlugin(options = {}) {
39
39
  if (!/\.(js|ts|svelte|jsx|tsx|mjs|mts)(\?.*)?$/.test(id))
40
40
  return null;
41
41
  // Quick bail: no gg calls in this file
42
- if (!code.includes('gg(') && !code.includes('gg.ns('))
42
+ if (!code.includes('gg(') &&
43
+ !code.includes('gg.ns(') &&
44
+ !code.includes('gg.info(') &&
45
+ !code.includes('gg.warn(') &&
46
+ !code.includes('gg.error(') &&
47
+ !code.includes('gg.table(') &&
48
+ !code.includes('gg.trace(') &&
49
+ !code.includes('gg.assert(') &&
50
+ !code.includes('gg.time(') &&
51
+ !code.includes('gg.timeLog(') &&
52
+ !code.includes('gg.timeEnd('))
43
53
  return null;
44
54
  // Don't transform gg's own source files
45
55
  if (id.includes('/lib/gg.') || id.includes('/lib/debug'))
@@ -122,7 +132,7 @@ export function collectCodeRanges(code) {
122
132
  const functionScopes = [];
123
133
  // Script blocks (instance + module)
124
134
  // The Svelte AST Program node has start/end at runtime but TypeScript's
125
- // estree Program type doesn't declare them — cast through any.
135
+ // estree Program type doesn't declare them — we know they exist.
126
136
  if (ast.instance) {
127
137
  const content = ast.instance.content;
128
138
  ranges.push({ start: content.start, end: content.end, context: 'script' });
@@ -643,61 +653,68 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
643
653
  // States for string/comment tracking
644
654
  let i = 0;
645
655
  while (i < code.length) {
646
- // Skip single-line comments
647
- if (code[i] === '/' && code[i + 1] === '/') {
648
- const end = code.indexOf('\n', i);
649
- i = end === -1 ? code.length : end + 1;
650
- continue;
651
- }
652
- // Skip multi-line comments
653
- if (code[i] === '/' && code[i + 1] === '*') {
654
- const end = code.indexOf('*/', i + 2);
655
- i = end === -1 ? code.length : end + 2;
656
- continue;
657
- }
658
- // Skip template literals (backticks)
659
- if (code[i] === '`') {
660
- i++;
661
- let depth = 0;
662
- while (i < code.length) {
663
- if (code[i] === '\\') {
664
- i += 2;
665
- continue;
666
- }
667
- if (code[i] === '$' && code[i + 1] === '{') {
668
- depth++;
669
- i += 2;
670
- continue;
671
- }
672
- if (code[i] === '}' && depth > 0) {
673
- depth--;
674
- i++;
675
- continue;
676
- }
677
- if (code[i] === '`' && depth === 0) {
656
+ // For .svelte files, only apply JS string/comment/backtick skipping inside
657
+ // code ranges (script blocks + template expressions). Outside code ranges,
658
+ // characters like ' " ` // /* are just HTML prose — NOT JS syntax.
659
+ // e.g. "Eruda's" contains an apostrophe that is NOT a JS string delimiter.
660
+ const inCodeRange = !svelteInfo || !!rangeAt(i);
661
+ if (inCodeRange) {
662
+ // Skip single-line comments
663
+ if (code[i] === '/' && code[i + 1] === '/') {
664
+ const end = code.indexOf('\n', i);
665
+ i = end === -1 ? code.length : end + 1;
666
+ continue;
667
+ }
668
+ // Skip multi-line comments
669
+ if (code[i] === '/' && code[i + 1] === '*') {
670
+ const end = code.indexOf('*/', i + 2);
671
+ i = end === -1 ? code.length : end + 2;
672
+ continue;
673
+ }
674
+ // Skip template literals (backticks)
675
+ if (code[i] === '`') {
676
+ i++;
677
+ let depth = 0;
678
+ while (i < code.length) {
679
+ if (code[i] === '\\') {
680
+ i += 2;
681
+ continue;
682
+ }
683
+ if (code[i] === '$' && code[i + 1] === '{') {
684
+ depth++;
685
+ i += 2;
686
+ continue;
687
+ }
688
+ if (code[i] === '}' && depth > 0) {
689
+ depth--;
690
+ i++;
691
+ continue;
692
+ }
693
+ if (code[i] === '`' && depth === 0) {
694
+ i++;
695
+ break;
696
+ }
678
697
  i++;
679
- break;
680
698
  }
681
- i++;
699
+ continue;
682
700
  }
683
- continue;
684
- }
685
- // Skip strings (single and double quotes)
686
- if (code[i] === '"' || code[i] === "'") {
687
- const quote = code[i];
688
- i++;
689
- while (i < code.length) {
690
- if (code[i] === '\\') {
691
- i += 2;
692
- continue;
693
- }
694
- if (code[i] === quote) {
701
+ // Skip strings (single and double quotes)
702
+ if (code[i] === '"' || code[i] === "'") {
703
+ const quote = code[i];
704
+ i++;
705
+ while (i < code.length) {
706
+ if (code[i] === '\\') {
707
+ i += 2;
708
+ continue;
709
+ }
710
+ if (code[i] === quote) {
711
+ i++;
712
+ break;
713
+ }
695
714
  i++;
696
- break;
697
715
  }
698
- i++;
716
+ continue;
699
717
  }
700
- continue;
701
718
  }
702
719
  // Look for 'gg' pattern — could be gg( or gg.ns(
703
720
  if (code[i] === 'g' && code[i + 1] === 'g') {
@@ -783,7 +800,44 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
783
800
  i += 6;
784
801
  continue;
785
802
  }
786
- // Skip other gg.* calls (gg.enable, gg.disable, gg._ns, gg._onLog, etc.)
803
+ // Case 1b: gg.info/warn/error/table/trace/assert gg._info/_warn/_error/_table/_trace/_assert
804
+ // These methods are rewritten like bare gg() but with their internal variant.
805
+ const dotMethodMatch = code
806
+ .slice(i + 2)
807
+ .match(/^\.(info|warn|error|table|trace|assert|time|timeLog|timeEnd)\(/);
808
+ if (dotMethodMatch) {
809
+ const methodName = dotMethodMatch[1];
810
+ const internalName = `_${methodName}`;
811
+ const methodCallLen = 2 + 1 + methodName.length + 1; // 'gg' + '.' + method + '('
812
+ const openParenPos = i + methodCallLen - 1;
813
+ const { line, col } = getLineCol(code, i);
814
+ const fnName = getFunctionName(i, range);
815
+ const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
816
+ const escapedNs = escapeForString(callpoint);
817
+ const closeParenPos = findMatchingParen(code, openParenPos);
818
+ if (closeParenPos === -1) {
819
+ i += methodCallLen;
820
+ continue;
821
+ }
822
+ const argsText = code.slice(openParenPos + 1, closeParenPos).trim();
823
+ result.push(code.slice(lastIndex, i));
824
+ if (argsText === '') {
825
+ // gg.warn() → gg._warn(opts)
826
+ result.push(`gg.${internalName}(${buildOptions(range, escapedNs, line, col)})`);
827
+ lastIndex = closeParenPos + 1;
828
+ i = closeParenPos + 1;
829
+ }
830
+ else {
831
+ // gg.warn(expr) → gg._warn(opts, expr)
832
+ const escapedSrc = escapeForString(argsText);
833
+ result.push(`gg.${internalName}(${buildOptions(range, escapedNs, line, col, escapedSrc)}, `);
834
+ lastIndex = openParenPos + 1; // keep original args
835
+ i = openParenPos + 1;
836
+ }
837
+ modified = true;
838
+ continue;
839
+ }
840
+ // Skip other gg.* calls (gg.enable, gg.disable, gg._ns, gg._onLog, gg.time, etc.)
787
841
  if (code[i + 2] === '.') {
788
842
  i += 3;
789
843
  continue;
package/dist/gg.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Hook for capturing gg() output (used by Eruda plugin)
3
3
  */
4
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
4
5
  interface CapturedEntry {
5
6
  namespace: string;
6
7
  color: string;
@@ -12,6 +13,12 @@ interface CapturedEntry {
12
13
  line?: number;
13
14
  col?: number;
14
15
  src?: string;
16
+ level?: LogLevel;
17
+ stack?: string;
18
+ tableData?: {
19
+ keys: string[];
20
+ rows: Array<Record<string, unknown>>;
21
+ };
15
22
  }
16
23
  type OnLogCallback = (entry: CapturedEntry) => void;
17
24
  export declare function gg(): {
@@ -93,6 +100,8 @@ export declare namespace gg {
93
100
  line?: number;
94
101
  col?: number;
95
102
  src?: string;
103
+ level?: LogLevel;
104
+ stack?: string;
96
105
  }, ...args: unknown[]) => unknown;
97
106
  let _o: (ns: string, file?: string, line?: number, col?: number, src?: string) => {
98
107
  ns: string;
@@ -101,6 +110,78 @@ export declare namespace gg {
101
110
  col?: number;
102
111
  src?: string;
103
112
  };
113
+ let info: (...args: unknown[]) => unknown;
114
+ let warn: (...args: unknown[]) => unknown;
115
+ let error: (...args: unknown[]) => unknown;
116
+ let assert: (condition: unknown, ...args: unknown[]) => unknown;
117
+ let table: (data: unknown, columns?: string[]) => unknown;
118
+ let time: (label?: string) => void;
119
+ let timeLog: (label?: string, ...args: unknown[]) => void;
120
+ let timeEnd: (label?: string) => void;
121
+ let trace: (...args: unknown[]) => unknown;
122
+ let _info: (options: {
123
+ ns: string;
124
+ file?: string;
125
+ line?: number;
126
+ col?: number;
127
+ src?: string;
128
+ }, ...args: unknown[]) => unknown;
129
+ let _warn: (options: {
130
+ ns: string;
131
+ file?: string;
132
+ line?: number;
133
+ col?: number;
134
+ src?: string;
135
+ }, ...args: unknown[]) => unknown;
136
+ let _error: (options: {
137
+ ns: string;
138
+ file?: string;
139
+ line?: number;
140
+ col?: number;
141
+ src?: string;
142
+ }, ...args: unknown[]) => unknown;
143
+ let _assert: (options: {
144
+ ns: string;
145
+ file?: string;
146
+ line?: number;
147
+ col?: number;
148
+ src?: string;
149
+ }, condition: unknown, ...args: unknown[]) => unknown;
150
+ let _table: (options: {
151
+ ns: string;
152
+ file?: string;
153
+ line?: number;
154
+ col?: number;
155
+ src?: string;
156
+ }, data: unknown, columns?: string[]) => unknown;
157
+ let _trace: (options: {
158
+ ns: string;
159
+ file?: string;
160
+ line?: number;
161
+ col?: number;
162
+ src?: string;
163
+ }, ...args: unknown[]) => unknown;
164
+ let _time: (options: {
165
+ ns: string;
166
+ file?: string;
167
+ line?: number;
168
+ col?: number;
169
+ src?: string;
170
+ }, label?: string) => void;
171
+ let _timeLog: (options: {
172
+ ns: string;
173
+ file?: string;
174
+ line?: number;
175
+ col?: number;
176
+ src?: string;
177
+ }, label?: string, ...args: unknown[]) => void;
178
+ let _timeEnd: (options: {
179
+ ns: string;
180
+ file?: string;
181
+ line?: number;
182
+ col?: number;
183
+ src?: string;
184
+ }, label?: string) => void;
104
185
  }
105
186
  /**
106
187
  * Run gg diagnostics and log configuration status
package/dist/gg.js CHANGED
@@ -207,53 +207,7 @@ export function gg(...args) {
207
207
  // Same call site always produces the same word pair (e.g. "calm-fox").
208
208
  // depth=2: skip "Error" header [0] and gg() frame [1]
209
209
  const callpoint = resolveCallpoint(2);
210
- const namespace = `gg:${callpoint}`;
211
- const ggLogFunction = namespaceToLogFunction.get(namespace) ||
212
- namespaceToLogFunction.set(namespace, createGgDebugger(namespace)).get(namespace);
213
- // Prepare args for logging
214
- let logArgs;
215
- let returnValue;
216
- if (!args.length) {
217
- // No arguments: return stub call-site info (no open-in-editor without plugin)
218
- logArgs = [` 📝 ${callpoint} (install gg-call-sites-plugin for editor links)`];
219
- returnValue = {
220
- fileName: callpoint,
221
- functionName: '',
222
- url: ''
223
- };
224
- }
225
- else if (args.length === 1) {
226
- logArgs = [args[0]];
227
- returnValue = args[0];
228
- }
229
- else {
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));
239
- }
240
- // Call capture hook if registered (for Eruda plugin)
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);
255
- }
256
- return returnValue;
210
+ return ggLog({ ns: callpoint }, ...args);
257
211
  }
258
212
  /**
259
213
  * gg.ns() - Log with an explicit namespace (callpoint label).
@@ -291,19 +245,14 @@ gg.ns = function (nsLabel, ...args) {
291
245
  return gg._ns({ ns: nsLabel }, ...args);
292
246
  };
293
247
  /**
294
- * gg._ns() - Internal: log with namespace and source file metadata.
248
+ * Core logging function shared by all gg methods.
295
249
  *
296
- * Called by the ggCallSitesPlugin Vite plugin, which rewrites both bare gg()
297
- * calls and manual gg.ns() calls to gg._ns({ns, file, line, col}, ...) at
298
- * build time. This gives each call site a unique namespace plus the source
299
- * location for open-in-editor support.
300
- *
301
- * @param options - { ns: string; file?: string; line?: number; col?: number }
302
- * @param args - Same arguments as gg()
303
- * @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.
304
253
  */
305
- gg._ns = function (options, ...args) {
306
- 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;
307
256
  if (!ggConfig.enabled || isCloudflareWorker()) {
308
257
  return args.length ? args[0] : { fileName: '', functionName: '', url: '' };
309
258
  }
@@ -333,6 +282,16 @@ gg._ns = function (options, ...args) {
333
282
  logArgs = [args[0], ...args.slice(1)];
334
283
  returnValue = args[0];
335
284
  }
285
+ // Add level prefix emoji for info/warn/error
286
+ if (level === 'info') {
287
+ logArgs[0] = `ℹ️ ${logArgs[0]}`;
288
+ }
289
+ else if (level === 'warn') {
290
+ logArgs[0] = `⚠️ ${logArgs[0]}`;
291
+ }
292
+ else if (level === 'error') {
293
+ logArgs[0] = `⛔ ${logArgs[0]}`;
294
+ }
336
295
  // Log to console via debug
337
296
  if (logArgs.length === 1) {
338
297
  ggLogFunction(logArgs[0]);
@@ -351,7 +310,10 @@ gg._ns = function (options, ...args) {
351
310
  file,
352
311
  line,
353
312
  col,
354
- src
313
+ src,
314
+ level,
315
+ stack,
316
+ tableData
355
317
  };
356
318
  if (_onLogCallback) {
357
319
  _onLogCallback(entry);
@@ -360,6 +322,21 @@ gg._ns = function (options, ...args) {
360
322
  earlyLogBuffer.push(entry);
361
323
  }
362
324
  return returnValue;
325
+ }
326
+ /**
327
+ * gg._ns() - Internal: log with namespace and source file metadata.
328
+ *
329
+ * Called by the ggCallSitesPlugin Vite plugin, which rewrites both bare gg()
330
+ * calls and manual gg.ns() calls to gg._ns({ns, file, line, col}, ...) at
331
+ * build time. This gives each call site a unique namespace plus the source
332
+ * location for open-in-editor support.
333
+ *
334
+ * @param options - { ns: string; file?: string; line?: number; col?: number }
335
+ * @param args - Same arguments as gg()
336
+ * @returns Same as gg() - the first arg, or call-site info if no args
337
+ */
338
+ gg._ns = function (options, ...args) {
339
+ return ggLog(options, ...args);
363
340
  };
364
341
  /**
365
342
  * gg._o() - Internal: build options object for gg._ns() without object literal syntax.
@@ -390,6 +367,380 @@ gg.clearPersist = () => {
390
367
  }
391
368
  }
392
369
  };
370
+ // ── Console-like methods ───────────────────────────────────────────────
371
+ // Each public method (gg.warn, gg.error, etc.) has a corresponding internal
372
+ // method (gg._warn, gg._error, etc.) that accepts call-site metadata from
373
+ // the Vite plugin. The public methods use runtime stack-based callpoints
374
+ // as a fallback when the plugin isn't installed.
375
+ /**
376
+ * Capture a cleaned-up stack trace, stripping internal gg frames.
377
+ * @param skipFrames - Number of internal frames to strip from the top
378
+ */
379
+ function captureStack(skipFrames) {
380
+ let stack = new Error().stack || undefined;
381
+ if (stack) {
382
+ const lines = stack.split('\n');
383
+ stack = lines.slice(skipFrames).join('\n');
384
+ }
385
+ return stack;
386
+ }
387
+ /**
388
+ * Get stack from an Error arg or capture a fresh one.
389
+ */
390
+ function getErrorStack(firstArg, skipFrames) {
391
+ if (firstArg instanceof Error && firstArg.stack) {
392
+ return firstArg.stack;
393
+ }
394
+ return captureStack(skipFrames);
395
+ }
396
+ /**
397
+ * gg.info() - Log at info level.
398
+ *
399
+ * Passthrough: returns the first argument.
400
+ * In Eruda, entries are styled with a blue/info indicator.
401
+ *
402
+ * @example
403
+ * gg.info('System startup complete');
404
+ * const config = gg.info(loadedConfig, 'loaded config');
405
+ */
406
+ gg.info = function (...args) {
407
+ if (!ggConfig.enabled || isCloudflareWorker()) {
408
+ return args.length ? args[0] : undefined;
409
+ }
410
+ const callpoint = resolveCallpoint(3);
411
+ return ggLog({ ns: callpoint, level: 'info' }, ...args);
412
+ };
413
+ /**
414
+ * gg._info() - Internal: info with call-site metadata from Vite plugin.
415
+ */
416
+ gg._info = function (options, ...args) {
417
+ return ggLog({ ...options, level: 'info' }, ...args);
418
+ };
419
+ /**
420
+ * gg.warn() - Log at warning level.
421
+ *
422
+ * Passthrough: returns the first argument.
423
+ * In Eruda, entries are styled with a yellow/warning indicator.
424
+ *
425
+ * @example
426
+ * gg.warn('deprecated API used');
427
+ * const result = gg.warn(computeValue(), 'might be slow');
428
+ */
429
+ gg.warn = function (...args) {
430
+ if (!ggConfig.enabled || isCloudflareWorker()) {
431
+ return args.length ? args[0] : undefined;
432
+ }
433
+ const callpoint = resolveCallpoint(3);
434
+ return ggLog({ ns: callpoint, level: 'warn' }, ...args);
435
+ };
436
+ /**
437
+ * gg._warn() - Internal: warn with call-site metadata from Vite plugin.
438
+ */
439
+ gg._warn = function (options, ...args) {
440
+ return ggLog({ ...options, level: 'warn' }, ...args);
441
+ };
442
+ /**
443
+ * gg.error() - Log at error level.
444
+ *
445
+ * Passthrough: returns the first argument.
446
+ * Captures a stack trace silently — visible in Eruda via a collapsible toggle.
447
+ * If the first argument is an Error object, its .stack is used instead.
448
+ *
449
+ * @example
450
+ * gg.error('connection failed');
451
+ * gg.error(new Error('timeout'));
452
+ * const val = gg.error(response, 'unexpected status');
453
+ */
454
+ gg.error = function (...args) {
455
+ if (!ggConfig.enabled || isCloudflareWorker()) {
456
+ return args.length ? args[0] : undefined;
457
+ }
458
+ const callpoint = resolveCallpoint(3);
459
+ const stack = getErrorStack(args[0], 4);
460
+ return ggLog({ ns: callpoint, level: 'error', stack }, ...args);
461
+ };
462
+ /**
463
+ * gg._error() - Internal: error with call-site metadata from Vite plugin.
464
+ */
465
+ gg._error = function (options, ...args) {
466
+ const stack = getErrorStack(args[0], 3);
467
+ return ggLog({ ...options, level: 'error', stack }, ...args);
468
+ };
469
+ /**
470
+ * gg.assert() - Log only if condition is false.
471
+ *
472
+ * Like console.assert: if the first argument is falsy, logs the remaining
473
+ * arguments at error level. If the condition is truthy, does nothing.
474
+ * Passthrough: always returns the condition value.
475
+ *
476
+ * @example
477
+ * gg.assert(user != null, 'user should exist');
478
+ * gg.assert(list.length > 0, 'list is empty', list);
479
+ */
480
+ gg.assert = function (condition, ...args) {
481
+ if (!condition) {
482
+ if (!ggConfig.enabled || isCloudflareWorker())
483
+ return condition;
484
+ const callpoint = resolveCallpoint(3);
485
+ const stack = captureStack(4);
486
+ const assertArgs = args.length > 0 ? args : ['Assertion failed'];
487
+ ggLog({ ns: callpoint, level: 'error', stack }, ...assertArgs);
488
+ }
489
+ return condition;
490
+ };
491
+ /**
492
+ * gg._assert() - Internal: assert with call-site metadata from Vite plugin.
493
+ */
494
+ gg._assert = function (options, condition, ...args) {
495
+ if (!condition) {
496
+ if (!ggConfig.enabled || isCloudflareWorker())
497
+ return condition;
498
+ const stack = captureStack(3);
499
+ const assertArgs = args.length > 0 ? args : ['Assertion failed'];
500
+ ggLog({ ...options, level: 'error', stack }, ...assertArgs);
501
+ }
502
+ return condition;
503
+ };
504
+ /**
505
+ * gg.table() - Log tabular data.
506
+ *
507
+ * Formats an array of objects (or an object of objects) as an ASCII table.
508
+ * Passthrough: returns the data argument.
509
+ *
510
+ * @example
511
+ * gg.table([{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]);
512
+ * gg.table({a: {x: 1}, b: {x: 2}});
513
+ */
514
+ gg.table = function (data, columns) {
515
+ if (!ggConfig.enabled || isCloudflareWorker())
516
+ return data;
517
+ const callpoint = resolveCallpoint(3);
518
+ const { keys, rows } = formatTable(data, columns);
519
+ ggLog({ ns: callpoint, tableData: { keys, rows } }, '(table)');
520
+ // Also emit a native console.table for proper rendering in browser/Node consoles
521
+ if (columns) {
522
+ console.table(data, columns);
523
+ }
524
+ else {
525
+ console.table(data);
526
+ }
527
+ return data;
528
+ };
529
+ /**
530
+ * gg._table() - Internal: table with call-site metadata from Vite plugin.
531
+ */
532
+ gg._table = function (options, data, columns) {
533
+ if (!ggConfig.enabled || isCloudflareWorker())
534
+ return data;
535
+ const { keys, rows } = formatTable(data, columns);
536
+ ggLog({ ...options, tableData: { keys, rows } }, '(table)');
537
+ if (columns) {
538
+ console.table(data, columns);
539
+ }
540
+ else {
541
+ console.table(data);
542
+ }
543
+ return data;
544
+ };
545
+ // Timer storage for gg.time / gg.timeEnd / gg.timeLog
546
+ const timers = new Map();
547
+ /**
548
+ * gg.time() - Start a named timer.
549
+ *
550
+ * @example
551
+ * gg.time('fetch');
552
+ * const data = await fetchData();
553
+ * gg.timeEnd('fetch'); // logs "+123ms fetch: 456ms"
554
+ */
555
+ gg.time = function (label = 'default') {
556
+ if (!ggConfig.enabled || isCloudflareWorker())
557
+ return;
558
+ timers.set(label, performance.now());
559
+ };
560
+ /** gg._time() - Internal: time with call-site metadata from Vite plugin. */
561
+ gg._time = function (_options, label = 'default') {
562
+ if (!ggConfig.enabled || isCloudflareWorker())
563
+ return;
564
+ timers.set(label, performance.now());
565
+ };
566
+ /**
567
+ * gg.timeLog() - Log the current elapsed time without stopping the timer.
568
+ *
569
+ * @example
570
+ * gg.time('process');
571
+ * // ... step 1 ...
572
+ * gg.timeLog('process', 'step 1 done');
573
+ * // ... step 2 ...
574
+ * gg.timeEnd('process');
575
+ */
576
+ gg.timeLog = function (label = 'default', ...args) {
577
+ if (!ggConfig.enabled || isCloudflareWorker())
578
+ return;
579
+ const start = timers.get(label);
580
+ if (start === undefined) {
581
+ const callpoint = resolveCallpoint(3);
582
+ ggLog({ ns: callpoint, level: 'warn' }, `Timer '${label}' does not exist`);
583
+ return;
584
+ }
585
+ const elapsed = performance.now() - start;
586
+ const callpoint = resolveCallpoint(3);
587
+ ggLog({ ns: callpoint }, `${label}: ${formatElapsed(elapsed)}`, ...args);
588
+ };
589
+ /** gg._timeLog() - Internal: timeLog with call-site metadata from Vite plugin. */
590
+ gg._timeLog = function (options, label = 'default', ...args) {
591
+ if (!ggConfig.enabled || isCloudflareWorker())
592
+ return;
593
+ const start = timers.get(label);
594
+ if (start === undefined) {
595
+ ggLog({ ...options, level: 'warn' }, `Timer '${label}' does not exist`);
596
+ return;
597
+ }
598
+ const elapsed = performance.now() - start;
599
+ ggLog(options, `${label}: ${formatElapsed(elapsed)}`, ...args);
600
+ };
601
+ /**
602
+ * gg.timeEnd() - Stop a named timer and log the elapsed time.
603
+ *
604
+ * @example
605
+ * gg.time('fetch');
606
+ * const data = await fetchData();
607
+ * gg.timeEnd('fetch'); // logs "fetch: 456.12ms"
608
+ */
609
+ gg.timeEnd = function (label = 'default') {
610
+ if (!ggConfig.enabled || isCloudflareWorker())
611
+ return;
612
+ const start = timers.get(label);
613
+ if (start === undefined) {
614
+ const callpoint = resolveCallpoint(3);
615
+ ggLog({ ns: callpoint, level: 'warn' }, `Timer '${label}' does not exist`);
616
+ return;
617
+ }
618
+ const elapsed = performance.now() - start;
619
+ timers.delete(label);
620
+ const callpoint = resolveCallpoint(3);
621
+ ggLog({ ns: callpoint }, `${label}: ${formatElapsed(elapsed)}`);
622
+ };
623
+ /** gg._timeEnd() - Internal: timeEnd with call-site metadata from Vite plugin. */
624
+ gg._timeEnd = function (options, label = 'default') {
625
+ if (!ggConfig.enabled || isCloudflareWorker())
626
+ return;
627
+ const start = timers.get(label);
628
+ if (start === undefined) {
629
+ ggLog({ ...options, level: 'warn' }, `Timer '${label}' does not exist`);
630
+ return;
631
+ }
632
+ const elapsed = performance.now() - start;
633
+ timers.delete(label);
634
+ ggLog(options, `${label}: ${formatElapsed(elapsed)}`);
635
+ };
636
+ /**
637
+ * gg.trace() - Log with a stack trace.
638
+ *
639
+ * Like console.trace: logs the arguments plus a full stack trace.
640
+ * Passthrough: returns the first argument.
641
+ *
642
+ * @example
643
+ * gg.trace('how did we get here?');
644
+ * const val = gg.trace(result, 'call path');
645
+ */
646
+ gg.trace = function (...args) {
647
+ if (!ggConfig.enabled || isCloudflareWorker()) {
648
+ return args.length ? args[0] : undefined;
649
+ }
650
+ const callpoint = resolveCallpoint(3);
651
+ const stack = captureStack(4);
652
+ const traceArgs = args.length > 0 ? args : ['Trace'];
653
+ return ggLog({ ns: callpoint, stack }, ...traceArgs);
654
+ };
655
+ /**
656
+ * gg._trace() - Internal: trace with call-site metadata from Vite plugin.
657
+ */
658
+ gg._trace = function (options, ...args) {
659
+ if (!ggConfig.enabled || isCloudflareWorker()) {
660
+ return args.length ? args[0] : undefined;
661
+ }
662
+ const stack = captureStack(3);
663
+ const traceArgs = args.length > 0 ? args : ['Trace'];
664
+ return ggLog({ ...options, stack }, ...traceArgs);
665
+ };
666
+ /**
667
+ * Format elapsed time with appropriate precision.
668
+ * < 1s → "123.45ms", >= 1s → "1.23s", >= 60s → "1m 2.3s"
669
+ */
670
+ function formatElapsed(ms) {
671
+ if (ms < 1000)
672
+ return `${ms.toFixed(2)}ms`;
673
+ if (ms < 60000)
674
+ return `${(ms / 1000).toFixed(2)}s`;
675
+ const minutes = Math.floor(ms / 60000);
676
+ const seconds = (ms % 60000) / 1000;
677
+ return `${minutes}m ${seconds.toFixed(1)}s`;
678
+ }
679
+ /**
680
+ * Normalize data into structured keys + rows for table rendering.
681
+ * Used by both Eruda (HTML table) and console.table() delegation.
682
+ * Supports arrays of objects, arrays of primitives, and objects of objects.
683
+ */
684
+ function formatTable(data, columns) {
685
+ if (data === null || data === undefined || typeof data !== 'object') {
686
+ return { keys: [], rows: [] };
687
+ }
688
+ // Normalize to rows: [{key, ...values}]
689
+ let rows;
690
+ let allKeys;
691
+ if (Array.isArray(data)) {
692
+ if (data.length === 0)
693
+ return { keys: [], rows: [] };
694
+ // Array of primitives
695
+ if (typeof data[0] !== 'object' || data[0] === null) {
696
+ allKeys = ['(index)', 'Value'];
697
+ rows = data.map((v, i) => ({ '(index)': i, Value: v }));
698
+ }
699
+ else {
700
+ // Array of objects
701
+ const keySet = new Set();
702
+ keySet.add('(index)');
703
+ for (const item of data) {
704
+ if (item && typeof item === 'object') {
705
+ Object.keys(item).forEach((k) => keySet.add(k));
706
+ }
707
+ }
708
+ allKeys = Array.from(keySet);
709
+ rows = data.map((item, i) => ({
710
+ '(index)': i,
711
+ ...(item && typeof item === 'object' ? item : { Value: item })
712
+ }));
713
+ }
714
+ }
715
+ else {
716
+ // Object of objects/values
717
+ const entries = Object.entries(data);
718
+ if (entries.length === 0)
719
+ return { keys: [], rows: [] };
720
+ const keySet = new Set();
721
+ keySet.add('(index)');
722
+ for (const [, val] of entries) {
723
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
724
+ Object.keys(val).forEach((k) => keySet.add(k));
725
+ }
726
+ else {
727
+ keySet.add('Value');
728
+ }
729
+ }
730
+ allKeys = Array.from(keySet);
731
+ rows = entries.map(([key, val]) => ({
732
+ '(index)': key,
733
+ ...(val && typeof val === 'object' && !Array.isArray(val)
734
+ ? val
735
+ : { Value: val })
736
+ }));
737
+ }
738
+ // Apply column filter
739
+ if (columns && columns.length > 0) {
740
+ allKeys = ['(index)', ...columns.filter((c) => allKeys.includes(c))];
741
+ }
742
+ return { keys: allKeys, rows };
743
+ }
393
744
  /**
394
745
  * Parse color string to RGB values
395
746
  * Accepts: named colors, hex (#rgb, #rrggbb), rgb(r,g,b), rgba(r,g,b,a)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftium/gg",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"