UNPKG

@logtape/pretty

Version:

Beautiful text formatter for LogTapeβ€”perfect for local development

401 lines (399 loc) β€’ 14.6 kB
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs'); const require_terminal = require('./terminal.cjs'); const require_truncate = require('./truncate.cjs'); const require_wcwidth = require('./wcwidth.cjs'); const require_wordwrap = require('./wordwrap.cjs'); const __logtape_logtape = require_rolldown_runtime.__toESM(require("@logtape/logtape")); const __util = require_rolldown_runtime.__toESM(require("#util")); //#region src/formatter.ts /** * ANSI escape codes for styling */ const RESET = "\x1B[0m"; const DIM = "\x1B[2m"; const defaultColors = { trace: "rgb(167,139,250)", debug: "rgb(96,165,250)", info: "rgb(52,211,153)", warning: "rgb(251,191,36)", error: "rgb(248,113,113)", fatal: "rgb(220,38,38)", category: "rgb(100,116,139)", message: "rgb(148,163,184)", timestamp: "rgb(100,116,139)" }; /** * ANSI style codes */ const styles = { reset: RESET, bold: "\x1B[1m", dim: DIM, italic: "\x1B[3m", underline: "\x1B[4m", strikethrough: "\x1B[9m" }; /** * Standard ANSI colors (16-color) */ const ansiColors = { black: "\x1B[30m", red: "\x1B[31m", green: "\x1B[32m", yellow: "\x1B[33m", blue: "\x1B[34m", magenta: "\x1B[35m", cyan: "\x1B[36m", white: "\x1B[37m" }; const RGB_PATTERN = /^rgb\((\d+),(\d+),(\d+)\)$/; const HEX_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; /** * Helper function to convert color to ANSI escape code */ function colorToAnsi(color) { if (color === null) return ""; if (color in ansiColors) return ansiColors[color]; const rgbMatch = color.match(RGB_PATTERN); if (rgbMatch) { const [, r, g, b] = rgbMatch; return `\x1b[38;2;${r};${g};${b}m`; } const hexMatch = color.match(HEX_PATTERN); if (hexMatch) { let hex = hexMatch[1]; if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); return `\x1b[38;2;${r};${g};${b}m`; } return ""; } /** * Helper function to convert style to ANSI escape code */ function styleToAnsi(style) { if (style === null) return ""; if (Array.isArray(style)) return style.map((s) => styles[s] || "").join(""); return styles[style] || ""; } /** * Converts a category color map to internal patterns and sorts them by specificity. * More specific (longer) prefixes come first for proper matching precedence. */ function prepareCategoryPatterns(categoryColorMap) { const patterns = []; for (const [prefix, color] of categoryColorMap) patterns.push({ prefix, color }); return patterns.sort((a, b) => b.prefix.length - a.prefix.length); } /** * Matches a category against category color patterns. * Returns the color of the first matching pattern, or null if no match. */ function matchCategoryColor(category, patterns) { for (const pattern of patterns) if (categoryMatches(category, pattern.prefix)) return pattern.color; return null; } /** * Checks if a category matches a prefix pattern. * A category matches if it starts with all segments of the prefix. */ function categoryMatches(category, prefix) { if (prefix.length > category.length) return false; for (let i = 0; i < prefix.length; i++) if (category[i] !== prefix[i]) return false; return true; } /** * Default icons for each log level */ const defaultIcons = { trace: "πŸ”", debug: "πŸ›", info: "✨", warning: "⚑", error: "❌", fatal: "πŸ’€" }; /** * Normalize icon spacing to ensure consistent column alignment. * * All icons will be padded with spaces to match the width of the widest icon, * ensuring consistent prefix alignment across all log levels. * * @param iconMap The icon mapping to normalize * @returns A new icon map with consistent spacing */ function normalizeIconSpacing(iconMap) { const entries = Object.entries(iconMap); const maxWidth = Math.max(...entries.map(([, icon]) => require_wcwidth.getDisplayWidth(icon))); return Object.fromEntries(entries.map(([level, icon]) => [level, icon + " ".repeat(maxWidth - require_wcwidth.getDisplayWidth(icon))])); } /** * Creates a beautiful console formatter optimized for local development. * * This formatter provides a Signale-inspired visual design with colorful icons, * smart category truncation, dimmed styling, and perfect column alignment. * It's specifically designed for development environments that support true colors * and Unicode characters. * * The formatter features: * - Emoji icons for each log level (πŸ” trace, πŸ› debug, ✨ info, etc.) * - True color support with rich color schemes * - Intelligent category truncation for long hierarchical categories * - Optional timestamp display with multiple formats * - Configurable alignment and styling options * - Enhanced value rendering with syntax highlighting * * @param options Configuration options for customizing the formatter behavior. * @returns A text formatter function that can be used with LogTape sinks. * * @example * ```typescript * import { configure } from "@logtape/logtape"; * import { getConsoleSink } from "@logtape/logtape/sink"; * import { getPrettyFormatter } from "@logtape/pretty"; * * await configure({ * sinks: { * console: getConsoleSink({ * formatter: getPrettyFormatter({ * timestamp: "time", * categoryWidth: 25, * icons: { * info: "πŸ“˜", * error: "πŸ”₯" * } * }) * }) * } * }); * ``` * * @since 1.0.0 */ function getPrettyFormatter(options = {}) { const { timestamp = "none", timestampColor = "rgb(100,116,139)", timestampStyle = "dim", level: levelFormat = "full", levelColors = {}, levelStyle = "underline", icons = true, categorySeparator = "Β·", categoryColor = "rgb(100,116,139)", categoryColorMap = /* @__PURE__ */ new Map(), categoryStyle = ["dim", "italic"], categoryWidth = 20, categoryTruncate = "middle", messageColor = "rgb(148,163,184)", messageStyle = "dim", colors: useColors = true, align = true, inspectOptions = {}, properties = false, wordWrap = true } = options; const baseIconMap = icons === false ? { trace: "", debug: "", info: "", warning: "", error: "", fatal: "" } : icons === true ? defaultIcons : { ...defaultIcons, ...icons }; const iconMap = normalizeIconSpacing(baseIconMap); const resolvedLevelColors = { trace: defaultColors.trace, debug: defaultColors.debug, info: defaultColors.info, warning: defaultColors.warning, error: defaultColors.error, fatal: defaultColors.fatal, ...levelColors }; const levelMappings = { "ABBR": { trace: "TRC", debug: "DBG", info: "INF", warning: "WRN", error: "ERR", fatal: "FTL" }, "L": { trace: "T", debug: "D", info: "I", warning: "W", error: "E", fatal: "F" }, "abbr": { trace: "trc", debug: "dbg", info: "inf", warning: "wrn", error: "err", fatal: "ftl" }, "l": { trace: "t", debug: "d", info: "i", warning: "w", error: "e", fatal: "f" } }; const formatLevel = (level) => { if (typeof levelFormat === "function") return levelFormat(level); if (levelFormat === "FULL") return level.toUpperCase(); if (levelFormat === "full") return level; return levelMappings[levelFormat]?.[level] ?? level; }; const timestampFormatters = { "date-time-timezone": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace("T", " ").replace("Z", " +00:00"); }, "date-time-tz": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace("T", " ").replace("Z", " +00"); }, "date-time": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace("T", " ").replace("Z", ""); }, "time-timezone": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace(/.*T/, "").replace("Z", " +00:00"); }, "time-tz": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace(/.*T/, "").replace("Z", " +00"); }, "time": (ts) => { const iso = new Date(ts).toISOString(); return iso.replace(/.*T/, "").replace("Z", ""); }, "date": (ts) => new Date(ts).toISOString().replace(/T.*/, ""), "rfc3339": (ts) => new Date(ts).toISOString() }; let timestampFn = null; if (timestamp === "none" || timestamp === "disabled") timestampFn = null; else if (typeof timestamp === "function") timestampFn = timestamp; else timestampFn = timestampFormatters[timestamp] ?? null; const wordWrapEnabled = wordWrap !== false; let wordWrapWidth; if (typeof wordWrap === "number") wordWrapWidth = wordWrap; else if (wordWrap === true) wordWrapWidth = require_terminal.getOptimalWordWrapWidth(80); else wordWrapWidth = 80; const categoryPatterns = prepareCategoryPatterns(categoryColorMap); const allLevels = [...(0, __logtape_logtape.getLogLevels)()]; const levelWidth = Math.max(...allLevels.map((l) => formatLevel(l).length)); return (record) => { const icon = iconMap[record.level] || ""; const level = formatLevel(record.level); const categoryStr = require_truncate.truncateCategory(record.category, categoryWidth, categorySeparator, categoryTruncate); let message = ""; const messageColorCode = useColors ? colorToAnsi(messageColor) : ""; const messageStyleCode = useColors ? styleToAnsi(messageStyle) : ""; const messagePrefix = useColors ? `${messageStyleCode}${messageColorCode}` : ""; for (let i = 0; i < record.message.length; i++) if (i % 2 === 0) message += record.message[i]; else { const value = record.message[i]; const inspected = (0, __util.inspect)(value, { colors: useColors, ...inspectOptions }); if (inspected.includes("\n")) { const lines = inspected.split("\n"); const formattedLines = lines.map((line, index) => { if (index === 0) if (useColors && (messageColorCode || messageStyleCode)) return `${RESET}${line}${messagePrefix}`; else return line; else if (useColors && (messageColorCode || messageStyleCode)) return `${line}${messagePrefix}`; else return line; }); message += formattedLines.join("\n"); } else if (useColors && (messageColorCode || messageStyleCode)) message += `${RESET}${inspected}${messagePrefix}`; else message += inspected; } const finalCategoryColor = useColors ? matchCategoryColor(record.category, categoryPatterns) || categoryColor : null; const formattedIcon = icon; let formattedLevel = level; let formattedCategory = categoryStr; let formattedMessage = message; let formattedTimestamp = ""; if (useColors) { const levelColorCode = colorToAnsi(resolvedLevelColors[record.level]); const levelStyleCode = styleToAnsi(levelStyle); formattedLevel = `${levelStyleCode}${levelColorCode}${level}${RESET}`; const categoryColorCode = colorToAnsi(finalCategoryColor); const categoryStyleCode = styleToAnsi(categoryStyle); formattedCategory = `${categoryStyleCode}${categoryColorCode}${categoryStr}${RESET}`; formattedMessage = `${messagePrefix}${message}${RESET}`; } if (timestampFn) { const ts = timestampFn(record.timestamp); if (ts !== null) if (useColors) { const timestampColorCode = colorToAnsi(timestampColor); const timestampStyleCode = styleToAnsi(timestampStyle); formattedTimestamp = `${timestampStyleCode}${timestampColorCode}${ts}${RESET} `; } else formattedTimestamp = `${ts} `; } if (align) { const levelColorLength = useColors ? colorToAnsi(resolvedLevelColors[record.level]).length + styleToAnsi(levelStyle).length + RESET.length : 0; const categoryColorLength = useColors ? colorToAnsi(finalCategoryColor).length + styleToAnsi(categoryStyle).length + RESET.length : 0; const paddedLevel = formattedLevel.padEnd(levelWidth + levelColorLength); const paddedCategory = formattedCategory.padEnd(categoryWidth + categoryColorLength); let result = `${formattedTimestamp}${formattedIcon} ${paddedLevel} ${paddedCategory} ${formattedMessage}`; const indentWidth = require_wcwidth.getDisplayWidth(require_wcwidth.stripAnsi(`${formattedTimestamp}${formattedIcon} ${paddedLevel} ${paddedCategory} `)); if (wordWrapEnabled || message.includes("\n")) result = require_wordwrap.wrapText(result, wordWrapEnabled ? wordWrapWidth : Infinity, indentWidth); if (properties) result += formatProperties(record, indentWidth, wordWrapEnabled ? wordWrapWidth : Infinity, useColors, inspectOptions); return result + "\n"; } else { let result = `${formattedTimestamp}${formattedIcon} ${formattedLevel} ${formattedCategory} ${formattedMessage}`; const indentWidth = require_wcwidth.getDisplayWidth(require_wcwidth.stripAnsi(`${formattedTimestamp}${formattedIcon} ${formattedLevel} ${formattedCategory} `)); if (wordWrapEnabled || message.includes("\n")) result = require_wordwrap.wrapText(result, wordWrapEnabled ? wordWrapWidth : Infinity, indentWidth); if (properties) result += formatProperties(record, indentWidth, wordWrapEnabled ? wordWrapWidth : Infinity, useColors, inspectOptions); return result + "\n"; } }; } function formatProperties(record, indentWidth, maxWidth, useColors, inspectOptions) { let result = ""; for (const prop in record.properties) { const propValue = record.properties[prop]; const pad = Math.max(0, indentWidth - require_wcwidth.getDisplayWidth(prop) - 2); result += "\n" + require_wordwrap.wrapText(`${" ".repeat(pad)}${useColors ? DIM : ""}${prop}:${useColors ? RESET : ""} ${(0, __util.inspect)(propValue, { colors: useColors, ...inspectOptions })}`, maxWidth, indentWidth); } return result; } /** * A pre-configured beautiful console formatter for local development. * * This is a ready-to-use instance of the pretty formatter with sensible defaults * for most development scenarios. It provides immediate visual enhancement to * your logs without requiring any configuration. * * Features enabled by default: * - Emoji icons for all log levels * - True color support with rich color schemes * - Dimmed text styling for better readability * - Smart category truncation (20 characters max) * - Perfect column alignment * - No timestamp display (cleaner for development) * * For custom configuration, use {@link getPrettyFormatter} instead. * * @example * ```typescript * import { configure } from "@logtape/logtape"; * import { getConsoleSink } from "@logtape/logtape/sink"; * import { prettyFormatter } from "@logtape/pretty"; * * await configure({ * sinks: { * console: getConsoleSink({ * formatter: prettyFormatter * }) * } * }); * ``` * * @since 1.0.0 */ const prettyFormatter = getPrettyFormatter(); //#endregion exports.getPrettyFormatter = getPrettyFormatter; exports.prettyFormatter = prettyFormatter;