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 { stripAnsi, 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"), "");
}

/**
* Calculates the visual width of a string in terminal columns, correctly handling
* emoji, CJK characters, and ANSI escape codes. Falls back to stripped ANSI length
* when `Intl.Segmenter` is not available.
*
* @param {string} str - The string to measure.
* @returns {number} The visual width of the string in terminal columns.
*/
export 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);
}

/**
* 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
40 changes: 40 additions & 0 deletions test/box.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, test, expect } from "vitest";
import { stringWidth } from "../src/utils/string";
import { box } from "../src/utils/box";

function expectAlignedBox(result: string) {
const lines = result.split("\n").filter(Boolean);
const widths = lines.map((line) => stringWidth(line));
const uniqueWidths = [...new Set(widths)];
expect(uniqueWidths.length).toBe(1);
}

describe("box", () => {
test("renders a basic box", () => {
expectAlignedBox(box("Hello"));
});

test("aligns box edges with emoji content", () => {
expectAlignedBox(box("Hello 🌍 World"));
});

test("aligns box edges with CJK characters", () => {
expectAlignedBox(box("こんにけは"));
});

test("aligns box edges with multiple emoji lines", () => {
expectAlignedBox(box("Line 1 πŸŽ‰\nLine 2 πŸš€\nLine 3"));
});

test("aligns box edges with emoji in title", () => {
expectAlignedBox(box("Content", { title: "πŸŽ‰ Title" }));
});

test("aligns box edges with CJK title", () => {
expectAlignedBox(box("Content", { title: "γ‚Ώγ‚€γƒˆγƒ«" }));
});

test("aligns box edges with ZWJ emoji sequences", () => {
expectAlignedBox(box("Hello πŸ‘¨β€πŸ‘©β€πŸ‘§ World"));
});
});