Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion preview/index.ts
Original file line number Diff line number Diff line change
@@ -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`);
Expand All @@ -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"
}`);
31 changes: 28 additions & 3 deletions src/createLogger.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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;

Expand All @@ -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;
Expand All @@ -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);
};
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface Options {
* @default globalThis.console
*/
console?: Pick<Console, 'log' | 'warn' | 'error'>;
/**
* 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;
Expand Down
11 changes: 9 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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)]*|\(<anonymous>\)|\(index\s\d+\))$/;

export const isErrorStackMessage = (message: string) =>
errorStackRegExp.test(message);
export const isErrorStackMessage = (message: string) => {
const stripped = stripAnsi(message);
return errorStackRegExp.test(stripped);
};
38 changes: 38 additions & 0 deletions tests/__snapshots__/logger.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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`] = `
[
[
Expand Down Expand Up @@ -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`] = `
[
[
Expand Down Expand Up @@ -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`] = `
[
[
Expand Down
121 changes: 121 additions & 0 deletions tests/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
});
});