diff --git a/src/reporters/fancy.ts b/src/reporters/fancy.ts index 3c58b277..4ec3c274 100644 --- a/src/reporters/fancy.ts +++ b/src/reporters/fancy.ts @@ -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 } = { @@ -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); diff --git a/src/utils.ts b/src/utils.ts index 1ddf92c5..a33e2800 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ export * from "./utils/box"; export * from "./utils/color"; export { stripAnsi, + stringWidth, centerAlign, rightAlign, leftAlign, diff --git a/src/utils/box.ts b/src/utils/box.ts index e400a15a..2e45cf8f 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,5 +1,5 @@ import { getColor } from "./color"; -import { stripAnsi } from "./string"; +import { stringWidth } from "./string"; export type BoxBorderStyle = { /** @@ -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; @@ -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}`, @@ -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}`, ); diff --git a/src/utils/string.ts b/src/utils/string.ts index 11498016..9dc4fdc9 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -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=><~]))`, @@ -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. diff --git a/test/box.test.ts b/test/box.test.ts new file mode 100644 index 00000000..d9553497 --- /dev/null +++ b/test/box.test.ts @@ -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")); + }); +});