From 3df572abf3cbe32983eaa2c1f37b8d90a6a55a09 Mon Sep 17 00:00:00 2001 From: a145789 <2091927351@qq.com> Date: Mon, 22 Jun 2026 16:42:17 +0800 Subject: [PATCH] feat(logger): add opt-in multi-line log alignment Add `alignMultiline` option to `createLogger` (default: false, non-breaking). When enabled, continuation lines of multi-line messages are indented to align with the first line's content column (label + prefix), so multi-line output reads as a contiguous block instead of wrapping back to column 0. ## Changes - src/types.ts: add `alignMultiline?: boolean` to `Options`. - src/utils.ts: add `stripAnsi`; use it in `isErrorStackMessage` so the stack regex still matches ANSI-grayed lines. - src/createLogger.ts: extract `LABEL_WIDTH` constant; after text is assembled, indent continuation lines to `LABEL_WIDTH + 1 + (prefix length + 1)`. Skip the first line, blank lines, and stack-like lines (only at `level === 'error'`). Error objects are exempt entirely. - tests: add new tests for the feature only; existing tests and snapshots are untouched. ## Expected output Without prefix: info User profile updated: - Name: John Doe - Email: john@example.com With prefix `[web]`: info [web] User profile updated: - Name: John Doe - Email: john@example.com Error stacks stay top-aligned: error Something failed: - reason A at /rslog/foo/bar.js:29:0 - reason B Closes #51 --- preview/index.ts | 31 +++++- src/createLogger.ts | 34 ++++++- src/types.ts | 7 ++ src/utils.ts | 17 +++- tests/__snapshots__/logger.test.ts.snap | 38 +++++++ tests/logger.test.ts | 126 ++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 9 deletions(-) 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 6f0ebc6..fcfc528 100644 --- a/src/createLogger.ts +++ b/src/createLogger.ts @@ -1,9 +1,11 @@ import { color } from './color.js'; import { gradient } from './gradient.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'); @@ -15,7 +17,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; @@ -33,13 +40,17 @@ export const createLogger = (options: Options = {}) => { let label = ''; let text = ''; + let hasLabel = false; if ('label' in logType) { - label = (logType.label || '').padEnd(7); + hasLabel = true; + label = (logType.label || '').padEnd(LABEL_WIDTH); label = color.bold(logType.color ? logType.color(label) : label); } - if (message instanceof Error) { + const isErrorObject = message instanceof Error; + + if (isErrorObject) { text += normalizeErrorMessage(message); const { cause } = message; @@ -63,6 +74,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.length ? `${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 6d94776..77a998f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,11 +3,20 @@ import supportsColor from 'supports-color'; // https://github.com/chalk/supports-color export const colorLevel = supportsColor.stdout ? supportsColor.stdout.level : 0; +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)]*$/; const anonymousErrorStackRegExp = /at [^\r\n]{0,200}\(\)$/; const indexErrorStackRegExp = /at [^\r\n]{0,200}\(index\s\d+\)$/; -export const isErrorStackMessage = (message: string) => - errorStackRegExp.test(message) || - anonymousErrorStackRegExp.test(message) || - indexErrorStackRegExp.test(message); +export const isErrorStackMessage = (message: string) => { + const stripped = stripAnsi(message); + return ( + errorStackRegExp.test(stripped) || + anonymousErrorStackRegExp.test(stripped) || + indexErrorStackRegExp.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 07ffefd..9e61c34 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -197,4 +197,130 @@ describe('logger', () => { console: customConsole, }); }); + + 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'); + + // stack line is grayed (still contains ANSI codes after alignment ran) + expect(lines[1]).toContain(String.fromCharCode(27)); + // stack line is not indented (starts with the original spaces, not the 8-space align indent) + expect(stripAnsi(lines[1]).startsWith(' at ')).toBe(true); + // non-stack line is indented to the label column + 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 }); + + // "- deployed at server:443:8080" matches the stack regex, but at info + // level it is user content, not a stack frame -> must still be aligned. + 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(''); + }); });