@logtape/pretty
Version:
Beautiful text formatter for LogTapeβperfect for local development
390 lines (388 loc) β’ 13.1 kB
JavaScript
import { getOptimalWordWrapWidth } from "./terminal.js";
import { truncateCategory } from "./truncate.js";
import { getDisplayWidth } from "./wcwidth.js";
import { wrapText } from "./wordwrap.js";
import { inspect } from "#util";
//#region 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]) => getDisplayWidth(icon)));
return Object.fromEntries(entries.map(([level, icon]) => [level, icon + " ".repeat(maxWidth - 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 = {}, 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 = getOptimalWordWrapWidth(80);
else wordWrapWidth = 80;
const categoryPatterns = prepareCategoryPatterns(categoryColorMap);
const allLevels = [
"trace",
"debug",
"info",
"warning",
"error",
"fatal"
];
const levelWidth = Math.max(...allLevels.map((l) => formatLevel(l).length));
return (record) => {
const icon = iconMap[record.level] || "";
const level = formatLevel(record.level);
const categoryStr = 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 = 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}`;
if (wordWrapEnabled || message.includes("\n")) result = wrapText(result, wordWrapEnabled ? wordWrapWidth : Infinity, message);
return result + "\n";
} else {
let result = `${formattedTimestamp}${formattedIcon} ${formattedLevel} ${formattedCategory} ${formattedMessage}`;
if (wordWrapEnabled || message.includes("\n")) result = wrapText(result, wordWrapEnabled ? wordWrapWidth : Infinity, message);
return result + "\n";
}
};
}
/**
* 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
export { getPrettyFormatter, prettyFormatter };
//# sourceMappingURL=formatter.js.map