diff --git a/.changeset/lucky-dragons-think.md b/.changeset/lucky-dragons-think.md new file mode 100644 index 00000000..ffb40962 --- /dev/null +++ b/.changeset/lucky-dragons-think.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +fix(note, box): handle CJK correctly diff --git a/packages/prompts/package.json b/packages/prompts/package.json index 80c2e51d..2b5566d4 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -58,10 +58,11 @@ "sisteransi": "^1.0.5" }, "devDependencies": { + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", "is-unicode-supported": "^1.3.0", "memfs": "^4.17.2", "vitest": "^3.2.4", - "vitest-ansi-serializer": "^0.1.2", - "fast-wrap-ansi": "^0.1.3" + "vitest-ansi-serializer": "^0.1.2" } } diff --git a/packages/prompts/src/box.ts b/packages/prompts/src/box.ts index 0e145d6e..398f7465 100644 --- a/packages/prompts/src/box.ts +++ b/packages/prompts/src/box.ts @@ -14,6 +14,7 @@ import { S_CORNER_TOP_LEFT, S_CORNER_TOP_RIGHT, } from './common.js'; +import stringWidth from "fast-string-width"; export type BoxAlignment = 'left' | 'center' | 'right'; @@ -72,13 +73,15 @@ export const box = (message = '', title = '', opts?: BoxOptions) => { const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder); const hSymbol = formatBorder(S_BAR_H); const vSymbol = formatBorder(S_BAR); - const maxBoxWidth = columns - linePrefix.length; - let boxWidth = Math.floor(columns * width) - linePrefix.length; + const linePrefixWidth = stringWidth(linePrefix); + const titleWidth = stringWidth(title); + const maxBoxWidth = columns - linePrefixWidth; + let boxWidth = Math.floor(columns * width) - linePrefixWidth; if (opts?.width === 'auto') { const lines = message.split('\n'); - let longestLine = title.length + titlePadding * 2; + let longestLine = titleWidth + titlePadding * 2; for (const line of lines) { - const lineWithPadding = line.length + contentPadding * 2; + const lineWithPadding = stringWidth(line) + contentPadding * 2; if (lineWithPadding > longestLine) { longestLine = lineWithPadding; } @@ -98,9 +101,9 @@ export const box = (message = '', title = '', opts?: BoxOptions) => { const innerWidth = boxWidth - borderTotalWidth; const maxTitleLength = innerWidth - titlePadding * 2; const truncatedTitle = - title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title; + titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title; const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine( - truncatedTitle.length, + stringWidth(truncatedTitle), innerWidth, titlePadding, opts?.titleAlign @@ -115,7 +118,7 @@ export const box = (message = '', title = '', opts?: BoxOptions) => { const wrappedLines = wrappedMessage.split('\n'); for (const line of wrappedLines) { const [leftLinePadding, rightLinePadding] = getPaddingForLine( - line.length, + stringWidth(line), innerWidth, contentPadding, opts?.contentAlign diff --git a/packages/prompts/src/note.ts b/packages/prompts/src/note.ts index 7af69f13..6b07055a 100644 --- a/packages/prompts/src/note.ts +++ b/packages/prompts/src/note.ts @@ -1,6 +1,5 @@ import process from 'node:process'; import type { Writable } from 'node:stream'; -import { stripVTControlCharacters as strip } from 'node:util'; import { getColumns } from '@clack/core'; import { type Options as WrapAnsiOptions, wrapAnsi } from 'fast-wrap-ansi'; import color from 'picocolors'; @@ -13,6 +12,7 @@ import { S_CORNER_TOP_RIGHT, S_STEP_SUBMIT, } from './common.js'; +import stringWidth from "fast-string-width"; type FormatFn = (line: string) => string; export interface NoteOptions extends CommonOptions { @@ -27,10 +27,10 @@ const wrapWithFormat = (message: string, width: number, format: FormatFn): strin trim: false, }; const wrapMsg = wrapAnsi(message, width, opts).split('\n'); - const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(strip(ln).length, sum), 0); + const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0); const maxWidthFormat = wrapMsg .map(format) - .reduce((sum, ln) => Math.max(strip(ln).length, sum), 0); + .reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0); const wrapWidth = width - (maxWidthFormat - maxWidthNormal); return wrapAnsi(message, wrapWidth, opts); }; @@ -40,18 +40,18 @@ export const note = (message = '', title = '', opts?: NoteOptions) => { const format = opts?.format ?? defaultNoteFormatter; const wrapMsg = wrapWithFormat(message, getColumns(output) - 6, format); const lines = ['', ...wrapMsg.split('\n').map(format), '']; - const titleLen = strip(title).length; + const titleLen = stringWidth(title); const len = Math.max( lines.reduce((sum, ln) => { - const line = strip(ln); - return line.length > sum ? line.length : sum; + const width = stringWidth(ln); + return width > sum ? width : sum; }, 0), titleLen ) + 2; const msg = lines .map( - (ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}` + (ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - stringWidth(ln))}${color.gray(S_BAR)}` ) .join('\n'); output.write( diff --git a/packages/prompts/test/__snapshots__/box.test.ts.snap b/packages/prompts/test/__snapshots__/box.test.ts.snap index 60cc91ec..63f3b8c7 100644 --- a/packages/prompts/test/__snapshots__/box.test.ts.snap +++ b/packages/prompts/test/__snapshots__/box.test.ts.snap @@ -191,6 +191,38 @@ exports[`box (isCI = false) > renders truncated long titles 1`] = ` ] `; +exports[`box (isCI = false) > renders wide characters with auto width 1`] = ` +[ + "┌─这是标题─────────────────┐ +", + "│ 이게 첫 번째 줄이에요 │ +", + "│ これは次の行です │ +", + "└──────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders wide characters with specified width 1`] = ` +[ + "┌─这是标题─────┐ +", + "│ 이게 첫 │ +", + "│ 번째 │ +", + "│ 줄이에요 │ +", + "│ これは次の │ +", + "│ 行です │ +", + "└──────────────┘ +", +] +`; + exports[`box (isCI = false) > renders with formatBorder formatting 1`] = ` [ "┌─title──────┐ @@ -423,6 +455,38 @@ exports[`box (isCI = true) > renders truncated long titles 1`] = ` ] `; +exports[`box (isCI = true) > renders wide characters with auto width 1`] = ` +[ + "┌─这是标题─────────────────┐ +", + "│ 이게 첫 번째 줄이에요 │ +", + "│ これは次の行です │ +", + "└──────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders wide characters with specified width 1`] = ` +[ + "┌─这是标题─────┐ +", + "│ 이게 첫 │ +", + "│ 번째 │ +", + "│ 줄이에요 │ +", + "│ これは次の │ +", + "│ 行です │ +", + "└──────────────┘ +", +] +`; + exports[`box (isCI = true) > renders with formatBorder formatting 1`] = ` [ "┌─title──────┐ diff --git a/packages/prompts/test/__snapshots__/note.test.ts.snap b/packages/prompts/test/__snapshots__/note.test.ts.snap index bb1bd673..ccb42566 100644 --- a/packages/prompts/test/__snapshots__/note.test.ts.snap +++ b/packages/prompts/test/__snapshots__/note.test.ts.snap @@ -3,34 +3,34 @@ exports[`note (isCI = false) > don't overflow 1`] = ` [ "│ -◇ title ────────────────────────────────────────────────────────────────────╮ -│ │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string │ -│ │ -├────────────────────────────────────────────────────────────────────────────╯ +◇ title ───────────────────────────────────────────────────────────────╮ +│ │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string │ +│ │ +├───────────────────────────────────────────────────────────────────────╯ ", ] `; @@ -38,34 +38,38 @@ exports[`note (isCI = false) > don't overflow 1`] = ` exports[`note (isCI = false) > don't overflow with formatter 1`] = ` [ "│ -◇ title ───────────────────────────────────────────────────────────────────╮ -│ │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string * │ -│ │ -├───────────────────────────────────────────────────────────────────────────╯ +◇ title ─────────────────────────────────────────────────────────────────╮ +│ │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string * │ +│ │ +├─────────────────────────────────────────────────────────────────────────╯ ", ] `; @@ -98,6 +102,60 @@ exports[`note (isCI = false) > formatter which adds length works 1`] = ` ] `; +exports[`note (isCI = false) > handle wide characters 1`] = ` +[ + "│ +◇ 这是标题 ─╮ +│ │ +│ 이게 │ +│  첫  │ +│ 번째 │ +│   │ +│ 줄이 │ +│ 에요 │ +│ これ │ +│ は次 │ +│ の行 │ +│ です │ +│ │ +├────────────╯ +", +] +`; + +exports[`note (isCI = false) > handle wide characters with formatter 1`] = ` +[ + "│ +◇ 这是标题 ─╮ +│ │ +│ *  * │ +│ * 이 * │ +│ * 게 * │ +│ *   * │ +│ * 첫 * │ +│ *   * │ +│ * 번 * │ +│ * 째 * │ +│ *   * │ +│ * 줄 * │ +│ * 이 * │ +│ * 에 * │ +│ * 요 * │ +│ *  * │ +│ * こ * │ +│ * れ * │ +│ * は * │ +│ * 次 * │ +│ * の * │ +│ * 行 * │ +│ * で * │ +│ * す * │ +│ │ +├────────────╯ +", +] +`; + exports[`note (isCI = false) > renders as wide as longest line 1`] = ` [ "│ @@ -126,34 +184,34 @@ exports[`note (isCI = false) > renders message with title 1`] = ` exports[`note (isCI = true) > don't overflow 1`] = ` [ "│ -◇ title ────────────────────────────────────────────────────────────────────╮ -│ │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string test string test string test string test string  │ -│ test string test string │ -│ │ -├────────────────────────────────────────────────────────────────────────────╯ +◇ title ───────────────────────────────────────────────────────────────╮ +│ │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string test string  │ +│ test string test string test string test string test string test  │ +│ string test string test string test string test string │ +│ │ +├───────────────────────────────────────────────────────────────────────╯ ", ] `; @@ -161,34 +219,38 @@ exports[`note (isCI = true) > don't overflow 1`] = ` exports[`note (isCI = true) > don't overflow with formatter 1`] = ` [ "│ -◇ title ───────────────────────────────────────────────────────────────────╮ -│ │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string test string  * │ -│ * test string test string test string test string test string test  * │ -│ * string test string test string test string test string * │ -│ │ -├───────────────────────────────────────────────────────────────────────────╯ +◇ title ─────────────────────────────────────────────────────────────────╮ +│ │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string  * │ +│ * test string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string test string test string test string test  * │ +│ * string test string * │ +│ │ +├─────────────────────────────────────────────────────────────────────────╯ ", ] `; @@ -221,6 +283,60 @@ exports[`note (isCI = true) > formatter which adds length works 1`] = ` ] `; +exports[`note (isCI = true) > handle wide characters 1`] = ` +[ + "│ +◇ 这是标题 ─╮ +│ │ +│ 이게 │ +│  첫  │ +│ 번째 │ +│   │ +│ 줄이 │ +│ 에요 │ +│ これ │ +│ は次 │ +│ の行 │ +│ です │ +│ │ +├────────────╯ +", +] +`; + +exports[`note (isCI = true) > handle wide characters with formatter 1`] = ` +[ + "│ +◇ 这是标题 ─╮ +│ │ +│ *  * │ +│ * 이 * │ +│ * 게 * │ +│ *   * │ +│ * 첫 * │ +│ *   * │ +│ * 번 * │ +│ * 째 * │ +│ *   * │ +│ * 줄 * │ +│ * 이 * │ +│ * 에 * │ +│ * 요 * │ +│ *  * │ +│ * こ * │ +│ * れ * │ +│ * は * │ +│ * 次 * │ +│ * の * │ +│ * 行 * │ +│ * で * │ +│ * す * │ +│ │ +├────────────╯ +", +] +`; + exports[`note (isCI = true) > renders as wide as longest line 1`] = ` [ "│ diff --git a/packages/prompts/test/box.test.ts b/packages/prompts/test/box.test.ts index 69cd205d..8c9a2249 100644 --- a/packages/prompts/test/box.test.ts +++ b/packages/prompts/test/box.test.ts @@ -234,4 +234,32 @@ describe.each(['true', 'false'])('box (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('renders wide characters with auto width', () => { + const messages = [ + '이게 첫 번째 줄이에요', + 'これは次の行です', + ]; + prompts.box(messages.join("\n"), '这是标题', { + input, + output, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders wide characters with specified width', () => { + const messages = [ + '이게 첫 번째 줄이에요', + 'これは次の行です', + ]; + prompts.box(messages.join("\n"), '这是标题', { + input, + output, + width: 0.2, + }); + + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/note.test.ts b/packages/prompts/test/note.test.ts index 595b3b20..775b29dc 100644 --- a/packages/prompts/test/note.test.ts +++ b/packages/prompts/test/note.test.ts @@ -66,6 +66,7 @@ describe.each(['true', 'false'])('note (isCI = %s)', (isCI) => { test("don't overflow", () => { const message = `${'test string '.repeat(32)}\n`.repeat(4).trim(); + output.columns = 75; prompts.note(message, 'title', { input, output, @@ -76,6 +77,7 @@ describe.each(['true', 'false'])('note (isCI = %s)', (isCI) => { test("don't overflow with formatter", () => { const message = `${'test string '.repeat(32)}\n`.repeat(4).trim(); + output.columns = 75; prompts.note(message, 'title', { format: (line) => colors.red(`* ${colors.cyan(line)} *`), input, @@ -84,4 +86,33 @@ describe.each(['true', 'false'])('note (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test("handle wide characters", () => { + const messages = [ + '이게 첫 번째 줄이에요', + 'これは次の行です', + ]; + output.columns = 10; + prompts.note(messages.join("\n"), '这是标题', { + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test("handle wide characters with formatter", () => { + const messages = [ + '이게 첫 번째 줄이에요', + 'これは次の行です', + ]; + output.columns = 10; + prompts.note(messages.join("\n"), '这是标题', { + format: (line) => colors.red(`* ${colors.cyan(line)} *`), + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95e45a8b..2c19acf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: specifier: ^1.0.5 version: 1.0.5 devDependencies: + fast-string-width: + specifier: ^1.1.0 + version: 1.1.0 fast-wrap-ansi: specifier: ^0.1.3 version: 0.1.3