Skip to content
Open
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
12 changes: 1 addition & 11 deletions src/reporters/fancy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import _stringWidth from "string-width";
import isUnicodeSupported from "is-unicode-supported";
import { colors } from "../utils/color";
import { parseStack } from "../utils/error";
import { FormatOptions, LogObject } from "../types";
import { LogLevel, LogType } from "../constants";
import { BoxOpts, box } from "../utils/box";
import { stripAnsi } from "../utils";
import { stringWidth } from "../utils";
import { BasicReporter } from "./basic";

export const TYPE_COLOR_MAP: { [k in LogType]?: string } = {
Expand Down Expand Up @@ -37,15 +36,6 @@ const TYPE_ICONS: { [k in LogType]?: string } = {
log: "",
};

function stringWidth(str: string) {
// https://github.com/unjs/consola/issues/204
const hasICU = typeof Intl === "object";
if (!hasICU || !Intl.Segmenter) {
return stripAnsi(str).length;
}
return _stringWidth(str);
}

export class FancyReporter extends BasicReporter {
formatStack(stack: string, message: string, opts?: FormatOptions) {
const indent = " ".repeat((opts?.errorLevel || 0) + 1);
Expand Down
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./utils/box";
export * from "./utils/color";
export {
stripAnsi,
stringWidth,
centerAlign,
rightAlign,
leftAlign,
Expand Down
15 changes: 6 additions & 9 deletions src/utils/box.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getColor } from "./color";
import { stripAnsi } from "./string";
import { stringWidth } from "./string";

export type BoxBorderStyle = {
/**
Expand Down Expand Up @@ -257,8 +257,8 @@ export function box(text: string, _opts: BoxOpts = {}) {
const height = textLines.length + paddingOffset;
const width =
Math.max(
...textLines.map((line) => stripAnsi(line).length),
opts.title ? stripAnsi(opts.title).length : 0,
...textLines.map((line) => stringWidth(line)),
opts.title ? stringWidth(opts.title) : 0,
) + paddingOffset;
const widthOffset = width + paddingOffset;

Expand All @@ -273,13 +273,10 @@ export function box(text: string, _opts: BoxOpts = {}) {
if (opts.title) {
const title = _color ? _color(opts.title) : opts.title;
const left = borderStyle.h.repeat(
Math.floor((width - stripAnsi(opts.title).length) / 2),
Math.floor((width - stringWidth(opts.title)) / 2),
);
const right = borderStyle.h.repeat(
width -
stripAnsi(opts.title).length -
stripAnsi(left).length +
paddingOffset,
width - stringWidth(opts.title) - stringWidth(left) + paddingOffset,
);
boxLines.push(
`${leftSpace}${borderStyle.tl}${left}${title}${right}${borderStyle.tr}`,
Expand Down Expand Up @@ -312,7 +309,7 @@ export function box(text: string, _opts: BoxOpts = {}) {
// Text line
const line = textLines[i - valignOffset];
const left = " ".repeat(paddingOffset);
const right = " ".repeat(width - stripAnsi(line).length);
const right = " ".repeat(width - stringWidth(line));
boxLines.push(
`${leftSpace}${borderStyle.v}${left}${line}${right}${borderStyle.v}`,
);
Expand Down
19 changes: 19 additions & 0 deletions src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import _stringWidth from "string-width";

const ansiRegex = [
String.raw`[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)`,
String.raw`(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))`,
Expand All @@ -15,6 +17,23 @@ export function stripAnsi(text: string) {
return text.replace(new RegExp(ansiRegex, "g"), "");
}

/**
* Returns the visual width of a string in terminal columns, accounting for ANSI
* escape codes and wide/emoji characters. Falls back to code-unit length when
* `Intl.Segmenter` is unavailable.
*
* @param {string} str - The string to measure.
* @returns {number} The number of columns the string occupies.
*/
export function stringWidth(str: string): number {
// https://github.com/unjs/consola/issues/204
const hasICU = typeof Intl === "object";
if (!hasICU || !Intl.Segmenter) {
return stripAnsi(str).length;
}
return _stringWidth(str);
}

/**
* Centers a string within a specified total width, padding it with spaces or another specified character.
* If the string is longer than the total width, it is returned as is.
Expand Down
19 changes: 19 additions & 0 deletions test/box.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, test, expect } from "vitest";
import { box } from "../src/utils/box";
import { stringWidth } from "../src/utils/string";

describe("box", () => {
// https://github.com/unjs/consola/issues/402
test("keeps borders aligned when content contains emoji", () => {
// "πŸ‘¨β€πŸ‘©β€πŸ‘§" is a ZWJ sequence: many code units (`.length` overcounts badly)
// but only 2 display columns. With `.length` the border zig-zags.
const rendered = box("a short line\nπŸ‘¨β€πŸ‘©β€πŸ‘§ family");
const widths = rendered
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => stringWidth(line));

// every rendered line must share the same visual width
expect([...new Set(widths)]).toHaveLength(1);
});
});