diff --git a/preview/index.ts b/preview/index.ts index 08320d2..38f9991 100644 --- a/preview/index.ts +++ b/preview/index.ts @@ -1,4 +1,4 @@ -import { logger } from '../dist/index.js'; +import { createLogger, logger } from '../dist/index.js'; import { getErrorCause } from './cause.ts'; logger.greet(`\n➜ Rslog v1.0.0\n`); @@ -16,3 +16,32 @@ logger.error( cause: getErrorCause(), }), ); + +// multi-line alignment demo +const alignLogger = createLogger({ alignMultiline: true }); +const prefixAlignLogger = createLogger({ + alignMultiline: true, + prefix: '[web]', +}); + +alignLogger.info(`User profile updated: +- Name: John Doe +- Email: john@example.com +- Phone: +1234567890`); + +prefixAlignLogger.info(`User profile updated: +- Name: John Doe +- Email: john@example.com`); + +// error stacks stay top-aligned even with alignMultiline +alignLogger.error(`Something failed: +- reason A + at /rslog/foo/bar.js:29:0 +- reason B`); + +// pre-formatted JSON content (demonstrates the double-indent trade-off) +alignLogger.info(`Config loaded: +{ + "port": 3000, + "host": "localhost" +}`); diff --git a/src/createLogger.ts b/src/createLogger.ts index 1d6948c..cdc1e7d 100644 --- a/src/createLogger.ts +++ b/src/createLogger.ts @@ -1,8 +1,10 @@ import { boldMint, color } from './color.js'; import { LOG_LEVEL, LOG_TYPES } from './constants.js'; -import { isErrorStackMessage } from './utils.js'; +import { isErrorStackMessage, stripAnsi } from './utils.js'; import type { Options, LogMessage, Logger, LogMethods } from './types.js'; +const LABEL_WIDTH = 7; + const normalizeErrorMessage = (err: Error) => { if (err.stack) { const [rawName, ...rest] = err.stack.split('\n'); @@ -14,7 +16,12 @@ const normalizeErrorMessage = (err: Error) => { }; export const createLogger = (options: Options = {}) => { - const { level = 'info', prefix, console = globalThis.console } = options; + const { + level = 'info', + prefix, + console = globalThis.console, + alignMultiline, + } = options; let maxLevel = level; @@ -32,8 +39,11 @@ export const createLogger = (options: Options = {}) => { const label = 'label' in logType ? logType.label : ''; let text = ''; + const hasLabel = Boolean(label); + + const isErrorObject = message instanceof Error; - if (message instanceof Error) { + if (isErrorObject) { text += normalizeErrorMessage(message); const { cause } = message; @@ -57,6 +67,21 @@ export const createLogger = (options: Options = {}) => { text = `${prefix} ${text}`; } + if (alignMultiline && hasLabel && !isErrorObject && text.includes('\n')) { + const indent = ' '.repeat( + LABEL_WIDTH + 1 + (prefix ? stripAnsi(prefix).length + 1 : 0), + ); + const skipStack = level === 'error'; + text = text + .split('\n') + .map((line, i) => + i === 0 || line === '' || (skipStack && isErrorStackMessage(line)) + ? line + : indent + line, + ) + .join('\n'); + } + const method = level === 'error' || level === 'warn' ? level : 'log'; console[method](label ? `${label} ${text}` : text, ...args); }; diff --git a/src/types.ts b/src/types.ts index 533a731..ac6e3ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,13 @@ export interface Options { * @default globalThis.console */ console?: Pick; + /** + * Aligns continuation lines of multi-line messages to the first line's + * content column (label + prefix). Error object stacks keep their original + * top-aligned formatting. + * @default false + */ + alignMultiline?: boolean; } export type LogMethods = keyof typeof LOG_TYPES; diff --git a/src/utils.ts b/src/utils.ts index a676392..9a74465 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ +const ANSI_REGEXP = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +export const stripAnsi = (text: string): string => + text.replace(ANSI_REGEXP, ''); + const errorStackRegExp = /at [^\r\n]{0,200}(?::\d+:\d+[\s)]*|\(\)|\(index\s\d+\))$/; -export const isErrorStackMessage = (message: string) => - errorStackRegExp.test(message); +export const isErrorStackMessage = (message: string) => { + const stripped = stripAnsi(message); + return errorStackRegExp.test(stripped); +}; diff --git a/tests/__snapshots__/logger.test.ts.snap b/tests/__snapshots__/logger.test.ts.snap index a5b1249..487c777 100644 --- a/tests/__snapshots__/logger.test.ts.snap +++ b/tests/__snapshots__/logger.test.ts.snap @@ -1,5 +1,25 @@ // Rstest Snapshot v1 +exports[`logger > should align multi-line messages to label + prefix column 1`] = ` +[ + [ + "info [web] User profile updated: + - Name: John Doe + - Email: john@example.com", + ], +] +`; + +exports[`logger > should align multi-line messages when alignMultiline is enabled 1`] = ` +[ + [ + "info User profile updated: + - Name: John Doe + - Email: john@example.com", + ], +] +`; + exports[`logger > should create new logger with info level correctly 1`] = ` [ [ @@ -53,6 +73,17 @@ exports[`logger > should create new logger with warn level correctly 2`] = ` ] `; +exports[`logger > should keep error stacks top-aligned when alignMultiline is enabled 1`] = ` +[ + [ + "error Something failed: + - reason A + at /rslog/foo/bar.js:29:0 + - reason B", + ], +] +`; + exports[`logger > should log as expected 1`] = ` [ [ @@ -102,6 +133,13 @@ exports[`logger > should log error with stack correctly 1`] = ` " `; +exports[`logger > should not align Error object output when alignMultiline is enabled 1`] = ` +"error at /rslog/foo/bar.js:29:0 + + [cause]: at /rslog/foo/bar.js:29:0 +" +`; + exports[`logger > should prepend prefix correctly 1`] = ` [ [ diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 4afd5f8..3a7481e 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -267,4 +267,125 @@ describe('logger', () => { rs.resetModules(); } }); + + test('should align multi-line messages when alignMultiline is enabled', () => { + console.log = rs.fn(); + console.error = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + alignLogger.info(`User profile updated: +- Name: John Doe +- Email: john@example.com`); + + expect( + (console.log as Mock).mock.calls.map((items) => + items.map((item) => stripAnsi(item.toString())), + ), + ).toMatchSnapshot(); + }); + + test('should align multi-line messages to label + prefix column', () => { + console.log = rs.fn(); + + const alignLogger = createLogger({ + alignMultiline: true, + prefix: '[web]', + }); + + alignLogger.info(`User profile updated: +- Name: John Doe +- Email: john@example.com`); + + expect( + (console.log as Mock).mock.calls.map((items) => + items.map((item) => stripAnsi(item.toString())), + ), + ).toMatchSnapshot(); + }); + + test('should keep error stacks top-aligned when alignMultiline is enabled', () => { + console.error = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + alignLogger.error(`Something failed: +- reason A + at /rslog/foo/bar.js:29:0 +- reason B`); + + expect( + (console.error as Mock).mock.calls.map((items) => + items.map((item) => stripAnsi(item.toString())), + ), + ).toMatchSnapshot(); + }); + + test('should not align Error object output when alignMultiline is enabled', () => { + console.error = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + const err = new Error('this is an error message with cause', { + cause: getErrorCause(), + }); + err.stack = ' at /rslog/foo/bar.js:29:0'; + + alignLogger.error(err); + + expect( + stripAnsi((console.error as Mock).mock.calls[0][0].toString()), + ).toMatchSnapshot(); + }); + + test('should keep grayed error stack un-indented and colored when alignMultiline is enabled', () => { + console.error = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + alignLogger.error(`Something failed: + at /rslog/foo/bar.js:29:0 +- reason B`); + + const raw = (console.error as Mock).mock.calls[0][0].toString(); + const lines = raw.split('\n'); + + expect(lines[1]).toContain(String.fromCharCode(27)); + expect(stripAnsi(lines[1]).startsWith(' at ')).toBe(true); + expect(stripAnsi(lines[2]).startsWith(' - reason B')).toBe(true); + }); + + test('should align stack-like lines in non-error levels (no stack exemption)', () => { + console.log = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + alignLogger.info(`Deploy summary: +- deployed at server:443:8080 +- status: ok`); + + const lines = stripAnsi( + (console.log as Mock).mock.calls[0][0].toString(), + ).split('\n'); + + expect(lines[0].startsWith('info Deploy summary:')).toBe(true); + expect(lines[1].startsWith(' - deployed at server:443:8080')).toBe( + true, + ); + expect(lines[2].startsWith(' - status: ok')).toBe(true); + }); + + test('should not add trailing whitespace on blank lines when alignMultiline is enabled', () => { + console.log = rs.fn(); + + const alignLogger = createLogger({ alignMultiline: true }); + + alignLogger.info(`Part 1 + +Part 2`); + + const lines = (console.log as Mock).mock.calls[0][0].toString().split('\n'); + + expect(lines[1]).toBe(''); + }); });