@logtape/pretty 1.0.0-dev.231

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/formatter.ts ADDED
@@ -0,0 +1,1037 @@
1
+ import type {
2
+ LogLevel,
3
+ LogRecord,
4
+ TextFormatter,
5
+ TextFormatterOptions,
6
+ } from "@logtape/logtape";
7
+ import { inspect as nodeInspect } from "node:util";
8
+ import { getOptimalWordWrapWidth } from "./terminal.ts";
9
+ import { truncateCategory, type TruncationStrategy } from "./truncate.ts";
10
+ import { getDisplayWidth } from "./wcwidth.ts";
11
+ import { wrapText } from "./wordwrap.ts";
12
+
13
+ /**
14
+ * ANSI escape codes for styling
15
+ */
16
+ const RESET = "\x1b[0m";
17
+ const DIM = "\x1b[2m";
18
+
19
+ // Default true color values (referenced in JSDoc)
20
+ const defaultColors = {
21
+ trace: "rgb(167,139,250)", // Light purple
22
+ debug: "rgb(96,165,250)", // Light blue
23
+ info: "rgb(52,211,153)", // Emerald
24
+ warning: "rgb(251,191,36)", // Amber
25
+ error: "rgb(248,113,113)", // Light red
26
+ fatal: "rgb(220,38,38)", // Dark red
27
+ category: "rgb(100,116,139)", // Slate
28
+ message: "rgb(148,163,184)", // Light slate
29
+ timestamp: "rgb(100,116,139)", // Slate
30
+ } as const;
31
+
32
+ /**
33
+ * ANSI style codes
34
+ */
35
+ const styles = {
36
+ reset: RESET,
37
+ bold: "\x1b[1m",
38
+ dim: DIM,
39
+ italic: "\x1b[3m",
40
+ underline: "\x1b[4m",
41
+ strikethrough: "\x1b[9m",
42
+ } as const;
43
+
44
+ /**
45
+ * Standard ANSI colors (16-color)
46
+ */
47
+ const ansiColors = {
48
+ black: "\x1b[30m",
49
+ red: "\x1b[31m",
50
+ green: "\x1b[32m",
51
+ yellow: "\x1b[33m",
52
+ blue: "\x1b[34m",
53
+ magenta: "\x1b[35m",
54
+ cyan: "\x1b[36m",
55
+ white: "\x1b[37m",
56
+ } as const;
57
+
58
+ /**
59
+ * Color type definition
60
+ */
61
+ export type Color =
62
+ | keyof typeof ansiColors
63
+ | `rgb(${number},${number},${number})`
64
+ | `#${string}`
65
+ | null;
66
+
67
+ /**
68
+ * Category color mapping for prefix-based coloring.
69
+ *
70
+ * Maps category prefixes (as arrays) to colors. The formatter will match
71
+ * categories against these prefixes and use the corresponding color.
72
+ * Longer/more specific prefixes take precedence over shorter ones.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * new Map([
77
+ * [["app", "auth"], "#ff6b6b"], // app.auth.* -> red
78
+ * [["app", "db"], "#4ecdc4"], // app.db.* -> teal
79
+ * [["app"], "#45b7d1"], // app.* (fallback) -> blue
80
+ * [["lib"], "#96ceb4"], // lib.* -> green
81
+ * ])
82
+ * ```
83
+ */
84
+ export type CategoryColorMap = Map<readonly string[], Color>;
85
+
86
+ /**
87
+ * Internal representation of category prefix patterns
88
+ */
89
+ type CategoryPattern = {
90
+ prefix: readonly string[];
91
+ color: Color;
92
+ };
93
+
94
+ /**
95
+ * Style type definition - supports single styles, arrays of styles, or null
96
+ */
97
+ export type Style = keyof typeof styles | (keyof typeof styles)[] | null;
98
+
99
+ /**
100
+ * Helper function to convert color to ANSI escape code
101
+ */
102
+ function colorToAnsi(color: Color): string {
103
+ if (color === null) return "";
104
+ if (color in ansiColors) {
105
+ return ansiColors[color as keyof typeof ansiColors];
106
+ }
107
+ // Handle rgb() format
108
+ const rgbMatch = color.match(/^rgb\((\d+),(\d+),(\d+)\)$/);
109
+ if (rgbMatch) {
110
+ const [, r, g, b] = rgbMatch;
111
+ return `\x1b[38;2;${r};${g};${b}m`;
112
+ }
113
+ // Handle hex format (#rrggbb or #rgb)
114
+ const hexMatch = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
115
+ if (hexMatch) {
116
+ let hex = hexMatch[1];
117
+ // Convert 3-digit hex to 6-digit
118
+ if (hex.length === 3) {
119
+ hex = hex.split("").map((c) => c + c).join("");
120
+ }
121
+ const r = parseInt(hex.substr(0, 2), 16);
122
+ const g = parseInt(hex.substr(2, 2), 16);
123
+ const b = parseInt(hex.substr(4, 2), 16);
124
+ return `\x1b[38;2;${r};${g};${b}m`;
125
+ }
126
+ return "";
127
+ }
128
+
129
+ /**
130
+ * Helper function to convert style to ANSI escape code
131
+ */
132
+ function styleToAnsi(style: Style): string {
133
+ if (style === null) return "";
134
+ if (Array.isArray(style)) {
135
+ return style.map((s) => styles[s] || "").join("");
136
+ }
137
+ return styles[style] || "";
138
+ }
139
+
140
+ /**
141
+ * Converts a category color map to internal patterns and sorts them by specificity.
142
+ * More specific (longer) prefixes come first for proper matching precedence.
143
+ */
144
+ function prepareCategoryPatterns(
145
+ categoryColorMap: CategoryColorMap,
146
+ ): CategoryPattern[] {
147
+ const patterns: CategoryPattern[] = [];
148
+
149
+ for (const [prefix, color] of categoryColorMap) {
150
+ patterns.push({ prefix, color });
151
+ }
152
+
153
+ // Sort by prefix length (descending) for most-specific-first matching
154
+ return patterns.sort((a, b) => b.prefix.length - a.prefix.length);
155
+ }
156
+
157
+ /**
158
+ * Matches a category against category color patterns.
159
+ * Returns the color of the first matching pattern, or null if no match.
160
+ */
161
+ function matchCategoryColor(
162
+ category: readonly string[],
163
+ patterns: CategoryPattern[],
164
+ ): Color {
165
+ for (const pattern of patterns) {
166
+ if (categoryMatches(category, pattern.prefix)) {
167
+ return pattern.color;
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Checks if a category matches a prefix pattern.
175
+ * A category matches if it starts with all segments of the prefix.
176
+ */
177
+ function categoryMatches(
178
+ category: readonly string[],
179
+ prefix: readonly string[],
180
+ ): boolean {
181
+ if (prefix.length > category.length) {
182
+ return false;
183
+ }
184
+
185
+ for (let i = 0; i < prefix.length; i++) {
186
+ if (category[i] !== prefix[i]) {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Default icons for each log level
196
+ */
197
+ const defaultIcons: Record<LogLevel, string> = {
198
+ trace: "🔍",
199
+ debug: "🐛",
200
+ info: "✨",
201
+ warning: "⚡",
202
+ error: "❌",
203
+ fatal: "💀",
204
+ };
205
+
206
+ /**
207
+ * Normalize icon spacing to ensure consistent column alignment.
208
+ *
209
+ * All icons will be padded with spaces to match the width of the widest icon,
210
+ * ensuring consistent prefix alignment across all log levels.
211
+ *
212
+ * @param iconMap The icon mapping to normalize
213
+ * @returns A new icon map with consistent spacing
214
+ */
215
+ function normalizeIconSpacing(
216
+ iconMap: Record<LogLevel, string>,
217
+ ): Record<LogLevel, string> {
218
+ // Calculate the maximum display width among all icons
219
+ const maxWidth = Math.max(
220
+ ...Object.values(iconMap).map((icon) => getDisplayWidth(icon)),
221
+ );
222
+
223
+ // Normalize each icon to the maximum width by adding spaces
224
+ const normalizedMap: Record<LogLevel, string> = {} as Record<
225
+ LogLevel,
226
+ string
227
+ >;
228
+ for (
229
+ const [level, icon] of Object.entries(iconMap) as Array<[LogLevel, string]>
230
+ ) {
231
+ const currentWidth = getDisplayWidth(icon);
232
+ const spacesToAdd = maxWidth - currentWidth;
233
+ normalizedMap[level] = icon + " ".repeat(spacesToAdd);
234
+ }
235
+
236
+ return normalizedMap;
237
+ }
238
+
239
+ /**
240
+ * Platform-specific inspect function. Uses Node.js `util.inspect()` which
241
+ * is available in both Deno and Node.js environments. For browser environments,
242
+ * it falls back to {@link JSON.stringify}.
243
+ *
244
+ * @param value The value to inspect.
245
+ * @param options The options for inspecting the value.
246
+ * @returns The string representation of the value.
247
+ */
248
+ const inspect: (value: unknown, options?: { colors?: boolean }) => string =
249
+ // @ts-ignore: Browser detection
250
+ typeof document !== "undefined" ||
251
+ // @ts-ignore: React Native detection
252
+ typeof navigator !== "undefined" && navigator.product === "ReactNative"
253
+ ? (v) => JSON.stringify(v)
254
+ : (v, opts) =>
255
+ nodeInspect(v, {
256
+ maxArrayLength: 10,
257
+ maxStringLength: 80,
258
+ compact: true,
259
+ ...opts,
260
+ });
261
+
262
+ /**
263
+ * Configuration options for the pretty formatter.
264
+ *
265
+ * This interface extends the base text formatter options while providing
266
+ * extensive customization options for visual styling, layout control, and
267
+ * development-focused features. It offers granular control over colors,
268
+ * styles, and formatting similar to the ANSI color formatter.
269
+ *
270
+ * @since 1.0.0
271
+ */
272
+ export interface PrettyFormatterOptions
273
+ extends Omit<TextFormatterOptions, "category" | "value" | "format"> {
274
+ /**
275
+ * Color for timestamp display when timestamps are enabled.
276
+ *
277
+ * Supports true color RGB values, hex colors, or ANSI color names.
278
+ * Set to `null` to disable timestamp coloring.
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * timestampColor: "#888888" // Hex color
283
+ * timestampColor: "rgb(128,128,128)" // RGB color
284
+ * timestampColor: "cyan" // ANSI color name
285
+ * timestampColor: null // No color
286
+ * ```
287
+ *
288
+ * @default `"rgb(100,116,139)"` (slate gray)
289
+ */
290
+ timestampColor?: Color;
291
+
292
+ /**
293
+ * Visual style applied to timestamp text.
294
+ *
295
+ * Controls text appearance like boldness, dimming, etc.
296
+ * Supports single styles, multiple styles combined, or no styling.
297
+ * Combines with `timestampColor` for full styling control.
298
+ *
299
+ * @example
300
+ * ```typescript
301
+ * timestampStyle: "dim" // Single style: dimmed text
302
+ * timestampStyle: "bold" // Single style: bold text
303
+ * timestampStyle: ["bold", "underline"] // Multiple styles: bold + underlined
304
+ * timestampStyle: ["dim", "italic"] // Multiple styles: dimmed + italic
305
+ * timestampStyle: null // No styling
306
+ * ```
307
+ *
308
+ * @default `"dim"`
309
+ */
310
+ timestampStyle?: Style;
311
+
312
+ /**
313
+ * Custom colors for each log level.
314
+ *
315
+ * Allows fine-grained control over level appearance. Each level can have
316
+ * its own color scheme. Unspecified levels use built-in defaults.
317
+ * Set individual levels to `null` to disable coloring for that level.
318
+ *
319
+ * @example
320
+ * ```typescript
321
+ * levelColors: {
322
+ * info: "#00ff00", // Bright green for info
323
+ * error: "#ff0000", // Bright red for errors
324
+ * warning: "orange", // ANSI orange for warnings
325
+ * debug: null, // No color for debug
326
+ * }
327
+ * ```
328
+ *
329
+ * @default Built-in color scheme (purple trace, blue debug, green info, amber warning, red error, dark red fatal)
330
+ */
331
+ levelColors?: Partial<Record<LogLevel, Color>>;
332
+
333
+ /**
334
+ * Visual style applied to log level text.
335
+ *
336
+ * Controls the appearance of the level indicator (e.g., "info", "error").
337
+ * Supports single styles, multiple styles combined, or no styling.
338
+ * Applied in addition to level-specific colors.
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * levelStyle: "bold" // Single style: bold level text
343
+ * levelStyle: "underline" // Single style: underlined level text
344
+ * levelStyle: ["bold", "underline"] // Multiple styles: bold + underlined
345
+ * levelStyle: ["dim", "italic"] // Multiple styles: dimmed + italic
346
+ * levelStyle: null // No additional styling
347
+ * ```
348
+ *
349
+ * @default `null` (no additional styling)
350
+ */
351
+ levelStyle?: Style;
352
+
353
+ /**
354
+ * Icon configuration for each log level.
355
+ *
356
+ * Controls the emoji/symbol displayed before each log entry.
357
+ * Provides visual quick-identification of log severity.
358
+ *
359
+ * - `true`: Use built-in emoji set (🔍 trace, 🐛 debug, ✨ info, ⚠️ warning, ❌ error, 💀 fatal)
360
+ * - `false`: Disable all icons for clean text-only output
361
+ * - Object: Custom icon mapping, falls back to defaults for unspecified levels
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * icons: true // Use default emoji set
366
+ * icons: false // No icons
367
+ * icons: {
368
+ * info: "ℹ️", // Custom info icon
369
+ * error: "🔥", // Custom error icon
370
+ * warning: "⚡", // Custom warning icon
371
+ * }
372
+ * ```
373
+ *
374
+ * @default `true` (use default emoji icons)
375
+ */
376
+ icons?: boolean | Partial<Record<LogLevel, string>>;
377
+
378
+ /**
379
+ * Character(s) used to separate category hierarchy levels.
380
+ *
381
+ * Categories are hierarchical (e.g., ["app", "auth", "jwt"]) and this
382
+ * separator joins them for display (e.g., "app.auth.jwt").
383
+ *
384
+ * @example
385
+ * ```typescript
386
+ * categorySeparator: "·" // app·auth·jwt
387
+ * categorySeparator: "." // app.auth.jwt
388
+ * categorySeparator: ":" // app:auth:jwt
389
+ * categorySeparator: " > " // app > auth > jwt
390
+ * categorySeparator: "::" // app::auth::jwt
391
+ * ```
392
+ *
393
+ * @default `"·"` (interpunct)
394
+ */
395
+ categorySeparator?: string;
396
+
397
+ /**
398
+ * Default color for category display.
399
+ *
400
+ * Used as fallback when no specific color is found in `categoryColorMap`.
401
+ * Controls the visual appearance of the category hierarchy display.
402
+ *
403
+ * @example
404
+ * ```typescript
405
+ * categoryColor: "#666666" // Gray categories
406
+ * categoryColor: "blue" // Blue categories
407
+ * categoryColor: "rgb(100,150,200)" // Light blue categories
408
+ * categoryColor: null // No coloring
409
+ * ```
410
+ *
411
+ * @default `"rgb(100,116,139)"` (slate gray)
412
+ */
413
+ categoryColor?: Color;
414
+
415
+ /**
416
+ * Category-specific color mapping based on prefixes.
417
+ *
418
+ * Maps category prefixes (as arrays) to colors for visual grouping.
419
+ * More specific (longer) prefixes take precedence over shorter ones.
420
+ * If no prefix matches, falls back to the default `categoryColor`.
421
+ *
422
+ * @example
423
+ * ```typescript
424
+ * new Map([
425
+ * [["app", "auth"], "#ff6b6b"], // app.auth.* -> red
426
+ * [["app", "db"], "#4ecdc4"], // app.db.* -> teal
427
+ * [["app"], "#45b7d1"], // app.* (fallback) -> blue
428
+ * [["lib"], "#96ceb4"], // lib.* -> green
429
+ * ])
430
+ * ```
431
+ */
432
+ categoryColorMap?: CategoryColorMap;
433
+
434
+ /**
435
+ * Visual style applied to category text.
436
+ *
437
+ * Controls the appearance of the category hierarchy display.
438
+ * Supports single styles, multiple styles combined, or no styling.
439
+ * Applied in addition to category colors from `categoryColor` or `categoryColorMap`.
440
+ *
441
+ * @example
442
+ * ```typescript
443
+ * categoryStyle: "dim" // Single style: dimmed category text
444
+ * categoryStyle: "italic" // Single style: italic category text
445
+ * categoryStyle: ["dim", "italic"] // Multiple styles: dimmed + italic
446
+ * categoryStyle: ["bold", "underline"] // Multiple styles: bold + underlined
447
+ * categoryStyle: null // No additional styling
448
+ * ```
449
+ *
450
+ * @default `["dim", "italic"]` (dimmed for subtle appearance)
451
+ */
452
+ categoryStyle?: Style;
453
+
454
+ /**
455
+ * Maximum display width for category names.
456
+ *
457
+ * Controls layout consistency by limiting category width.
458
+ * Long categories are truncated according to `categoryTruncate` strategy.
459
+ *
460
+ * - Number: Fixed character width limit
461
+ * - `"auto"`: No width limit, categories display at full length
462
+ *
463
+ * @example
464
+ * ```typescript
465
+ * categoryWidth: 20 // Limit to 20 characters
466
+ * categoryWidth: 30 // Limit to 30 characters
467
+ * categoryWidth: "auto" // No limit
468
+ * ```
469
+ *
470
+ * @default `20` (20 character limit)
471
+ */
472
+ categoryWidth?: number | "auto";
473
+
474
+ /**
475
+ * Strategy for truncating long category names.
476
+ *
477
+ * When categories exceed `categoryWidth`, this controls how truncation works.
478
+ * Smart truncation preserves important context while maintaining layout.
479
+ *
480
+ * - `"middle"`: Keep first and last parts (e.g., "app.server…auth.jwt")
481
+ * - `"end"`: Truncate at the end (e.g., "app.server.middleware…")
482
+ * - `false`: No truncation (ignores `categoryWidth`)
483
+ *
484
+ * @example
485
+ * ```typescript
486
+ * categoryTruncate: "middle" // app.server…jwt (preserves context)
487
+ * categoryTruncate: "end" // app.server.midd… (linear truncation)
488
+ * categoryTruncate: false // app.server.middleware.auth.jwt (full)
489
+ * ```
490
+ *
491
+ * @default `"middle"` (smart context-preserving truncation)
492
+ */
493
+ categoryTruncate?: TruncationStrategy;
494
+
495
+ /**
496
+ * Color for log message text content.
497
+ *
498
+ * Controls the visual appearance of the actual log message content.
499
+ * Does not affect structured values, which use syntax highlighting.
500
+ *
501
+ * @example
502
+ * ```typescript
503
+ * messageColor: "#ffffff" // White message text
504
+ * messageColor: "green" // Green message text
505
+ * messageColor: "rgb(200,200,200)" // Light gray message text
506
+ * messageColor: null // No coloring
507
+ * ```
508
+ *
509
+ * @default `"rgb(148,163,184)"` (light slate gray)
510
+ */
511
+ messageColor?: Color;
512
+
513
+ /**
514
+ * Visual style applied to log message text.
515
+ *
516
+ * Controls the appearance of the log message content.
517
+ * Supports single styles, multiple styles combined, or no styling.
518
+ * Applied in addition to `messageColor`.
519
+ *
520
+ * @example
521
+ * ```typescript
522
+ * messageStyle: "dim" // Single style: dimmed message text
523
+ * messageStyle: "italic" // Single style: italic message text
524
+ * messageStyle: ["dim", "italic"] // Multiple styles: dimmed + italic
525
+ * messageStyle: ["bold", "underline"] // Multiple styles: bold + underlined
526
+ * messageStyle: null // No additional styling
527
+ * ```
528
+ *
529
+ * @default `"dim"` (dimmed for subtle readability)
530
+ */
531
+ messageStyle?: Style;
532
+
533
+ /**
534
+ * Global color control for the entire formatter.
535
+ *
536
+ * Master switch to enable/disable all color output.
537
+ * When disabled, produces clean monochrome output suitable for
538
+ * non-color terminals or when colors are not desired.
539
+ *
540
+ * @example
541
+ * ```typescript
542
+ * colors: true // Full color output (default)
543
+ * colors: false // Monochrome output only
544
+ * ```
545
+ *
546
+ * @default `true` (colors enabled)
547
+ */
548
+ colors?: boolean;
549
+
550
+ /**
551
+ * Column alignment for consistent visual layout.
552
+ *
553
+ * When enabled, ensures all log components (icons, levels, categories)
554
+ * align consistently across multiple log entries, creating a clean
555
+ * tabular appearance.
556
+ *
557
+ * @example
558
+ * ```typescript
559
+ * align: true // Aligned columns (default)
560
+ * align: false // Compact, non-aligned output
561
+ * ```
562
+ *
563
+ * @default `true` (alignment enabled)
564
+ */
565
+ align?: boolean;
566
+
567
+ /**
568
+ * Configuration for structured value inspection and rendering.
569
+ *
570
+ * Controls how objects, arrays, and other complex values are displayed
571
+ * within log messages. Uses Node.js `util.inspect()` style options.
572
+ *
573
+ * @example
574
+ * ```typescript
575
+ * inspectOptions: {
576
+ * depth: 3, // Show 3 levels of nesting
577
+ * colors: false, // Disable value syntax highlighting
578
+ * compact: true, // Use compact object display
579
+ * }
580
+ * ```
581
+ *
582
+ * @default `{}` (use built-in defaults: depth=unlimited, colors=auto, compact=true)
583
+ */
584
+ inspectOptions?: {
585
+ /**
586
+ * Maximum depth to traverse when inspecting nested objects.
587
+ * @default Infinity (no depth limit)
588
+ */
589
+ depth?: number;
590
+ /**
591
+ * Whether to use syntax highlighting colors for inspected values.
592
+ * @default Inherited from global `colors` setting
593
+ */
594
+ colors?: boolean;
595
+ /**
596
+ * Whether to use compact formatting for objects and arrays.
597
+ * @default `true` (compact formatting)
598
+ */
599
+ compact?: boolean;
600
+ };
601
+
602
+ /**
603
+ * Enable word wrapping for long messages.
604
+ *
605
+ * When enabled, long messages will be wrapped at the specified width,
606
+ * with continuation lines aligned to the message column position.
607
+ *
608
+ * - `true`: Auto-detect terminal width when attached to a terminal,
609
+ * fallback to 80 columns when not in a terminal or detection fails
610
+ * - `number`: Use the specified width in columns
611
+ * - `false`: Disable word wrapping
612
+ *
613
+ * @example
614
+ * ```typescript
615
+ * // Auto-detect terminal width (recommended)
616
+ * wordWrap: true
617
+ *
618
+ * // Custom wrap width
619
+ * wordWrap: 120
620
+ *
621
+ * // Disable word wrapping (default)
622
+ * wordWrap: false
623
+ * ```
624
+ *
625
+ * @default `false` (no word wrapping)
626
+ * @since 1.0.0
627
+ */
628
+ wordWrap?: boolean | number;
629
+ }
630
+
631
+ /**
632
+ * Creates a beautiful console formatter optimized for local development.
633
+ *
634
+ * This formatter provides a Signale-inspired visual design with colorful icons,
635
+ * smart category truncation, dimmed styling, and perfect column alignment.
636
+ * It's specifically designed for development environments that support true colors
637
+ * and Unicode characters.
638
+ *
639
+ * The formatter features:
640
+ * - Emoji icons for each log level (🔍 trace, 🐛 debug, ✨ info, etc.)
641
+ * - True color support with rich color schemes
642
+ * - Intelligent category truncation for long hierarchical categories
643
+ * - Optional timestamp display with multiple formats
644
+ * - Configurable alignment and styling options
645
+ * - Enhanced value rendering with syntax highlighting
646
+ *
647
+ * @param options Configuration options for customizing the formatter behavior.
648
+ * @returns A text formatter function that can be used with LogTape sinks.
649
+ *
650
+ * @example
651
+ * ```typescript
652
+ * import { configure } from "@logtape/logtape";
653
+ * import { getConsoleSink } from "@logtape/logtape";
654
+ * import { getPrettyFormatter } from "@logtape/pretty";
655
+ *
656
+ * await configure({
657
+ * sinks: {
658
+ * console: getConsoleSink({
659
+ * formatter: getPrettyFormatter({
660
+ * timestamp: "time",
661
+ * categoryWidth: 25,
662
+ * icons: {
663
+ * info: "📘",
664
+ * error: "🔥"
665
+ * }
666
+ * })
667
+ * })
668
+ * }
669
+ * });
670
+ * ```
671
+ *
672
+ * @since 1.0.0
673
+ */
674
+ export function getPrettyFormatter(
675
+ options: PrettyFormatterOptions = {},
676
+ ): TextFormatter {
677
+ // Extract options with defaults
678
+ const {
679
+ timestamp = "none",
680
+ timestampColor = "rgb(100,116,139)",
681
+ timestampStyle = "dim",
682
+ level: levelFormat = "full",
683
+ levelColors = {},
684
+ levelStyle = "underline",
685
+ icons = true,
686
+ categorySeparator = "·",
687
+ categoryColor = "rgb(100,116,139)",
688
+ categoryColorMap = new Map(),
689
+ categoryStyle = ["dim", "italic"],
690
+ categoryWidth = 20,
691
+ categoryTruncate = "middle",
692
+ messageColor = "rgb(148,163,184)",
693
+ messageStyle = "dim",
694
+ colors: useColors = true,
695
+ align = true,
696
+ inspectOptions = {},
697
+ wordWrap = false,
698
+ } = options;
699
+
700
+ // Resolve icons
701
+ const baseIconMap: Record<LogLevel, string> = icons === false
702
+ ? { trace: "", debug: "", info: "", warning: "", error: "", fatal: "" }
703
+ : icons === true
704
+ ? defaultIcons
705
+ : { ...defaultIcons, ...(icons as Partial<Record<LogLevel, string>>) };
706
+
707
+ // Normalize icon spacing for consistent alignment
708
+ const iconMap = normalizeIconSpacing(baseIconMap);
709
+
710
+ // Resolve level colors with defaults
711
+ const resolvedLevelColors: Record<LogLevel, Color> = {
712
+ trace: defaultColors.trace,
713
+ debug: defaultColors.debug,
714
+ info: defaultColors.info,
715
+ warning: defaultColors.warning,
716
+ error: defaultColors.error,
717
+ fatal: defaultColors.fatal,
718
+ ...levelColors,
719
+ };
720
+
721
+ // Level formatter function
722
+ const formatLevel = (level: LogLevel): string => {
723
+ if (typeof levelFormat === "function") {
724
+ return levelFormat(level);
725
+ }
726
+ switch (levelFormat) {
727
+ case "ABBR":
728
+ return {
729
+ trace: "TRC",
730
+ debug: "DBG",
731
+ info: "INF",
732
+ warning: "WRN",
733
+ error: "ERR",
734
+ fatal: "FTL",
735
+ }[level];
736
+ case "FULL":
737
+ return level.toUpperCase();
738
+ case "L":
739
+ return {
740
+ trace: "T",
741
+ debug: "D",
742
+ info: "I",
743
+ warning: "W",
744
+ error: "E",
745
+ fatal: "F",
746
+ }[level];
747
+ case "abbr":
748
+ return {
749
+ trace: "trc",
750
+ debug: "dbg",
751
+ info: "inf",
752
+ warning: "wrn",
753
+ error: "err",
754
+ fatal: "ftl",
755
+ }[level];
756
+ case "full":
757
+ return level;
758
+ case "l":
759
+ return {
760
+ trace: "t",
761
+ debug: "d",
762
+ info: "i",
763
+ warning: "w",
764
+ error: "e",
765
+ fatal: "f",
766
+ }[level];
767
+ default:
768
+ return level;
769
+ }
770
+ };
771
+
772
+ // Resolve timestamp formatter - support all TextFormatterOptions formats
773
+ let timestampFn: ((ts: number) => string | null) | null = null;
774
+ if (timestamp === "none" || timestamp === "disabled") {
775
+ timestampFn = null;
776
+ } else if (timestamp === "date-time-timezone") {
777
+ timestampFn = (ts: number) => {
778
+ const date = new Date(ts);
779
+ return date.toISOString().replace("T", " ").replace("Z", " +00:00");
780
+ };
781
+ } else if (timestamp === "date-time-tz") {
782
+ timestampFn = (ts: number) => {
783
+ const date = new Date(ts);
784
+ return date.toISOString().replace("T", " ").replace("Z", " +00");
785
+ };
786
+ } else if (timestamp === "date-time") {
787
+ timestampFn = (ts: number) => {
788
+ const date = new Date(ts);
789
+ return date.toISOString().replace("T", " ").replace("Z", "");
790
+ };
791
+ } else if (timestamp === "time-timezone") {
792
+ timestampFn = (ts: number) => {
793
+ const date = new Date(ts);
794
+ return date.toISOString().replace(/.*T/, "").replace("Z", " +00:00");
795
+ };
796
+ } else if (timestamp === "time-tz") {
797
+ timestampFn = (ts: number) => {
798
+ const date = new Date(ts);
799
+ return date.toISOString().replace(/.*T/, "").replace("Z", " +00");
800
+ };
801
+ } else if (timestamp === "time") {
802
+ timestampFn = (ts: number) => {
803
+ const date = new Date(ts);
804
+ return date.toISOString().replace(/.*T/, "").replace("Z", "");
805
+ };
806
+ } else if (timestamp === "date") {
807
+ timestampFn = (ts: number) => {
808
+ const date = new Date(ts);
809
+ return date.toISOString().replace(/T.*/, "");
810
+ };
811
+ } else if (timestamp === "rfc3339") {
812
+ timestampFn = (ts: number) => {
813
+ const date = new Date(ts);
814
+ return date.toISOString();
815
+ };
816
+ } else if (typeof timestamp === "function") {
817
+ timestampFn = timestamp;
818
+ }
819
+
820
+ // Configure word wrap settings
821
+ const wordWrapEnabled = wordWrap !== false;
822
+ let wordWrapWidth: number;
823
+
824
+ if (typeof wordWrap === "number") {
825
+ wordWrapWidth = wordWrap;
826
+ } else if (wordWrap === true) {
827
+ // Auto-detect terminal width
828
+ wordWrapWidth = getOptimalWordWrapWidth(80);
829
+ } else {
830
+ wordWrapWidth = 80; // Default fallback
831
+ }
832
+
833
+ // Prepare category color patterns for matching
834
+ const categoryPatterns = prepareCategoryPatterns(categoryColorMap);
835
+
836
+ // Calculate level width based on format
837
+ const allLevels: LogLevel[] = [
838
+ "trace",
839
+ "debug",
840
+ "info",
841
+ "warning",
842
+ "error",
843
+ "fatal",
844
+ ];
845
+ const levelWidth = Math.max(...allLevels.map((l) => formatLevel(l).length));
846
+
847
+ return (record: LogRecord): string => {
848
+ // Calculate the prefix parts first to determine message column position
849
+ const icon = iconMap[record.level] || "";
850
+ const level = formatLevel(record.level);
851
+ const categoryStr = truncateCategory(
852
+ record.category,
853
+ typeof categoryWidth === "number" ? categoryWidth : 30,
854
+ categorySeparator,
855
+ categoryTruncate,
856
+ );
857
+
858
+ // Format message with values - handle color reset/reapply for interpolated values
859
+ let message = "";
860
+ const messageColorCode = useColors ? colorToAnsi(messageColor) : "";
861
+ const messageStyleCode = useColors ? styleToAnsi(messageStyle) : "";
862
+ const messagePrefix = useColors
863
+ ? `${messageStyleCode}${messageColorCode}`
864
+ : "";
865
+
866
+ for (let i = 0; i < record.message.length; i++) {
867
+ if (i % 2 === 0) {
868
+ message += record.message[i];
869
+ } else {
870
+ const value = record.message[i];
871
+ const inspected = inspect(value, {
872
+ colors: useColors,
873
+ ...inspectOptions,
874
+ });
875
+
876
+ // Handle multiline interpolated values properly
877
+ if (inspected.includes("\n")) {
878
+ const lines = inspected.split("\n");
879
+
880
+ const formattedLines = lines.map((line, index) => {
881
+ if (index === 0) {
882
+ // First line: reset formatting, add the line, then reapply
883
+ if (useColors && (messageColorCode || messageStyleCode)) {
884
+ return `${RESET}${line}${messagePrefix}`;
885
+ } else {
886
+ return line;
887
+ }
888
+ } else {
889
+ // Continuation lines: just apply formatting, let wrapText handle indentation
890
+ if (useColors && (messageColorCode || messageStyleCode)) {
891
+ return `${line}${messagePrefix}`;
892
+ } else {
893
+ return line;
894
+ }
895
+ }
896
+ });
897
+ message += formattedLines.join("\n");
898
+ } else {
899
+ // Single line - handle normally
900
+ if (useColors && (messageColorCode || messageStyleCode)) {
901
+ message += `${RESET}${inspected}${messagePrefix}`;
902
+ } else {
903
+ message += inspected;
904
+ }
905
+ }
906
+ }
907
+ }
908
+
909
+ // Parts are already calculated above
910
+
911
+ // Determine category color (with prefix matching)
912
+ const finalCategoryColor = useColors
913
+ ? (matchCategoryColor(record.category, categoryPatterns) || categoryColor)
914
+ : null;
915
+
916
+ // Apply colors and styling
917
+ const formattedIcon = icon;
918
+ let formattedLevel = level;
919
+ let formattedCategory = categoryStr;
920
+ let formattedMessage = message;
921
+ let formattedTimestamp = "";
922
+
923
+ if (useColors) {
924
+ // Apply level color and style
925
+ const levelColorCode = colorToAnsi(resolvedLevelColors[record.level]);
926
+ const levelStyleCode = styleToAnsi(levelStyle);
927
+ formattedLevel = `${levelStyleCode}${levelColorCode}${level}${RESET}`;
928
+
929
+ // Apply category color and style (with prefix matching)
930
+ const categoryColorCode = colorToAnsi(finalCategoryColor);
931
+ const categoryStyleCode = styleToAnsi(categoryStyle);
932
+ formattedCategory =
933
+ `${categoryStyleCode}${categoryColorCode}${categoryStr}${RESET}`;
934
+
935
+ // Apply message color and style (already handled in message building above)
936
+ formattedMessage = `${messagePrefix}${message}${RESET}`;
937
+ }
938
+
939
+ // Format timestamp if needed
940
+ if (timestampFn) {
941
+ const ts = timestampFn(record.timestamp);
942
+ if (ts !== null) {
943
+ if (useColors) {
944
+ const timestampColorCode = colorToAnsi(timestampColor);
945
+ const timestampStyleCode = styleToAnsi(timestampStyle);
946
+ formattedTimestamp =
947
+ `${timestampStyleCode}${timestampColorCode}${ts}${RESET} `;
948
+ } else {
949
+ formattedTimestamp = `${ts} `;
950
+ }
951
+ }
952
+ }
953
+
954
+ // Build the final output with alignment
955
+ if (align) {
956
+ // Calculate padding accounting for ANSI escape sequences
957
+ const levelColorLength = useColors
958
+ ? (colorToAnsi(resolvedLevelColors[record.level]).length +
959
+ styleToAnsi(levelStyle).length + RESET.length)
960
+ : 0;
961
+ const categoryColorLength = useColors
962
+ ? (colorToAnsi(finalCategoryColor).length +
963
+ styleToAnsi(categoryStyle).length + RESET.length)
964
+ : 0;
965
+
966
+ const paddedLevel = formattedLevel.padEnd(levelWidth + levelColorLength);
967
+ const paddedCategory = formattedCategory.padEnd(
968
+ (typeof categoryWidth === "number" ? categoryWidth : 30) +
969
+ categoryColorLength,
970
+ );
971
+
972
+ let result =
973
+ `${formattedTimestamp}${formattedIcon} ${paddedLevel} ${paddedCategory} ${formattedMessage}`;
974
+
975
+ // Apply word wrapping if enabled, or if there are multiline interpolated values
976
+ if (wordWrapEnabled || message.includes("\n")) {
977
+ result = wrapText(
978
+ result,
979
+ wordWrapEnabled ? wordWrapWidth : Infinity,
980
+ message,
981
+ );
982
+ }
983
+
984
+ return result + "\n";
985
+ } else {
986
+ let result =
987
+ `${formattedTimestamp}${formattedIcon} ${formattedLevel} ${formattedCategory} ${formattedMessage}`;
988
+
989
+ // Apply word wrapping if enabled, or if there are multiline interpolated values
990
+ if (wordWrapEnabled || message.includes("\n")) {
991
+ result = wrapText(
992
+ result,
993
+ wordWrapEnabled ? wordWrapWidth : Infinity,
994
+ message,
995
+ );
996
+ }
997
+
998
+ return result + "\n";
999
+ }
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * A pre-configured beautiful console formatter for local development.
1005
+ *
1006
+ * This is a ready-to-use instance of the pretty formatter with sensible defaults
1007
+ * for most development scenarios. It provides immediate visual enhancement to
1008
+ * your logs without requiring any configuration.
1009
+ *
1010
+ * Features enabled by default:
1011
+ * - Emoji icons for all log levels
1012
+ * - True color support with rich color schemes
1013
+ * - Dimmed text styling for better readability
1014
+ * - Smart category truncation (20 characters max)
1015
+ * - Perfect column alignment
1016
+ * - No timestamp display (cleaner for development)
1017
+ *
1018
+ * For custom configuration, use {@link getPrettyFormatter} instead.
1019
+ *
1020
+ * @example
1021
+ * ```typescript
1022
+ * import { configure } from "@logtape/logtape";
1023
+ * import { getConsoleSink } from "@logtape/logtape";
1024
+ * import { prettyFormatter } from "@logtape/pretty";
1025
+ *
1026
+ * await configure({
1027
+ * sinks: {
1028
+ * console: getConsoleSink({
1029
+ * formatter: prettyFormatter
1030
+ * })
1031
+ * }
1032
+ * });
1033
+ * ```
1034
+ *
1035
+ * @since 1.0.0
1036
+ */
1037
+ export const prettyFormatter: TextFormatter = getPrettyFormatter();