@leftium/gg 0.0.34 → 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/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,53 @@ 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 warn/error entries */
525
+ .gg-level-warn .gg-log-diff,
526
+ .gg-level-warn .gg-log-ns,
527
+ .gg-level-warn .gg-log-content {
528
+ background: rgba(255, 200, 0, 0.08);
529
+ }
530
+ .gg-level-warn .gg-log-content {
531
+ border-left: 3px solid #e6a700;
532
+ padding-left: 6px;
533
+ }
534
+ .gg-level-error .gg-log-diff,
535
+ .gg-level-error .gg-log-ns,
536
+ .gg-level-error .gg-log-content {
537
+ background: rgba(255, 50, 50, 0.08);
538
+ }
539
+ .gg-level-error .gg-log-content {
540
+ border-left: 3px solid #cc0000;
541
+ padding-left: 6px;
542
+ }
543
+ /* Stack trace toggle */
544
+ .gg-stack-toggle {
545
+ cursor: pointer;
546
+ font-size: 11px;
547
+ opacity: 0.6;
548
+ margin-left: 8px;
549
+ user-select: none;
550
+ }
551
+ .gg-stack-toggle:hover {
552
+ opacity: 1;
553
+ }
554
+ .gg-stack-content {
555
+ display: none;
556
+ font-size: 11px;
557
+ font-family: monospace;
558
+ white-space: pre;
559
+ padding: 6px 8px;
560
+ margin-top: 4px;
561
+ background: #f0f0f0;
562
+ border-radius: 3px;
563
+ overflow-x: auto;
564
+ color: #666;
565
+ line-height: 1.4;
566
+ }
567
+ .gg-stack-content.expanded {
568
+ display: block;
569
+ }
570
+ .gg-filter-panel {
525
571
  background: #f5f5f5;
526
572
  padding: 10px;
527
573
  margin-bottom: 8px;
@@ -1214,6 +1260,19 @@ export function createGgPlugin(options, gg) {
1214
1260
  }
1215
1261
  return;
1216
1262
  }
1263
+ // Handle stack trace toggle
1264
+ if (target?.classList?.contains('gg-stack-toggle')) {
1265
+ const stackId = target.getAttribute('data-stack-id');
1266
+ if (!stackId)
1267
+ return;
1268
+ const stackEl = containerEl.querySelector(`.gg-stack-content[data-stack-id="${stackId}"]`);
1269
+ if (stackEl) {
1270
+ const isExpanded = stackEl.classList.contains('expanded');
1271
+ stackEl.classList.toggle('expanded');
1272
+ target.textContent = isExpanded ? '▶ stack' : '▼ stack';
1273
+ }
1274
+ return;
1275
+ }
1217
1276
  // Handle clicking namespace to open in editor (when filter collapsed)
1218
1277
  if (target?.classList?.contains('gg-log-ns') &&
1219
1278
  target.hasAttribute('data-file') &&
@@ -1404,7 +1463,27 @@ export function createGgPlugin(options, gg) {
1404
1463
  let detailsHTML = '';
1405
1464
  // Source expression for this entry (used in hover tooltips and expanded details)
1406
1465
  const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
1407
- if (entry.args.length > 0) {
1466
+ // HTML table rendering for gg.table() entries
1467
+ if (entry.tableData && entry.tableData.keys.length > 0) {
1468
+ const { keys, rows: tableRows } = entry.tableData;
1469
+ const headerCells = keys
1470
+ .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
1471
+ .join('');
1472
+ const bodyRowsHtml = tableRows
1473
+ .map((row) => {
1474
+ const cells = keys
1475
+ .map((k) => {
1476
+ const val = row[k];
1477
+ const display = val === undefined ? '' : escapeHtml(String(val));
1478
+ return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
1479
+ })
1480
+ .join('');
1481
+ return `<tr>${cells}</tr>`;
1482
+ })
1483
+ .join('');
1484
+ argsHTML = `<table style="border-collapse: collapse; margin: 2px 0; font-family: monospace;"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRowsHtml}</tbody></table>`;
1485
+ }
1486
+ else if (entry.args.length > 0) {
1408
1487
  argsHTML = entry.args
1409
1488
  .map((arg, argIdx) => {
1410
1489
  if (typeof arg === 'object' && arg !== null) {
@@ -1460,15 +1539,29 @@ export function createGgPlugin(options, gg) {
1460
1539
  }
1461
1540
  }
1462
1541
  const fileTitle = fileTitleText ? ` title="${escapeHtml(fileTitleText)}"` : '';
1542
+ // Level class for warn/error styling
1543
+ const levelClass = entry.level === 'warn'
1544
+ ? ' gg-level-warn'
1545
+ : entry.level === 'error'
1546
+ ? ' gg-level-error'
1547
+ : '';
1548
+ // Stack trace toggle (for error/trace entries with captured stacks)
1549
+ let stackHTML = '';
1550
+ if (entry.stack) {
1551
+ const stackId = `stack-${index}`;
1552
+ stackHTML =
1553
+ `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
1554
+ `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
1555
+ }
1463
1556
  // Desktop: grid layout, Mobile: stacked layout
1464
- return (`<div class="gg-log-entry">` +
1557
+ return (`<div class="gg-log-entry${levelClass}">` +
1465
1558
  `<div class="gg-log-header">` +
1466
1559
  iconsCol +
1467
1560
  `<div class="gg-log-diff${soloClass}" style="color: ${color};"${soloAttr}>${diff}</div>` +
1468
1561
  `<div class="gg-log-ns${soloClass}" style="color: ${color};"${soloAttr}${fileAttr}${lineAttr}${colAttr}${fileTitle}>${ns}</div>` +
1469
1562
  `<div class="gg-log-handle"></div>` +
1470
1563
  `</div>` +
1471
- `<div class="gg-log-content"${entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}</div>` +
1564
+ `<div class="gg-log-content"${!entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${stackHTML}</div>` +
1472
1565
  detailsHTML +
1473
1566
  `</div>`);
1474
1567
  })
@@ -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' | '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,16 @@ 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.warn(') &&
45
+ !code.includes('gg.error(') &&
46
+ !code.includes('gg.table(') &&
47
+ !code.includes('gg.trace(') &&
48
+ !code.includes('gg.assert(') &&
49
+ !code.includes('gg.time(') &&
50
+ !code.includes('gg.timeLog(') &&
51
+ !code.includes('gg.timeEnd('))
43
52
  return null;
44
53
  // Don't transform gg's own source files
45
54
  if (id.includes('/lib/gg.') || id.includes('/lib/debug'))
@@ -122,7 +131,7 @@ export function collectCodeRanges(code) {
122
131
  const functionScopes = [];
123
132
  // Script blocks (instance + module)
124
133
  // The Svelte AST Program node has start/end at runtime but TypeScript's
125
- // estree Program type doesn't declare them — cast through any.
134
+ // estree Program type doesn't declare them — we know they exist.
126
135
  if (ast.instance) {
127
136
  const content = ast.instance.content;
128
137
  ranges.push({ start: content.start, end: content.end, context: 'script' });
@@ -643,61 +652,68 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
643
652
  // States for string/comment tracking
644
653
  let i = 0;
645
654
  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) {
655
+ // For .svelte files, only apply JS string/comment/backtick skipping inside
656
+ // code ranges (script blocks + template expressions). Outside code ranges,
657
+ // characters like ' " ` // /* are just HTML prose — NOT JS syntax.
658
+ // e.g. "Eruda's" contains an apostrophe that is NOT a JS string delimiter.
659
+ const inCodeRange = !svelteInfo || !!rangeAt(i);
660
+ if (inCodeRange) {
661
+ // Skip single-line comments
662
+ if (code[i] === '/' && code[i + 1] === '/') {
663
+ const end = code.indexOf('\n', i);
664
+ i = end === -1 ? code.length : end + 1;
665
+ continue;
666
+ }
667
+ // Skip multi-line comments
668
+ if (code[i] === '/' && code[i + 1] === '*') {
669
+ const end = code.indexOf('*/', i + 2);
670
+ i = end === -1 ? code.length : end + 2;
671
+ continue;
672
+ }
673
+ // Skip template literals (backticks)
674
+ if (code[i] === '`') {
675
+ i++;
676
+ let depth = 0;
677
+ while (i < code.length) {
678
+ if (code[i] === '\\') {
679
+ i += 2;
680
+ continue;
681
+ }
682
+ if (code[i] === '$' && code[i + 1] === '{') {
683
+ depth++;
684
+ i += 2;
685
+ continue;
686
+ }
687
+ if (code[i] === '}' && depth > 0) {
688
+ depth--;
689
+ i++;
690
+ continue;
691
+ }
692
+ if (code[i] === '`' && depth === 0) {
693
+ i++;
694
+ break;
695
+ }
678
696
  i++;
679
- break;
680
697
  }
681
- i++;
698
+ continue;
682
699
  }
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) {
700
+ // Skip strings (single and double quotes)
701
+ if (code[i] === '"' || code[i] === "'") {
702
+ const quote = code[i];
703
+ i++;
704
+ while (i < code.length) {
705
+ if (code[i] === '\\') {
706
+ i += 2;
707
+ continue;
708
+ }
709
+ if (code[i] === quote) {
710
+ i++;
711
+ break;
712
+ }
695
713
  i++;
696
- break;
697
714
  }
698
- i++;
715
+ continue;
699
716
  }
700
- continue;
701
717
  }
702
718
  // Look for 'gg' pattern — could be gg( or gg.ns(
703
719
  if (code[i] === 'g' && code[i + 1] === 'g') {
@@ -783,7 +799,44 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
783
799
  i += 6;
784
800
  continue;
785
801
  }
786
- // Skip other gg.* calls (gg.enable, gg.disable, gg._ns, gg._onLog, etc.)
802
+ // Case 1b: gg.warn/error/table/trace/assert gg._warn/_error/_table/_trace/_assert
803
+ // These methods are rewritten like bare gg() but with their internal variant.
804
+ const dotMethodMatch = code
805
+ .slice(i + 2)
806
+ .match(/^\.(warn|error|table|trace|assert|time|timeLog|timeEnd)\(/);
807
+ if (dotMethodMatch) {
808
+ const methodName = dotMethodMatch[1];
809
+ const internalName = `_${methodName}`;
810
+ const methodCallLen = 2 + 1 + methodName.length + 1; // 'gg' + '.' + method + '('
811
+ const openParenPos = i + methodCallLen - 1;
812
+ const { line, col } = getLineCol(code, i);
813
+ const fnName = getFunctionName(i, range);
814
+ const callpoint = `${shortPath}${fnName ? `@${fnName}` : ''}`;
815
+ const escapedNs = escapeForString(callpoint);
816
+ const closeParenPos = findMatchingParen(code, openParenPos);
817
+ if (closeParenPos === -1) {
818
+ i += methodCallLen;
819
+ continue;
820
+ }
821
+ const argsText = code.slice(openParenPos + 1, closeParenPos).trim();
822
+ result.push(code.slice(lastIndex, i));
823
+ if (argsText === '') {
824
+ // gg.warn() → gg._warn(opts)
825
+ result.push(`gg.${internalName}(${buildOptions(range, escapedNs, line, col)})`);
826
+ lastIndex = closeParenPos + 1;
827
+ i = closeParenPos + 1;
828
+ }
829
+ else {
830
+ // gg.warn(expr) → gg._warn(opts, expr)
831
+ const escapedSrc = escapeForString(argsText);
832
+ result.push(`gg.${internalName}(${buildOptions(range, escapedNs, line, col, escapedSrc)}, `);
833
+ lastIndex = openParenPos + 1; // keep original args
834
+ i = openParenPos + 1;
835
+ }
836
+ modified = true;
837
+ continue;
838
+ }
839
+ // Skip other gg.* calls (gg.enable, gg.disable, gg._ns, gg._onLog, gg.time, etc.)
787
840
  if (code[i + 2] === '.') {
788
841
  i += 3;
789
842
  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' | '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,70 @@ export declare namespace gg {
101
110
  col?: number;
102
111
  src?: string;
103
112
  };
113
+ let warn: (...args: unknown[]) => unknown;
114
+ let error: (...args: unknown[]) => unknown;
115
+ let assert: (condition: unknown, ...args: unknown[]) => unknown;
116
+ let table: (data: unknown, columns?: string[]) => unknown;
117
+ let time: (label?: string) => void;
118
+ let timeLog: (label?: string, ...args: unknown[]) => void;
119
+ let timeEnd: (label?: string) => void;
120
+ let trace: (...args: unknown[]) => unknown;
121
+ let _warn: (options: {
122
+ ns: string;
123
+ file?: string;
124
+ line?: number;
125
+ col?: number;
126
+ src?: string;
127
+ }, ...args: unknown[]) => unknown;
128
+ let _error: (options: {
129
+ ns: string;
130
+ file?: string;
131
+ line?: number;
132
+ col?: number;
133
+ src?: string;
134
+ }, ...args: unknown[]) => unknown;
135
+ let _assert: (options: {
136
+ ns: string;
137
+ file?: string;
138
+ line?: number;
139
+ col?: number;
140
+ src?: string;
141
+ }, condition: unknown, ...args: unknown[]) => unknown;
142
+ let _table: (options: {
143
+ ns: string;
144
+ file?: string;
145
+ line?: number;
146
+ col?: number;
147
+ src?: string;
148
+ }, data: unknown, columns?: string[]) => unknown;
149
+ let _trace: (options: {
150
+ ns: string;
151
+ file?: string;
152
+ line?: number;
153
+ col?: number;
154
+ src?: string;
155
+ }, ...args: unknown[]) => unknown;
156
+ let _time: (options: {
157
+ ns: string;
158
+ file?: string;
159
+ line?: number;
160
+ col?: number;
161
+ src?: string;
162
+ }, label?: string) => void;
163
+ let _timeLog: (options: {
164
+ ns: string;
165
+ file?: string;
166
+ line?: number;
167
+ col?: number;
168
+ src?: string;
169
+ }, label?: string, ...args: unknown[]) => void;
170
+ let _timeEnd: (options: {
171
+ ns: string;
172
+ file?: string;
173
+ line?: number;
174
+ col?: number;
175
+ src?: string;
176
+ }, label?: string) => void;
104
177
  }
105
178
  /**
106
179
  * 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,13 @@ 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 warn/error
286
+ if (level === 'warn') {
287
+ logArgs[0] = `⚠️ ${logArgs[0]}`;
288
+ }
289
+ else if (level === 'error') {
290
+ logArgs[0] = `⛔ ${logArgs[0]}`;
291
+ }
336
292
  // Log to console via debug
337
293
  if (logArgs.length === 1) {
338
294
  ggLogFunction(logArgs[0]);
@@ -351,7 +307,10 @@ gg._ns = function (options, ...args) {
351
307
  file,
352
308
  line,
353
309
  col,
354
- src
310
+ src,
311
+ level,
312
+ stack,
313
+ tableData
355
314
  };
356
315
  if (_onLogCallback) {
357
316
  _onLogCallback(entry);
@@ -360,6 +319,21 @@ gg._ns = function (options, ...args) {
360
319
  earlyLogBuffer.push(entry);
361
320
  }
362
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);
363
337
  };
364
338
  /**
365
339
  * gg._o() - Internal: build options object for gg._ns() without object literal syntax.
@@ -390,6 +364,357 @@ gg.clearPersist = () => {
390
364
  }
391
365
  }
392
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
+ }
393
718
  /**
394
719
  * Parse color string to RGB values
395
720
  * 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.35",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"