@mandujs/cli 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +234 -234
- package/README.md +354 -354
- package/package.json +2 -2
- package/src/commands/contract.ts +173 -173
- package/src/commands/dev.ts +8 -68
- package/src/commands/doctor.ts +27 -27
- package/src/commands/guard-arch.ts +303 -303
- package/src/commands/guard-check.ts +3 -3
- package/src/commands/monitor.ts +300 -300
- package/src/commands/openapi.ts +107 -107
- package/src/commands/registry.ts +367 -357
- package/src/commands/routes.ts +228 -228
- package/src/commands/start.ts +184 -0
- package/src/errors/codes.ts +35 -35
- package/src/errors/index.ts +2 -2
- package/src/errors/messages.ts +143 -143
- package/src/hooks/index.ts +17 -17
- package/src/hooks/preaction.ts +256 -256
- package/src/main.ts +37 -34
- package/src/terminal/banner.ts +166 -166
- package/src/terminal/help.ts +306 -306
- package/src/terminal/index.ts +71 -71
- package/src/terminal/output.ts +295 -295
- package/src/terminal/palette.ts +30 -30
- package/src/terminal/progress.ts +327 -327
- package/src/terminal/stream-writer.ts +214 -214
- package/src/terminal/table.ts +354 -354
- package/src/terminal/theme.ts +142 -142
- package/src/util/bun.ts +6 -6
- package/src/util/fs.ts +23 -23
- package/src/util/handlers.ts +96 -0
- package/src/util/manifest.ts +52 -52
- package/src/util/output.ts +22 -22
- package/src/util/port.ts +71 -71
- package/templates/default/AGENTS.md +96 -96
- package/templates/default/app/api/health/route.ts +13 -13
- package/templates/default/app/globals.css +49 -49
- package/templates/default/app/layout.tsx +27 -27
- package/templates/default/app/page.tsx +38 -38
- package/templates/default/package.json +1 -0
- package/templates/default/src/client/shared/lib/utils.ts +16 -16
- package/templates/default/src/client/shared/ui/button.tsx +57 -57
- package/templates/default/src/client/shared/ui/card.tsx +78 -78
- package/templates/default/src/client/shared/ui/index.ts +21 -21
- package/templates/default/src/client/shared/ui/input.tsx +24 -24
- package/templates/default/tests/example.test.ts +58 -58
- package/templates/default/tests/helpers.ts +52 -52
- package/templates/default/tests/setup.ts +9 -9
- package/templates/default/tsconfig.json +12 -14
- package/templates/default/apps/server/main.ts +0 -67
- package/templates/default/apps/web/entry.tsx +0 -35
package/src/terminal/table.ts
CHANGED
|
@@ -1,354 +1,354 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DNA-011: ANSI-aware Table Rendering
|
|
3
|
-
*
|
|
4
|
-
* 터미널 테이블 렌더링
|
|
5
|
-
* - ANSI 색상 코드 인식 (너비 계산에서 제외)
|
|
6
|
-
* - 유니코드 박스 문자 지원
|
|
7
|
-
* - 반응형 너비 조정 (flex 컬럼)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { stripAnsi } from "./theme.js";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 테이블 컬럼 정의
|
|
14
|
-
*/
|
|
15
|
-
export interface TableColumn {
|
|
16
|
-
/** 데이터 키 */
|
|
17
|
-
key: string;
|
|
18
|
-
/** 헤더 텍스트 */
|
|
19
|
-
header: string;
|
|
20
|
-
/** 정렬 (기본: left) */
|
|
21
|
-
align?: "left" | "right" | "center";
|
|
22
|
-
/** 최소 너비 */
|
|
23
|
-
minWidth?: number;
|
|
24
|
-
/** 최대 너비 */
|
|
25
|
-
maxWidth?: number;
|
|
26
|
-
/** 반응형 너비 조정 대상 */
|
|
27
|
-
flex?: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 테두리 스타일
|
|
32
|
-
*/
|
|
33
|
-
export type BorderStyle = "unicode" | "ascii" | "none";
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 테이블 렌더링 옵션
|
|
37
|
-
*/
|
|
38
|
-
export interface RenderTableOptions {
|
|
39
|
-
/** 컬럼 정의 */
|
|
40
|
-
columns: TableColumn[];
|
|
41
|
-
/** 데이터 행 */
|
|
42
|
-
rows: Record<string, unknown>[];
|
|
43
|
-
/** 테두리 스타일 (기본: unicode) */
|
|
44
|
-
border?: BorderStyle;
|
|
45
|
-
/** 최대 테이블 너비 */
|
|
46
|
-
maxWidth?: number;
|
|
47
|
-
/** 헤더 표시 여부 (기본: true) */
|
|
48
|
-
showHeader?: boolean;
|
|
49
|
-
/** 컴팩트 모드 (패딩 최소화) */
|
|
50
|
-
compact?: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* 박스 그리기 문자
|
|
55
|
-
*/
|
|
56
|
-
interface BoxChars {
|
|
57
|
-
tl: string; // top-left
|
|
58
|
-
tr: string; // top-right
|
|
59
|
-
bl: string; // bottom-left
|
|
60
|
-
br: string; // bottom-right
|
|
61
|
-
h: string; // horizontal
|
|
62
|
-
v: string; // vertical
|
|
63
|
-
t: string; // top junction
|
|
64
|
-
b: string; // bottom junction
|
|
65
|
-
ml: string; // middle-left
|
|
66
|
-
mr: string; // middle-right
|
|
67
|
-
m: string; // middle junction
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const UNICODE_BOX: BoxChars = {
|
|
71
|
-
tl: "┌",
|
|
72
|
-
tr: "┐",
|
|
73
|
-
bl: "└",
|
|
74
|
-
br: "┘",
|
|
75
|
-
h: "─",
|
|
76
|
-
v: "│",
|
|
77
|
-
t: "┬",
|
|
78
|
-
b: "┴",
|
|
79
|
-
ml: "├",
|
|
80
|
-
mr: "┤",
|
|
81
|
-
m: "┼",
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const ASCII_BOX: BoxChars = {
|
|
85
|
-
tl: "+",
|
|
86
|
-
tr: "+",
|
|
87
|
-
bl: "+",
|
|
88
|
-
br: "+",
|
|
89
|
-
h: "-",
|
|
90
|
-
v: "|",
|
|
91
|
-
t: "+",
|
|
92
|
-
b: "+",
|
|
93
|
-
ml: "+",
|
|
94
|
-
mr: "+",
|
|
95
|
-
m: "+",
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* ANSI 코드를 제외한 실제 표시 너비 계산
|
|
100
|
-
*/
|
|
101
|
-
function displayWidth(str: string): number {
|
|
102
|
-
return stripAnsi(str).length;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* 문자열 패딩 (ANSI 인식)
|
|
107
|
-
*/
|
|
108
|
-
function padString(
|
|
109
|
-
str: string,
|
|
110
|
-
width: number,
|
|
111
|
-
align: "left" | "right" | "center" = "left"
|
|
112
|
-
): string {
|
|
113
|
-
const visibleLen = displayWidth(str);
|
|
114
|
-
const padding = width - visibleLen;
|
|
115
|
-
|
|
116
|
-
if (padding <= 0) return str;
|
|
117
|
-
|
|
118
|
-
switch (align) {
|
|
119
|
-
case "right":
|
|
120
|
-
return " ".repeat(padding) + str;
|
|
121
|
-
case "center": {
|
|
122
|
-
const left = Math.floor(padding / 2);
|
|
123
|
-
const right = padding - left;
|
|
124
|
-
return " ".repeat(left) + str + " ".repeat(right);
|
|
125
|
-
}
|
|
126
|
-
case "left":
|
|
127
|
-
default:
|
|
128
|
-
return str + " ".repeat(padding);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* 문자열 트렁케이션 (ANSI 인식)
|
|
134
|
-
*/
|
|
135
|
-
function truncateWithAnsi(str: string, maxWidth: number): string {
|
|
136
|
-
const plain = stripAnsi(str);
|
|
137
|
-
if (plain.length <= maxWidth) return str;
|
|
138
|
-
|
|
139
|
-
// ANSI 코드 위치 추적
|
|
140
|
-
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
141
|
-
const ansiCodes: { index: number; code: string }[] = [];
|
|
142
|
-
let match;
|
|
143
|
-
while ((match = ansiRegex.exec(str)) !== null) {
|
|
144
|
-
ansiCodes.push({ index: match.index, code: match[0] });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// plain text 기준으로 트렁케이션
|
|
148
|
-
const truncatedPlain = plain.slice(0, maxWidth - 1) + "…";
|
|
149
|
-
|
|
150
|
-
// ANSI 코드가 없으면 그대로 반환
|
|
151
|
-
if (ansiCodes.length === 0) return truncatedPlain;
|
|
152
|
-
|
|
153
|
-
// ANSI 코드 재삽입 (복잡하므로 단순화)
|
|
154
|
-
// 첫 번째와 마지막 ANSI 코드만 보존
|
|
155
|
-
const firstCode = ansiCodes[0]?.code || "";
|
|
156
|
-
const resetCode = "\x1b[0m";
|
|
157
|
-
|
|
158
|
-
if (firstCode && ansiCodes.length > 0) {
|
|
159
|
-
return firstCode + truncatedPlain + resetCode;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return truncatedPlain;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* 컬럼 너비 계산
|
|
167
|
-
*/
|
|
168
|
-
function calculateWidths(
|
|
169
|
-
columns: TableColumn[],
|
|
170
|
-
rows: Record<string, unknown>[],
|
|
171
|
-
maxWidth?: number,
|
|
172
|
-
compact?: boolean,
|
|
173
|
-
border: BorderStyle = "unicode"
|
|
174
|
-
): number[] {
|
|
175
|
-
const padding = compact ? 1 : 2;
|
|
176
|
-
|
|
177
|
-
// 각 컬럼의 초기 너비 계산
|
|
178
|
-
const widths = columns.map((col) => {
|
|
179
|
-
const headerWidth = displayWidth(col.header);
|
|
180
|
-
const maxCellWidth = Math.max(
|
|
181
|
-
0,
|
|
182
|
-
...rows.map((row) => displayWidth(String(row[col.key] ?? "")))
|
|
183
|
-
);
|
|
184
|
-
const contentWidth = Math.max(headerWidth, maxCellWidth);
|
|
185
|
-
|
|
186
|
-
let width = contentWidth + padding;
|
|
187
|
-
|
|
188
|
-
if (col.minWidth) width = Math.max(width, col.minWidth);
|
|
189
|
-
if (col.maxWidth) width = Math.min(width, col.maxWidth);
|
|
190
|
-
|
|
191
|
-
return width;
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// 최대 너비 제약 처리
|
|
195
|
-
if (maxWidth) {
|
|
196
|
-
const borderWidth = border === "none" ? 0 : columns.length + 1; // 세로선 개수
|
|
197
|
-
const totalWidth = widths.reduce((a, b) => a + b, 0) + borderWidth;
|
|
198
|
-
|
|
199
|
-
if (totalWidth > maxWidth) {
|
|
200
|
-
const overflow = totalWidth - maxWidth;
|
|
201
|
-
const flexIndices = columns
|
|
202
|
-
.map((c, i) => (c.flex ? i : -1))
|
|
203
|
-
.filter((i) => i >= 0);
|
|
204
|
-
|
|
205
|
-
if (flexIndices.length > 0) {
|
|
206
|
-
// flex 컬럼에서 균등 축소
|
|
207
|
-
const shrinkPerColumn = Math.ceil(overflow / flexIndices.length);
|
|
208
|
-
for (const idx of flexIndices) {
|
|
209
|
-
const minW = columns[idx].minWidth ?? 5;
|
|
210
|
-
widths[idx] = Math.max(minW, widths[idx] - shrinkPerColumn);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return widths;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* 테이블 렌더링
|
|
221
|
-
*
|
|
222
|
-
* @example
|
|
223
|
-
* ```ts
|
|
224
|
-
* const output = renderTable({
|
|
225
|
-
* columns: [
|
|
226
|
-
* { key: "name", header: "Name", minWidth: 10 },
|
|
227
|
-
* { key: "status", header: "Status", align: "center" },
|
|
228
|
-
* { key: "size", header: "Size", align: "right" },
|
|
229
|
-
* ],
|
|
230
|
-
* rows: [
|
|
231
|
-
* { name: "file1.ts", status: "✓", size: "1.2KB" },
|
|
232
|
-
* { name: "file2.ts", status: "✗", size: "3.4KB" },
|
|
233
|
-
* ],
|
|
234
|
-
* border: "unicode",
|
|
235
|
-
* });
|
|
236
|
-
*
|
|
237
|
-
* // ┌────────────┬────────┬───────┐
|
|
238
|
-
* // │ Name │ Status │ Size │
|
|
239
|
-
* // ├────────────┼────────┼───────┤
|
|
240
|
-
* // │ file1.ts │ ✓ │ 1.2KB │
|
|
241
|
-
* // │ file2.ts │ ✗ │ 3.4KB │
|
|
242
|
-
* // └────────────┴────────┴───────┘
|
|
243
|
-
* ```
|
|
244
|
-
*/
|
|
245
|
-
export function renderTable(options: RenderTableOptions): string {
|
|
246
|
-
const {
|
|
247
|
-
columns,
|
|
248
|
-
rows,
|
|
249
|
-
border = "unicode",
|
|
250
|
-
maxWidth,
|
|
251
|
-
showHeader = true,
|
|
252
|
-
compact = false,
|
|
253
|
-
} = options;
|
|
254
|
-
|
|
255
|
-
if (columns.length === 0) return "";
|
|
256
|
-
|
|
257
|
-
const widths = calculateWidths(columns, rows, maxWidth, compact, border);
|
|
258
|
-
const box = border === "unicode" ? UNICODE_BOX : ASCII_BOX;
|
|
259
|
-
const lines: string[] = [];
|
|
260
|
-
|
|
261
|
-
// 수평선 생성
|
|
262
|
-
const createLine = (
|
|
263
|
-
left: string,
|
|
264
|
-
mid: string,
|
|
265
|
-
right: string,
|
|
266
|
-
fill: string
|
|
267
|
-
): string => {
|
|
268
|
-
const segments = widths.map((w) => fill.repeat(w));
|
|
269
|
-
return left + segments.join(mid) + right;
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
// 데이터 행 생성
|
|
273
|
-
const createRow = (data: Record<string, unknown>): string => {
|
|
274
|
-
const cells = columns.map((col, i) => {
|
|
275
|
-
let value = String(data[col.key] ?? "");
|
|
276
|
-
const width = widths[i];
|
|
277
|
-
|
|
278
|
-
// 트렁케이션
|
|
279
|
-
if (displayWidth(value) > width - (compact ? 1 : 2)) {
|
|
280
|
-
value = truncateWithAnsi(value, width - (compact ? 1 : 2));
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// 패딩
|
|
284
|
-
const padded = padString(value, width - (compact ? 0 : 1), col.align);
|
|
285
|
-
return compact ? padded : " " + padded;
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
if (border === "none") {
|
|
289
|
-
return cells.join(" ");
|
|
290
|
-
}
|
|
291
|
-
return box.v + cells.join(box.v) + box.v;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
// 상단 테두리
|
|
295
|
-
if (border !== "none") {
|
|
296
|
-
lines.push(createLine(box.tl, box.t, box.tr, box.h));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// 헤더
|
|
300
|
-
if (showHeader) {
|
|
301
|
-
const headerRow: Record<string, unknown> = {};
|
|
302
|
-
for (const col of columns) {
|
|
303
|
-
headerRow[col.key] = col.header;
|
|
304
|
-
}
|
|
305
|
-
lines.push(createRow(headerRow));
|
|
306
|
-
|
|
307
|
-
// 헤더 구분선
|
|
308
|
-
if (border !== "none") {
|
|
309
|
-
lines.push(createLine(box.ml, box.m, box.mr, box.h));
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// 데이터 행
|
|
314
|
-
for (const row of rows) {
|
|
315
|
-
lines.push(createRow(row));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// 하단 테두리
|
|
319
|
-
if (border !== "none") {
|
|
320
|
-
lines.push(createLine(box.bl, box.b, box.br, box.h));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return lines.join("\n");
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* 간단한 리스트 테이블 (키-값 쌍)
|
|
328
|
-
*
|
|
329
|
-
* @example
|
|
330
|
-
* ```ts
|
|
331
|
-
* renderKeyValueTable([
|
|
332
|
-
* { key: "Name", value: "Mandu" },
|
|
333
|
-
* { key: "Version", value: "0.11.0" },
|
|
334
|
-
* ]);
|
|
335
|
-
* // ┌─────────┬─────────┐
|
|
336
|
-
* // │ Name │ Mandu │
|
|
337
|
-
* // │ Version │ 0.11.0 │
|
|
338
|
-
* // └─────────┴─────────┘
|
|
339
|
-
* ```
|
|
340
|
-
*/
|
|
341
|
-
export function renderKeyValueTable(
|
|
342
|
-
items: { key: string; value: string }[],
|
|
343
|
-
options: { border?: BorderStyle; maxWidth?: number } = {}
|
|
344
|
-
): string {
|
|
345
|
-
return renderTable({
|
|
346
|
-
columns: [
|
|
347
|
-
{ key: "key", header: "Key" },
|
|
348
|
-
{ key: "value", header: "Value", flex: true },
|
|
349
|
-
],
|
|
350
|
-
rows: items,
|
|
351
|
-
showHeader: false,
|
|
352
|
-
...options,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* DNA-011: ANSI-aware Table Rendering
|
|
3
|
+
*
|
|
4
|
+
* 터미널 테이블 렌더링
|
|
5
|
+
* - ANSI 색상 코드 인식 (너비 계산에서 제외)
|
|
6
|
+
* - 유니코드 박스 문자 지원
|
|
7
|
+
* - 반응형 너비 조정 (flex 컬럼)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { stripAnsi } from "./theme.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 테이블 컬럼 정의
|
|
14
|
+
*/
|
|
15
|
+
export interface TableColumn {
|
|
16
|
+
/** 데이터 키 */
|
|
17
|
+
key: string;
|
|
18
|
+
/** 헤더 텍스트 */
|
|
19
|
+
header: string;
|
|
20
|
+
/** 정렬 (기본: left) */
|
|
21
|
+
align?: "left" | "right" | "center";
|
|
22
|
+
/** 최소 너비 */
|
|
23
|
+
minWidth?: number;
|
|
24
|
+
/** 최대 너비 */
|
|
25
|
+
maxWidth?: number;
|
|
26
|
+
/** 반응형 너비 조정 대상 */
|
|
27
|
+
flex?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 테두리 스타일
|
|
32
|
+
*/
|
|
33
|
+
export type BorderStyle = "unicode" | "ascii" | "none";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 테이블 렌더링 옵션
|
|
37
|
+
*/
|
|
38
|
+
export interface RenderTableOptions {
|
|
39
|
+
/** 컬럼 정의 */
|
|
40
|
+
columns: TableColumn[];
|
|
41
|
+
/** 데이터 행 */
|
|
42
|
+
rows: Record<string, unknown>[];
|
|
43
|
+
/** 테두리 스타일 (기본: unicode) */
|
|
44
|
+
border?: BorderStyle;
|
|
45
|
+
/** 최대 테이블 너비 */
|
|
46
|
+
maxWidth?: number;
|
|
47
|
+
/** 헤더 표시 여부 (기본: true) */
|
|
48
|
+
showHeader?: boolean;
|
|
49
|
+
/** 컴팩트 모드 (패딩 최소화) */
|
|
50
|
+
compact?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 박스 그리기 문자
|
|
55
|
+
*/
|
|
56
|
+
interface BoxChars {
|
|
57
|
+
tl: string; // top-left
|
|
58
|
+
tr: string; // top-right
|
|
59
|
+
bl: string; // bottom-left
|
|
60
|
+
br: string; // bottom-right
|
|
61
|
+
h: string; // horizontal
|
|
62
|
+
v: string; // vertical
|
|
63
|
+
t: string; // top junction
|
|
64
|
+
b: string; // bottom junction
|
|
65
|
+
ml: string; // middle-left
|
|
66
|
+
mr: string; // middle-right
|
|
67
|
+
m: string; // middle junction
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const UNICODE_BOX: BoxChars = {
|
|
71
|
+
tl: "┌",
|
|
72
|
+
tr: "┐",
|
|
73
|
+
bl: "└",
|
|
74
|
+
br: "┘",
|
|
75
|
+
h: "─",
|
|
76
|
+
v: "│",
|
|
77
|
+
t: "┬",
|
|
78
|
+
b: "┴",
|
|
79
|
+
ml: "├",
|
|
80
|
+
mr: "┤",
|
|
81
|
+
m: "┼",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ASCII_BOX: BoxChars = {
|
|
85
|
+
tl: "+",
|
|
86
|
+
tr: "+",
|
|
87
|
+
bl: "+",
|
|
88
|
+
br: "+",
|
|
89
|
+
h: "-",
|
|
90
|
+
v: "|",
|
|
91
|
+
t: "+",
|
|
92
|
+
b: "+",
|
|
93
|
+
ml: "+",
|
|
94
|
+
mr: "+",
|
|
95
|
+
m: "+",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ANSI 코드를 제외한 실제 표시 너비 계산
|
|
100
|
+
*/
|
|
101
|
+
function displayWidth(str: string): number {
|
|
102
|
+
return stripAnsi(str).length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 문자열 패딩 (ANSI 인식)
|
|
107
|
+
*/
|
|
108
|
+
function padString(
|
|
109
|
+
str: string,
|
|
110
|
+
width: number,
|
|
111
|
+
align: "left" | "right" | "center" = "left"
|
|
112
|
+
): string {
|
|
113
|
+
const visibleLen = displayWidth(str);
|
|
114
|
+
const padding = width - visibleLen;
|
|
115
|
+
|
|
116
|
+
if (padding <= 0) return str;
|
|
117
|
+
|
|
118
|
+
switch (align) {
|
|
119
|
+
case "right":
|
|
120
|
+
return " ".repeat(padding) + str;
|
|
121
|
+
case "center": {
|
|
122
|
+
const left = Math.floor(padding / 2);
|
|
123
|
+
const right = padding - left;
|
|
124
|
+
return " ".repeat(left) + str + " ".repeat(right);
|
|
125
|
+
}
|
|
126
|
+
case "left":
|
|
127
|
+
default:
|
|
128
|
+
return str + " ".repeat(padding);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 문자열 트렁케이션 (ANSI 인식)
|
|
134
|
+
*/
|
|
135
|
+
function truncateWithAnsi(str: string, maxWidth: number): string {
|
|
136
|
+
const plain = stripAnsi(str);
|
|
137
|
+
if (plain.length <= maxWidth) return str;
|
|
138
|
+
|
|
139
|
+
// ANSI 코드 위치 추적
|
|
140
|
+
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
141
|
+
const ansiCodes: { index: number; code: string }[] = [];
|
|
142
|
+
let match;
|
|
143
|
+
while ((match = ansiRegex.exec(str)) !== null) {
|
|
144
|
+
ansiCodes.push({ index: match.index, code: match[0] });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// plain text 기준으로 트렁케이션
|
|
148
|
+
const truncatedPlain = plain.slice(0, maxWidth - 1) + "…";
|
|
149
|
+
|
|
150
|
+
// ANSI 코드가 없으면 그대로 반환
|
|
151
|
+
if (ansiCodes.length === 0) return truncatedPlain;
|
|
152
|
+
|
|
153
|
+
// ANSI 코드 재삽입 (복잡하므로 단순화)
|
|
154
|
+
// 첫 번째와 마지막 ANSI 코드만 보존
|
|
155
|
+
const firstCode = ansiCodes[0]?.code || "";
|
|
156
|
+
const resetCode = "\x1b[0m";
|
|
157
|
+
|
|
158
|
+
if (firstCode && ansiCodes.length > 0) {
|
|
159
|
+
return firstCode + truncatedPlain + resetCode;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return truncatedPlain;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 컬럼 너비 계산
|
|
167
|
+
*/
|
|
168
|
+
function calculateWidths(
|
|
169
|
+
columns: TableColumn[],
|
|
170
|
+
rows: Record<string, unknown>[],
|
|
171
|
+
maxWidth?: number,
|
|
172
|
+
compact?: boolean,
|
|
173
|
+
border: BorderStyle = "unicode"
|
|
174
|
+
): number[] {
|
|
175
|
+
const padding = compact ? 1 : 2;
|
|
176
|
+
|
|
177
|
+
// 각 컬럼의 초기 너비 계산
|
|
178
|
+
const widths = columns.map((col) => {
|
|
179
|
+
const headerWidth = displayWidth(col.header);
|
|
180
|
+
const maxCellWidth = Math.max(
|
|
181
|
+
0,
|
|
182
|
+
...rows.map((row) => displayWidth(String(row[col.key] ?? "")))
|
|
183
|
+
);
|
|
184
|
+
const contentWidth = Math.max(headerWidth, maxCellWidth);
|
|
185
|
+
|
|
186
|
+
let width = contentWidth + padding;
|
|
187
|
+
|
|
188
|
+
if (col.minWidth) width = Math.max(width, col.minWidth);
|
|
189
|
+
if (col.maxWidth) width = Math.min(width, col.maxWidth);
|
|
190
|
+
|
|
191
|
+
return width;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// 최대 너비 제약 처리
|
|
195
|
+
if (maxWidth) {
|
|
196
|
+
const borderWidth = border === "none" ? 0 : columns.length + 1; // 세로선 개수
|
|
197
|
+
const totalWidth = widths.reduce((a, b) => a + b, 0) + borderWidth;
|
|
198
|
+
|
|
199
|
+
if (totalWidth > maxWidth) {
|
|
200
|
+
const overflow = totalWidth - maxWidth;
|
|
201
|
+
const flexIndices = columns
|
|
202
|
+
.map((c, i) => (c.flex ? i : -1))
|
|
203
|
+
.filter((i) => i >= 0);
|
|
204
|
+
|
|
205
|
+
if (flexIndices.length > 0) {
|
|
206
|
+
// flex 컬럼에서 균등 축소
|
|
207
|
+
const shrinkPerColumn = Math.ceil(overflow / flexIndices.length);
|
|
208
|
+
for (const idx of flexIndices) {
|
|
209
|
+
const minW = columns[idx].minWidth ?? 5;
|
|
210
|
+
widths[idx] = Math.max(minW, widths[idx] - shrinkPerColumn);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return widths;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 테이블 렌더링
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const output = renderTable({
|
|
225
|
+
* columns: [
|
|
226
|
+
* { key: "name", header: "Name", minWidth: 10 },
|
|
227
|
+
* { key: "status", header: "Status", align: "center" },
|
|
228
|
+
* { key: "size", header: "Size", align: "right" },
|
|
229
|
+
* ],
|
|
230
|
+
* rows: [
|
|
231
|
+
* { name: "file1.ts", status: "✓", size: "1.2KB" },
|
|
232
|
+
* { name: "file2.ts", status: "✗", size: "3.4KB" },
|
|
233
|
+
* ],
|
|
234
|
+
* border: "unicode",
|
|
235
|
+
* });
|
|
236
|
+
*
|
|
237
|
+
* // ┌────────────┬────────┬───────┐
|
|
238
|
+
* // │ Name │ Status │ Size │
|
|
239
|
+
* // ├────────────┼────────┼───────┤
|
|
240
|
+
* // │ file1.ts │ ✓ │ 1.2KB │
|
|
241
|
+
* // │ file2.ts │ ✗ │ 3.4KB │
|
|
242
|
+
* // └────────────┴────────┴───────┘
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function renderTable(options: RenderTableOptions): string {
|
|
246
|
+
const {
|
|
247
|
+
columns,
|
|
248
|
+
rows,
|
|
249
|
+
border = "unicode",
|
|
250
|
+
maxWidth,
|
|
251
|
+
showHeader = true,
|
|
252
|
+
compact = false,
|
|
253
|
+
} = options;
|
|
254
|
+
|
|
255
|
+
if (columns.length === 0) return "";
|
|
256
|
+
|
|
257
|
+
const widths = calculateWidths(columns, rows, maxWidth, compact, border);
|
|
258
|
+
const box = border === "unicode" ? UNICODE_BOX : ASCII_BOX;
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
|
|
261
|
+
// 수평선 생성
|
|
262
|
+
const createLine = (
|
|
263
|
+
left: string,
|
|
264
|
+
mid: string,
|
|
265
|
+
right: string,
|
|
266
|
+
fill: string
|
|
267
|
+
): string => {
|
|
268
|
+
const segments = widths.map((w) => fill.repeat(w));
|
|
269
|
+
return left + segments.join(mid) + right;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// 데이터 행 생성
|
|
273
|
+
const createRow = (data: Record<string, unknown>): string => {
|
|
274
|
+
const cells = columns.map((col, i) => {
|
|
275
|
+
let value = String(data[col.key] ?? "");
|
|
276
|
+
const width = widths[i];
|
|
277
|
+
|
|
278
|
+
// 트렁케이션
|
|
279
|
+
if (displayWidth(value) > width - (compact ? 1 : 2)) {
|
|
280
|
+
value = truncateWithAnsi(value, width - (compact ? 1 : 2));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 패딩
|
|
284
|
+
const padded = padString(value, width - (compact ? 0 : 1), col.align);
|
|
285
|
+
return compact ? padded : " " + padded;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (border === "none") {
|
|
289
|
+
return cells.join(" ");
|
|
290
|
+
}
|
|
291
|
+
return box.v + cells.join(box.v) + box.v;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// 상단 테두리
|
|
295
|
+
if (border !== "none") {
|
|
296
|
+
lines.push(createLine(box.tl, box.t, box.tr, box.h));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 헤더
|
|
300
|
+
if (showHeader) {
|
|
301
|
+
const headerRow: Record<string, unknown> = {};
|
|
302
|
+
for (const col of columns) {
|
|
303
|
+
headerRow[col.key] = col.header;
|
|
304
|
+
}
|
|
305
|
+
lines.push(createRow(headerRow));
|
|
306
|
+
|
|
307
|
+
// 헤더 구분선
|
|
308
|
+
if (border !== "none") {
|
|
309
|
+
lines.push(createLine(box.ml, box.m, box.mr, box.h));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 데이터 행
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
lines.push(createRow(row));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 하단 테두리
|
|
319
|
+
if (border !== "none") {
|
|
320
|
+
lines.push(createLine(box.bl, box.b, box.br, box.h));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 간단한 리스트 테이블 (키-값 쌍)
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* renderKeyValueTable([
|
|
332
|
+
* { key: "Name", value: "Mandu" },
|
|
333
|
+
* { key: "Version", value: "0.11.0" },
|
|
334
|
+
* ]);
|
|
335
|
+
* // ┌─────────┬─────────┐
|
|
336
|
+
* // │ Name │ Mandu │
|
|
337
|
+
* // │ Version │ 0.11.0 │
|
|
338
|
+
* // └─────────┴─────────┘
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export function renderKeyValueTable(
|
|
342
|
+
items: { key: string; value: string }[],
|
|
343
|
+
options: { border?: BorderStyle; maxWidth?: number } = {}
|
|
344
|
+
): string {
|
|
345
|
+
return renderTable({
|
|
346
|
+
columns: [
|
|
347
|
+
{ key: "key", header: "Key" },
|
|
348
|
+
{ key: "value", header: "Value", flex: true },
|
|
349
|
+
],
|
|
350
|
+
rows: items,
|
|
351
|
+
showHeader: false,
|
|
352
|
+
...options,
|
|
353
|
+
});
|
|
354
|
+
}
|