hagen
Version:
A colorful logger for JS in Node and the Browser
268 lines (230 loc) • 6.41 kB
text/typescript
/**
* A colorful logger for JS in Node and in the Browser
*
* Named after Hagen the colorful Lumberjack from Synthie Forest
* https://vimeo.com/90995716
*/
import { Chalk, ChalkInstance } from "chalk";
import { isCI } from "std-env";
// ========= TYPES =========
type Label =
| string
| {
label: string;
color: ChalkInstance | number;
}
| {
label: string;
bgColor: string;
fgColor: string;
};
interface PrintParams {
logger: (...params: any[]) => void;
label: Label;
data: any[];
}
interface LoggerConfig {
showTimestamp: boolean;
colors: {
reserved: {
WARN: ChalkInstance;
ERROR: ChalkInstance;
INFO: ChalkInstance;
SUCCESS: ChalkInstance;
};
normal: ChalkInstance[];
};
fixedWidth?: {
width: number;
truncationMethod?: "start" | "end" | "middle";
};
}
// ========= CONFIGURATION =========
const customChalk = new Chalk({ level: 3 });
// if the environment is CI, don't use color
if (isCI) {
customChalk.level = 0;
}
const defaultConfig: LoggerConfig = {
showTimestamp: false,
colors: {
reserved: {
WARN: customChalk.bgYellowBright.black,
ERROR: customChalk.bgRedBright.black,
INFO: customChalk.bgBlack.white,
SUCCESS: customChalk.bgBlack.greenBright,
},
normal: [
customChalk.bgBlue.white, // blue
customChalk.bgGreen.black, // green
customChalk.bgCyan.black, // cyan
customChalk.bgRed.white, // red
customChalk.bgMagenta.white, // magenta
customChalk.bgYellow.black, // yellow
],
},
};
let currentConfig = { ...defaultConfig };
export function setConfig(config: Partial<LoggerConfig>): void {
currentConfig = { ...currentConfig, ...config };
}
export function getConfig(): LoggerConfig {
return currentConfig;
}
export function resetConfig(): void {
currentConfig = { ...defaultConfig };
}
// ========= HELPERS =========
/**
* Returns a default color either based on a manual index or a hash of the label.
*/
function calculateLabelColor(label: string) {
const charSum = Array.from(label).reduce((sum, char) => sum + char.charCodeAt(0), 0);
return currentConfig.colors.normal[charSum % currentConfig.colors.normal.length];
}
/**
* Formats the text to a fixed width.
* - If the text is shorter than the width, it is centered.
* - If it is longer, it is truncated based on the provided position:
* - "start": keep the end of the string.
* - "end": keep the beginning of the string.
* - "middle": keep the beginning and end, with an ellipsis in the middle.
*/
function fixedWidthFormat(
text: string,
width: number,
truncationMethod: "start" | "end" | "middle" = "end"
): string {
if (text.length === width) {
return text;
}
if (text.length < width) {
const spacesTotal = width - text.length;
const leftPadding = Math.floor(spacesTotal / 2);
const rightPadding = spacesTotal - leftPadding;
return " ".repeat(leftPadding) + text + " ".repeat(rightPadding);
}
const ellipsis = "…";
const charsToShow = width - 1; // account for the ellipsis
// text is longer than the target width, so truncate
switch (truncationMethod) {
case "start":
return `${ellipsis}${text.slice(text.length - charsToShow)}`;
case "end":
return `${text.slice(0, charsToShow)}${ellipsis}`;
case "middle": {
const frontChars = Math.ceil(charsToShow / 2);
const backChars = Math.floor(charsToShow / 2);
return `${text.slice(0, frontChars)}${ellipsis}${text.slice(text.length - backChars)}`;
}
default:
throw new Error(`Invalid truncation method: ${truncationMethod}`);
}
}
/**
* Prints the colored label and the data to the provided logger.
*/
function print({ logger, label, data }: PrintParams): void {
let color: ChalkInstance;
let finalLabel: string = "•";
if (typeof label === "object") {
// if the label is an object
finalLabel = label.label ? label.label : finalLabel;
if ("color" in label) {
if (typeof label.color === "number") {
// if the label has a color index, use it
color = currentConfig.colors.normal[label.color];
} else {
// if the label has a chalk color, use it
color = label.color;
}
} else {
// if the label has bgColor and fgColor properties use them
color = customChalk.bgHex(label.bgColor).hex(label.fgColor);
}
} else {
// if the label is a string
finalLabel = label || finalLabel;
// use the default color
color = calculateLabelColor(label);
}
// trim off extra spaces
finalLabel = finalLabel.trim();
// apply fixed width formatting if configured
if (currentConfig.fixedWidth !== undefined) {
finalLabel = fixedWidthFormat(
finalLabel,
currentConfig.fixedWidth.width,
currentConfig.fixedWidth.truncationMethod
);
}
// make the label bold
finalLabel = color.bold(` ${finalLabel} `);
// if the environment is CI, add a border around the label
if (isCI) {
finalLabel = `[${finalLabel}]`;
}
// add a timestamp if configured
if (currentConfig.showTimestamp) {
const timestamp = new Date().toISOString();
const timestampLabel = customChalk.gray(`[ ${timestamp} ]`);
finalLabel = `${finalLabel} ${timestampLabel}`;
}
// log it with all data using spread operator
logger(finalLabel, ...data);
}
/**
* Formats the label by prepending a prefix. If the label is empty, returns the fallback.
*/
function formatLabel(prefix: string, label: string): string {
return `${prefix} ${label}`.trim();
}
// ========= LOGGERS =========
export function log(label: Label, ...data: any[]): void {
print({
logger: console.log,
label,
data,
});
}
export function info(label: string, ...data: any[]): void {
print({
logger: console.log,
label: {
label: formatLabel("i", label),
color: currentConfig.colors.reserved.INFO,
},
data,
});
}
export function success(label: string, ...data: any[]): void {
print({
logger: console.log,
label: {
label: formatLabel("✓", label),
color: currentConfig.colors.reserved.SUCCESS,
},
data,
});
}
export function warn(label: string, ...data: any[]): void {
print({
logger: console.warn,
label: {
label: formatLabel("!", label),
color: currentConfig.colors.reserved.WARN,
},
data,
});
}
export function error(label: string, ...data: any[]): void {
print({
logger: console.error,
label: {
label: formatLabel("✕", label),
color: currentConfig.colors.reserved.ERROR,
},
data,
});
}
export default { log, info, success, warn, error };