create-lesa-app 0.0.1

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/src/ui.tsx ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * 프롬프트 UI — @clack/prompts 의 세로 레일 + ink 내장 박스(`cli-boxes`) 조합.
3
+ * 답한 값은 레일에 남겨 무엇을 입력했는지 계속 보이게 한다.
4
+ * 스텝 전이는 `flow.ts` 순수 함수가 결정한다(여기는 그리기만). 문구는 영어.
5
+ */
6
+
7
+ import { Box, Text, useApp, useInput } from 'ink';
8
+ import React, { useState } from 'react';
9
+
10
+ import { derive } from './derive.ts';
11
+ import { advance, answered, editingField, initialFlow, setField, toInput } from './flow.ts';
12
+
13
+ import type { AppInput } from './derive.ts';
14
+ import type { FlowState } from './flow.ts';
15
+
16
+ const colors = {
17
+ accent: '#FBBF24',
18
+ done: '#2DD4BF',
19
+ doneSoft: '#99F6E4',
20
+ error: '#FB7185',
21
+ /** 힌트·지시문. 종전 #475569 는 어두운 터미널에서 거의 안 보였다. */
22
+ faint: '#94A3B8',
23
+ muted: '#CBD5E1',
24
+ pending: '#64748B',
25
+ /** 세로선·테두리. 종전 #334155 는 배경에 묻혔다. */
26
+ rail: '#7C8DA6',
27
+ softText: '#E2E8F0',
28
+ text: '#F8FAFC',
29
+ } as const;
30
+
31
+ const ENVS = ['development', 'preview', 'production'] as const;
32
+
33
+ const PANEL_WIDTH = 66;
34
+
35
+ /**
36
+ * 세로 레일 — 왼쪽 테두리만 켠 Box 라 내용 높이만큼 `│` 가 알아서 늘어난다.
37
+ * 마커(◆/◇)는 각 절의 첫 줄 안에 넣는다(별도 열로 두면 박스 옆에서 레일이 끊긴다).
38
+ */
39
+ export function Rail({ children }: { children: React.ReactNode }) {
40
+ return (
41
+ <Box
42
+ borderBottom={false}
43
+ borderLeftColor={colors.rail}
44
+ borderRight={false}
45
+ borderStyle="single"
46
+ borderTop={false}
47
+ flexDirection="column"
48
+ marginLeft={2}
49
+ paddingLeft={2}
50
+ >
51
+ {children}
52
+ </Box>
53
+ );
54
+ }
55
+
56
+ /** 레일 위 한 절 — 마커 + 제목, 그 아래 내용. */
57
+ export function Section({
58
+ children,
59
+ color,
60
+ marker,
61
+ title,
62
+ }: {
63
+ children?: React.ReactNode;
64
+ color: string;
65
+ marker: string;
66
+ title: string;
67
+ }) {
68
+ return (
69
+ <>
70
+ <Text color={colors.rail}>{'\u00a0'}</Text>
71
+ <Text>
72
+ <Text color={color}>{`${marker} `}</Text>
73
+ <Text color={colors.softText}>{title}</Text>
74
+ </Text>
75
+ {children}
76
+ </>
77
+ );
78
+ }
79
+
80
+ /** `width` 를 주면 고정폭(입력 칸), 안 주면 내용에 맞춘다(요약 표는 잘리면 안 된다). */
81
+ function Panel({
82
+ children,
83
+ color,
84
+ width,
85
+ }: {
86
+ children: React.ReactNode;
87
+ color: string;
88
+ width?: number;
89
+ }) {
90
+ return (
91
+ // alignSelf 없으면 flex column 의 기본 stretch 로 레일 폭까지 늘어난다.
92
+ <Box
93
+ alignSelf="flex-start"
94
+ borderColor={color}
95
+ borderStyle="round"
96
+ flexDirection="column"
97
+ paddingX={1}
98
+ width={width}
99
+ >
100
+ {children}
101
+ </Box>
102
+ );
103
+ }
104
+
105
+ function Summary({ state }: { state: FlowState }) {
106
+ const fields = derive(toInput(state));
107
+ const rows = [
108
+ {
109
+ label: 'Display name',
110
+ note: fields.displayName ? 'under the app icon' : 'same as project name',
111
+ value: fields.displayName || fields.name,
112
+ },
113
+ { label: 'Project name', note: 'Xcode project · Expo slug', value: fields.name },
114
+ { label: 'Version', note: undefined, value: '0.0.1 (build 1)' },
115
+ {
116
+ label: 'iOS signing',
117
+ note: state.appleTeamId ? 'Apple Team ID' : undefined,
118
+ value: state.appleTeamId || 'Xcode automatic',
119
+ },
120
+ ];
121
+ // 열 폭을 내용에서 잡는다 — slug 이 길어도 어긋나지 않는다.
122
+ const labelWidth = Math.max(...rows.map(row => row.label.length), 'development'.length) + 2;
123
+ const schemeWidth = Math.max(...ENVS.map(env => fields.scheme[env].length), 10) + 3;
124
+
125
+ return (
126
+ <Panel color={colors.done}>
127
+ {rows.map(row => (
128
+ <Text color={colors.muted} key={row.label}>
129
+ {row.label.padEnd(labelWidth)}
130
+ <Text color={colors.text}>{row.value}</Text>
131
+ {row.note ? <Text color={colors.faint}>{` ${row.note}`}</Text> : null}
132
+ </Text>
133
+ ))}
134
+ <Box marginTop={1} />
135
+ <Text color={colors.faint}>
136
+ {`${''.padEnd(labelWidth)}${'URL scheme'.padEnd(schemeWidth)}iOS bundle ID · Android package`}
137
+ </Text>
138
+ {ENVS.map(env => (
139
+ <Text color={colors.muted} key={env}>
140
+ {env.padEnd(labelWidth)}
141
+ <Text color={colors.text}>{fields.scheme[env].padEnd(schemeWidth)}</Text>
142
+ <Text color={colors.text}>{fields.bundleId[env]}</Text>
143
+ </Text>
144
+ ))}
145
+ </Panel>
146
+ );
147
+ }
148
+
149
+ const PROMPTS: Record<string, { hint: (state: FlowState) => string; title: string }> = {
150
+ display: {
151
+ hint: state => `Optional — Enter keeps "${state.name}". Set it to control casing and spacing`,
152
+ title: 'Display name',
153
+ },
154
+ name: {
155
+ hint: () => 'Any language. Lowercase ASCII doubles as the slug; anything else asks for one',
156
+ title: 'App name',
157
+ },
158
+ slug: {
159
+ hint: () => 'Lowercase, digits, hyphens — drives the Xcode project, schemes and bundle id',
160
+ title: 'Slug',
161
+ },
162
+ team: {
163
+ hint: () => 'Optional, iOS only — Enter to skip. Android needs nothing here',
164
+ title: 'Apple Team ID',
165
+ },
166
+ };
167
+
168
+ export const railColors = colors;
169
+
170
+ const BAR_WIDTH = 28;
171
+
172
+ /** 채운 막대 + 개수. 총계를 모르면 막대를 안 그린다. */
173
+ export function Progress({ done, total }: { done: number; total: number }) {
174
+ const filled = Math.round((done / total) * BAR_WIDTH);
175
+ return (
176
+ <Text>
177
+ {' '}
178
+ <Text color={colors.done}>{'█'.repeat(filled)}</Text>
179
+ <Text color={colors.pending}>{'░'.repeat(BAR_WIDTH - filled)}</Text>
180
+ <Text color={colors.muted}>{` ${done}/${total} files`}</Text>
181
+ </Text>
182
+ );
183
+ }
184
+
185
+ /** 생성 단계 목록 — 끝난 것·지금 것·남은 것을 한눈에 보여준다. */
186
+ export function Steps({
187
+ current,
188
+ details,
189
+ labels,
190
+ note,
191
+ progress,
192
+ skipped,
193
+ }: {
194
+ current: number;
195
+ /** 끝난 단계 옆에 붙는 설명 — `216 files` 처럼. */
196
+ details?: Record<number, string>;
197
+ labels: readonly string[];
198
+ /** 진행 중인 단계의 세부 — 총계를 모르는 원격 다운로드 등. */
199
+ note?: string;
200
+ progress?: { done: number; total: number };
201
+ skipped: Set<number>;
202
+ }) {
203
+ const width = Math.max(...labels.map(label => label.length)) + 2;
204
+ return (
205
+ <>
206
+ {labels.map((label, index) => {
207
+ const state =
208
+ skipped.has(index) ? 'skipped' : index < current ? 'done' : index === current ? 'now' : 'todo';
209
+ const color =
210
+ state === 'now' ? colors.accent : state === 'todo' ? colors.pending : colors.done;
211
+ return (
212
+ <React.Fragment key={label}>
213
+ {index === 0 ? <Text color={colors.rail}>{'\u00a0'}</Text> : null}
214
+ <Text>
215
+ <Text color={color}>
216
+ {`${state === 'now' ? '◆' : state === 'todo' ? '○' : state === 'skipped' ? '◌' : '◇'} `}
217
+ </Text>
218
+ <Text color={state === 'todo' ? colors.pending : colors.softText}>
219
+ {details?.[index] || state === 'skipped' ? label.padEnd(width) : label}
220
+ </Text>
221
+ {state === 'skipped' ? (
222
+ <Text color={colors.pending}>{'skipped'}</Text>
223
+ ) : details?.[index] ? (
224
+ <Text color={colors.faint}>{details[index]}</Text>
225
+ ) : null}
226
+ </Text>
227
+ {state === 'now' && progress ? (
228
+ <Progress done={progress.done} total={progress.total} />
229
+ ) : state === 'now' && note ? (
230
+ <Text color={colors.faint}>{` ${note}`}</Text>
231
+ ) : null}
232
+ </React.Fragment>
233
+ );
234
+ })}
235
+ </>
236
+ );
237
+ }
238
+
239
+ /** `┌ create-lesa-app` … `└ <footer>` 껍데기 — 프롬프트·진행·완료가 같은 모양을 쓴다. */
240
+ export function Frame({ children, footer }: { children: React.ReactNode; footer: string }) {
241
+ return (
242
+ <Box flexDirection="column" marginTop={1}>
243
+ <Text>
244
+ <Text color={colors.rail}>{' ┌ '}</Text>
245
+ <Text color={colors.softText}>create-lesa-app</Text>
246
+ </Text>
247
+ <Rail>
248
+ {children}
249
+ <Text color={colors.rail}>{'\u00a0'}</Text>
250
+ </Rail>
251
+ <Text>
252
+ <Text color={colors.rail}>{' └ '}</Text>
253
+ <Text color={colors.faint}>{footer}</Text>
254
+ </Text>
255
+ </Box>
256
+ );
257
+ }
258
+
259
+ export type PromptResult = AppInput;
260
+
261
+ /** 입력만 담당한다 — 생성은 호출부가 한다. */
262
+ export function Prompt({ onDone }: { onDone: (result: PromptResult) => void }) {
263
+ const { exit } = useApp();
264
+ const [state, setState] = useState<FlowState>(initialFlow);
265
+
266
+ const field = editingField(state.step);
267
+ const prompt = PROMPTS[state.step];
268
+ const history = answered(state);
269
+
270
+ useInput((input, key) => {
271
+ if (key.escape || (key.ctrl && input === 'c')) return exit();
272
+
273
+ if (key.backspace || key.delete) {
274
+ if (field) setState(setField(state, state[field].slice(0, -1)));
275
+ return;
276
+ }
277
+
278
+ // 붙여넣기는 여러 글자가 한 번에 오고 줄바꿈이 섞일 수 있다 — 제어문자를 걷어내고
279
+ // 줄바꿈이 섞여 있었으면 Enter 로 본다(안 그러면 값에 \r 이 그대로 들어간다).
280
+ const text = input.replace(/\p{Cc}/gu, '');
281
+ const filled = text && field ? setField(state, state[field] + text) : state;
282
+
283
+ if (!(key.return || /[\r\n]/.test(input))) return setState(filled);
284
+ if (filled.step === 'ready') return onDone(toInput(filled));
285
+ setState(advance(filled));
286
+ });
287
+
288
+ const footer =
289
+ state.step === 'ready'
290
+ ? 'Enter create · Esc cancel'
291
+ : state.step === 'display' || state.step === 'team'
292
+ ? 'Enter skip or continue · Esc cancel'
293
+ : 'Enter continue · Esc cancel';
294
+
295
+ return (
296
+ <Frame footer={footer}>
297
+ {history.map(row => (
298
+ <Section color={colors.done} key={row.label} marker="◇" title={row.label}>
299
+ <Text color={colors.text}>{` ${row.value}`}</Text>
300
+ </Section>
301
+ ))}
302
+
303
+ <Section
304
+ color={state.error ? colors.error : state.step === 'ready' ? colors.done : colors.accent}
305
+ marker="◆"
306
+ title={state.step === 'ready' ? 'Ready to create' : prompt.title}
307
+ >
308
+ {state.step === 'ready' ? (
309
+ <Summary state={state} />
310
+ ) : (
311
+ <>
312
+ <Panel color={state.error ? colors.error : colors.accent} width={PANEL_WIDTH}>
313
+ <Text>
314
+ <Text color={colors.faint}>{'› '}</Text>
315
+ <Text color={colors.text}>{state[field!]}</Text>
316
+ <Text color={colors.accent}>▌</Text>
317
+ </Text>
318
+ </Panel>
319
+ <Text color={colors.faint}>{` ${prompt.hint(state)}`}</Text>
320
+ </>
321
+ )}
322
+ {state.error ? <Text color={colors.error}>{` ${state.error}`}</Text> : null}
323
+ </Section>
324
+ </Frame>
325
+ );
326
+ }