sh-ui-cli 0.91.0 → 0.93.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/data/changelog/versions.json +29 -0
- package/package.json +1 -1
- package/src/api.d.ts +6 -0
- package/src/constants.js +11 -0
- package/src/create/cli-args.js +9 -1
- package/src/create/describeTemplate.js +64 -0
- package/src/create/generator.js +380 -7
- package/src/create/index.mjs +11 -2
- package/src/mcp.mjs +74 -0
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.93.0",
|
|
7
|
+
"date": "2026-05-14",
|
|
8
|
+
"title": "vite 프리셋 Sentry observability opt-in (--observability sentry)",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`--observability sentry` 옵션** — vite preset 에 opt-in Sentry 셋업 추가. `@sentry/react` (deps) + `@sentry/vite-plugin` (devDeps) + `Sentry.init({...})` (DSN 가드, browserTracing + replay PII 보호 디폴트) + `SentryProvider` (ErrorBoundary wrap) + `.env.example` 자동 emit. DSN 빈 값이면 init 자동 skip — dev/CI 안전. ai-org 처럼 error tracking 부터 들어가는 워크로드 5분 boilerplate 제거.",
|
|
12
|
+
"**enum 디자인** — `observability: z.enum(['none', 'sentry'])` future-extensible. GlitchTip 등 Sentry-protocol 호환 서비스는 같은 SDK + DSN 만 변경하면 동작 (라이브러리 lock-in 아님). 추후 `glitchtip` / `bugsnag` 등 enum 확장 자연스럽게.",
|
|
13
|
+
"**Source map upload 자동 셋업** — `vite.config.ts` 에 `sentryVitePlugin({ org, project, authToken, disable: !SENTRY_AUTH_TOKEN })` 자동 추가 + `build.sourcemap: true`. SENTRY_AUTH_TOKEN 이 env 에 있으면 빌드 시 source map 자동 업로드, 없으면 plugin disable. CI 친화.",
|
|
14
|
+
"**i18n + Sentry 동시 시 wrapper 순서 보장** — SentryProvider 가 OUTERMOST (모든 children error 캐치) → I18nProvider → ThemeProvider → QueryClientProvider. Smoke V20 가 wrapper index 순서 단언으로 회귀 가드.",
|
|
15
|
+
"**Vite types 자동 reference** — emit 된 `sentry.ts` 가 `/// <reference types=\"vite/client\" />` triple-slash 로 `import.meta.env.VITE_SENTRY_DSN` 타입 정확. 사용자 tsconfig 손댈 필요 X.",
|
|
16
|
+
"**guards + 회귀** — `observability + platform !== 'vite'` 조합은 CLI + MCP 양쪽에서 명시적 Korean 에러. Smoke V18 (positive), V19 (default opt-out 회귀), V20 (i18n + sentry combo wrapper 순서). docs `/create` 페이지도 observability 토글 노출."
|
|
17
|
+
],
|
|
18
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.93.0"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.92.0",
|
|
22
|
+
"date": "2026-05-14",
|
|
23
|
+
"title": "vite 프리셋 react-i18next i18n opt-in (--i18n + --locales)",
|
|
24
|
+
"type": "minor",
|
|
25
|
+
"highlights": [
|
|
26
|
+
"**`--i18n react-i18next` 옵션** — vite preset 에 opt-in i18n 셋업 추가. `sh-ui-cli create --platform vite --i18n react-i18next --locales ko,en` 한 줄로 i18next + react-i18next + browser-languagedetector + http-backend 셋업 + `I18nProvider` + locale JSON 자동 emit. 디폴트 `'none'` 이라 기존 사용자 영향 X. ai-org 처럼 i18n 부터 들어가는 워크로드의 30 초 boilerplate 제거.",
|
|
27
|
+
"**Arch-aware 파일 위치** — fsd 일 때 `src/shared/i18n/` + `src/app/providers/I18nProvider.tsx`, flat 일 때 `src/lib/i18n/` + `src/components/providers/I18nProvider.tsx`. `emitI18n` 헬퍼가 arch descriptor 의 `paths`/`aliases` 기반 경로 자동 결정. 사용자가 `import i18n from '@/shared/i18n'` 또는 `'@/lib/i18n'` 즉시 가능.",
|
|
28
|
+
"**Monorepo + Tauri + i18n 조합 모두 동작** — `--structure monorepo --tauri --i18n react-i18next` 한 번에 OK. `apps/{appName}/` 안에 `src-tauri/` 와 `src/shared/i18n/` 가 같이 emit, 각 의존성이 app 의 `package.json` 에 정확히. `sh-ui-cli add-app` 도 `--i18n` 받음.",
|
|
29
|
+
"**Locale 시드 정책** — 첫 locale (fallbackLng) 만 `{ greeting: 'Hello World', app_title: 'sh-ui app' }` 시드, 나머지는 `{}` 빈 객체로 시작. 사용자가 키 채우는 흐름 자연스럽게. backend (`i18next-http-backend`) 가 `/locales/{lng}/{ns}.json` lazy-load 하므로 dev 부터 namespace 분리 + lazy 동작.",
|
|
30
|
+
"**가드 + 회귀** — `i18n + platform !== 'vite'` 조합은 CLI + MCP 양쪽에서 명시적 Korean 에러. Smoke V15 (fsd + ko,en happy path), V16 (i18n=none 디폴트 회귀 가드 — 어떤 i18n 파일도 안 들어감), V17 (flat + ko,en,ja — 3개 locale 중 첫 locale 만 시드, ja 는 빈 객체). docs `/create` 페이지도 i18n 토글 + locales 인풋 노출."
|
|
31
|
+
],
|
|
32
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.92.0"
|
|
33
|
+
},
|
|
5
34
|
{
|
|
6
35
|
"version": "0.91.0",
|
|
7
36
|
"date": "2026-05-14",
|
package/package.json
CHANGED
package/src/api.d.ts
CHANGED
|
@@ -141,6 +141,12 @@ export interface DescribeTemplateOptions {
|
|
|
141
141
|
appName?: string;
|
|
142
142
|
/** platform=vite + structure=standalone 일 때 Tauri 2.x 셸(`src-tauri/`) 동시 emit. */
|
|
143
143
|
tauri?: boolean;
|
|
144
|
+
/** vite 전용 — react-i18next opt-in. v0.92.0+ */
|
|
145
|
+
i18n?: 'none' | 'react-i18next';
|
|
146
|
+
/** i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en' */
|
|
147
|
+
locales?: string;
|
|
148
|
+
/** vite 전용 — Sentry observability opt-in. v0.93.0+ */
|
|
149
|
+
observability?: 'none' | 'sentry';
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
export interface DescribeTemplateGroup {
|
package/src/constants.js
CHANGED
|
@@ -54,3 +54,14 @@ export const INIT_DEFAULTS = {
|
|
|
54
54
|
mode: 'light-dark',
|
|
55
55
|
cssFramework: CSS_FRAMEWORK_DEFAULT,
|
|
56
56
|
};
|
|
57
|
+
|
|
58
|
+
// ─── i18n (vite preset 전용 — v0.92.0+) ───
|
|
59
|
+
// 'none' 디폴트 — opt-in 으로 react-i18next 설치 + 셋업. 향후 lingui / react-intl 등 추가 시 enum 확장.
|
|
60
|
+
export const I18N_LIBRARIES = ['none', 'react-i18next'];
|
|
61
|
+
export const I18N_DEFAULT = 'none';
|
|
62
|
+
export const I18N_DEFAULT_LOCALES = 'ko,en';
|
|
63
|
+
|
|
64
|
+
// ─── observability (vite preset 전용 — v0.93.0+) ───
|
|
65
|
+
// 'none' 디폴트 — opt-in 으로 @sentry/react + @sentry/vite-plugin 설치 + 셋업.
|
|
66
|
+
// GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 향후 bugsnag 등 추가 시 enum 확장.
|
|
67
|
+
export const OBSERVABILITY_PROVIDERS = ['none', 'sentry'];
|
package/src/create/cli-args.js
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
CREATE_STRUCTURES,
|
|
4
4
|
CSS_FRAMEWORKS_SUPPORTED,
|
|
5
5
|
CSS_FRAMEWORKS_PLANNED,
|
|
6
|
+
I18N_LIBRARIES,
|
|
7
|
+
OBSERVABILITY_PROVIDERS,
|
|
6
8
|
} from '../constants.js';
|
|
7
9
|
import { allPlugins } from './plugins/index.js';
|
|
8
10
|
import { allArchitectures } from './architectures/index.js';
|
|
@@ -12,7 +14,7 @@ const VALID_STRUCTURES = CREATE_STRUCTURES;
|
|
|
12
14
|
const VALID_PLUGINS = allPlugins.map((p) => p.name);
|
|
13
15
|
const VALID_ARCHES = allArchitectures.map((a) => a.name);
|
|
14
16
|
|
|
15
|
-
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port'];
|
|
17
|
+
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app', 'css', 'arch', 'port', 'i18n', 'locales', 'observability'];
|
|
16
18
|
const BOOL_FLAGS = ['yes', 'help', 'dry-run', 'tauri'];
|
|
17
19
|
|
|
18
20
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
|
@@ -75,6 +77,12 @@ export const parseArgs = (argv) => {
|
|
|
75
77
|
`--arch 는 ${VALID_ARCHES.join('/')} 중 하나여야 함 (받은 값: ${value})`,
|
|
76
78
|
);
|
|
77
79
|
}
|
|
80
|
+
if (name === 'i18n' && !I18N_LIBRARIES.includes(value)) {
|
|
81
|
+
throw new Error(`--i18n 은 ${I18N_LIBRARIES.join('/')} 중 하나여야 함 (받은 값: ${value})`);
|
|
82
|
+
}
|
|
83
|
+
if (name === 'observability' && !OBSERVABILITY_PROVIDERS.includes(value)) {
|
|
84
|
+
throw new Error(`--observability 는 ${OBSERVABILITY_PROVIDERS.join('/')} 중 하나여야 함 (받은 값: ${value})`);
|
|
85
|
+
}
|
|
78
86
|
if (name === 'css' && !CSS_FRAMEWORKS_SUPPORTED.includes(value)) {
|
|
79
87
|
// planned 값은 '곧 옵니다' 신호로 분기 — 사용자 의도가 더 명확히 전달.
|
|
80
88
|
if (CSS_FRAMEWORKS_PLANNED.includes(value)) {
|
|
@@ -36,6 +36,9 @@ import { CSS_FRAMEWORK_DEFAULT } from '../constants.js';
|
|
|
36
36
|
* @property {string} [projectName]
|
|
37
37
|
* @property {string} [appName] monorepo 첫 앱 이름. 기본 'web'
|
|
38
38
|
* @property {boolean} [tauri] platform=vite (standalone/monorepo 둘 다) 일 때 Tauri 2.x 셸 같이 emit
|
|
39
|
+
* @property {'none'|'react-i18next'} [i18n] vite 전용 — react-i18next 셋업
|
|
40
|
+
* @property {string} [locales] i18n 활성화 시 생성할 locale 코드 (comma-separated, default 'ko,en')
|
|
41
|
+
* @property {'none'|'sentry'} [observability] vite 전용 — Sentry 셋업 (v0.93.0+)
|
|
39
42
|
*/
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -64,6 +67,9 @@ export function describeTemplate(opts = {}) {
|
|
|
64
67
|
cssFramework = CSS_FRAMEWORK_DEFAULT,
|
|
65
68
|
appName: rawAppName = 'web',
|
|
66
69
|
tauri = false,
|
|
70
|
+
i18n = 'none',
|
|
71
|
+
locales = 'ko,en',
|
|
72
|
+
observability = 'none',
|
|
67
73
|
} = opts;
|
|
68
74
|
|
|
69
75
|
if (platform === 'flutter') {
|
|
@@ -99,6 +105,30 @@ export function describeTemplate(opts = {}) {
|
|
|
99
105
|
const tauriFiles = tauriTpl.base.map((p) => `src-tauri/${p}`);
|
|
100
106
|
groups.push(makeGroup('tauri', 'Tauri 셸 (src-tauri/)', tauriFiles));
|
|
101
107
|
}
|
|
108
|
+
if (i18n === 'react-i18next') {
|
|
109
|
+
const isFsd = safeArchName === 'fsd';
|
|
110
|
+
const i18nBase = isFsd ? 'src/shared/i18n' : 'src/lib/i18n';
|
|
111
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
112
|
+
const localesArr = parseLocalesString(locales);
|
|
113
|
+
const i18nFiles = [
|
|
114
|
+
`${i18nBase}/config.ts`,
|
|
115
|
+
`${i18nBase}/index.ts`,
|
|
116
|
+
...localesArr.map((lng) => `${i18nBase}/locales/${lng}/common.json`),
|
|
117
|
+
`${providersBase}/I18nProvider.tsx`,
|
|
118
|
+
];
|
|
119
|
+
groups.push(makeGroup('i18n', `i18n (${i18n})`, i18nFiles));
|
|
120
|
+
}
|
|
121
|
+
if (observability === 'sentry') {
|
|
122
|
+
const isFsd = safeArchName === 'fsd';
|
|
123
|
+
const obsBase = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
124
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
125
|
+
groups.push(makeGroup('sentry', `Sentry observability`, [
|
|
126
|
+
`${obsBase}/sentry.ts`,
|
|
127
|
+
`${obsBase}/index.ts`,
|
|
128
|
+
`${providersBase}/SentryProvider.tsx`,
|
|
129
|
+
'.env.example',
|
|
130
|
+
]));
|
|
131
|
+
}
|
|
102
132
|
return finalize(groups);
|
|
103
133
|
}
|
|
104
134
|
|
|
@@ -149,6 +179,32 @@ export function describeTemplate(opts = {}) {
|
|
|
149
179
|
groups.push(makeGroup('tauri', `Tauri 셸 (apps/${appName}/src-tauri/)`, tauriFiles));
|
|
150
180
|
}
|
|
151
181
|
|
|
182
|
+
if (i18n === 'react-i18next') {
|
|
183
|
+
const isFsd = safeArchName === 'fsd';
|
|
184
|
+
const i18nBase = isFsd ? 'src/shared/i18n' : 'src/lib/i18n';
|
|
185
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
186
|
+
const localesArr = parseLocalesString(locales);
|
|
187
|
+
const i18nFiles = [
|
|
188
|
+
`apps/${appName}/${i18nBase}/config.ts`,
|
|
189
|
+
`apps/${appName}/${i18nBase}/index.ts`,
|
|
190
|
+
...localesArr.map((lng) => `apps/${appName}/${i18nBase}/locales/${lng}/common.json`),
|
|
191
|
+
`apps/${appName}/${providersBase}/I18nProvider.tsx`,
|
|
192
|
+
];
|
|
193
|
+
groups.push(makeGroup('i18n', `i18n (${i18n}, apps/${appName}/)`, i18nFiles));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (observability === 'sentry') {
|
|
197
|
+
const isFsd = safeArchName === 'fsd';
|
|
198
|
+
const obsBase = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
199
|
+
const providersBase = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
200
|
+
groups.push(makeGroup('sentry', `Sentry observability (apps/${appName}/)`, [
|
|
201
|
+
`apps/${appName}/${obsBase}/sentry.ts`,
|
|
202
|
+
`apps/${appName}/${obsBase}/index.ts`,
|
|
203
|
+
`apps/${appName}/${providersBase}/SentryProvider.tsx`,
|
|
204
|
+
`apps/${appName}/.env.example`,
|
|
205
|
+
]));
|
|
206
|
+
}
|
|
207
|
+
|
|
152
208
|
return finalize(groups);
|
|
153
209
|
}
|
|
154
210
|
|
|
@@ -326,6 +382,14 @@ function makeGroup(id, label, paths) {
|
|
|
326
382
|
return { id, label, paths: paths.slice() };
|
|
327
383
|
}
|
|
328
384
|
|
|
385
|
+
/** locales (string or string[]) 를 정규화 — generator.js 의 parseLocales 와 동일 규칙. */
|
|
386
|
+
function parseLocalesString(s) {
|
|
387
|
+
if (Array.isArray(s)) return s;
|
|
388
|
+
if (typeof s !== 'string') return ['ko', 'en'];
|
|
389
|
+
const arr = s.split(',').map((x) => x.trim().toLowerCase()).filter((x) => /^[a-z]{2}(-[a-z]{2})?$/i.test(x));
|
|
390
|
+
return arr.length > 0 ? arr : ['ko', 'en'];
|
|
391
|
+
}
|
|
392
|
+
|
|
329
393
|
/**
|
|
330
394
|
* 후처리: 그룹 간 dedupe (같은 path 가 여러 그룹에 있으면 마지막 그룹이 소유),
|
|
331
395
|
* 그룹 안에서 path 정렬, 빈 그룹 제거, 전체 파일 목록 계산.
|
package/src/create/generator.js
CHANGED
|
@@ -224,6 +224,20 @@ export async function createProject(options = {}) {
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// i18n 옵션도 vite preset 전용. v0.92.0+.
|
|
228
|
+
if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --i18n none 또는 --platform vite 사용.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// observability 옵션도 vite preset 전용. v0.93.0+.
|
|
235
|
+
if (options.observability && options.observability !== 'none' && platform !== 'vite') {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
227
241
|
// arch 결정 — platform 확정 후. 사용자가 --arch 미지정 시:
|
|
228
242
|
// - next → DEFAULT_ARCH ('fsd')
|
|
229
243
|
// - flutter → 현재 Flutter arch 디스크립터 없음 → null. 미래에 flutter arch 추가되면
|
|
@@ -344,9 +358,21 @@ export async function createProject(options = {}) {
|
|
|
344
358
|
});
|
|
345
359
|
|
|
346
360
|
if (projectType === 'standalone') {
|
|
347
|
-
await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, {
|
|
361
|
+
await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase, {
|
|
362
|
+
tauri: !!options.tauri,
|
|
363
|
+
i18n: options.i18n ?? 'none',
|
|
364
|
+
locales: options.locales ?? 'ko,en',
|
|
365
|
+
observability: options.observability ?? 'none',
|
|
366
|
+
});
|
|
348
367
|
} else {
|
|
349
|
-
await generateMonorepo(targetDir, projectName, [], {
|
|
368
|
+
await generateMonorepo(targetDir, projectName, [], {
|
|
369
|
+
yes: options.yes, theme, css: cssFramework, arch, themeBase,
|
|
370
|
+
platform: 'vite',
|
|
371
|
+
tauri: options.tauri,
|
|
372
|
+
i18n: options.i18n ?? 'none',
|
|
373
|
+
locales: options.locales ?? 'ko,en',
|
|
374
|
+
observability: options.observability ?? 'none',
|
|
375
|
+
});
|
|
350
376
|
}
|
|
351
377
|
|
|
352
378
|
await finalizeProject(targetDir, { dryRun: options.dryRun });
|
|
@@ -507,6 +533,16 @@ export async function addApp(options = {}) {
|
|
|
507
533
|
`tauri 는 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --platform vite 사용 또는 tauri 옵션 제거.`,
|
|
508
534
|
);
|
|
509
535
|
}
|
|
536
|
+
if (options.i18n && options.i18n !== 'none' && platform !== 'vite') {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`i18n='${options.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}).`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (options.observability && options.observability !== 'none' && platform !== 'vite') {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`observability='${options.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${platform}). --observability none 또는 --platform vite 사용.`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
510
546
|
|
|
511
547
|
const appName = validateProjectName(
|
|
512
548
|
options.name ?? await input({
|
|
@@ -557,7 +593,12 @@ export async function addApp(options = {}) {
|
|
|
557
593
|
const arch = assertArchPlatformCompat(DEFAULT_ARCH, platform);
|
|
558
594
|
|
|
559
595
|
if (platform === 'vite') {
|
|
560
|
-
await generateViteApp(appsDir, appName, port, arch, css, {
|
|
596
|
+
await generateViteApp(appsDir, appName, port, arch, css, {
|
|
597
|
+
tauri: !!options.tauri,
|
|
598
|
+
i18n: options.i18n ?? 'none',
|
|
599
|
+
locales: options.locales ?? 'ko,en',
|
|
600
|
+
observability: options.observability ?? 'none',
|
|
601
|
+
});
|
|
561
602
|
} else {
|
|
562
603
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
563
604
|
}
|
|
@@ -820,7 +861,7 @@ async function generateStandalone(targetDir, projectName, plugins, theme, css, a
|
|
|
820
861
|
await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
|
|
821
862
|
}
|
|
822
863
|
|
|
823
|
-
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false } = {}) {
|
|
864
|
+
async function generateViteStandalone(targetDir, projectName, theme, css, arch, themeBase, { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
824
865
|
// 베이스 (arch-neutral) + arch 오버레이 — generateStandalone 과 같은 패턴.
|
|
825
866
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-standalone'), targetDir, {
|
|
826
867
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -861,6 +902,15 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
|
|
|
861
902
|
await emitTauri(targetDir, projectName, { devPort: 5173 });
|
|
862
903
|
await patchViteForTauri(targetDir, { port: 5173 });
|
|
863
904
|
}
|
|
905
|
+
|
|
906
|
+
if (i18n === 'react-i18next') {
|
|
907
|
+
const localesArr = parseLocales(locales);
|
|
908
|
+
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (observability === 'sentry') {
|
|
912
|
+
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
913
|
+
}
|
|
864
914
|
}
|
|
865
915
|
|
|
866
916
|
/**
|
|
@@ -958,7 +1008,321 @@ export default defineConfig({
|
|
|
958
1008
|
}
|
|
959
1009
|
}
|
|
960
1010
|
|
|
961
|
-
|
|
1011
|
+
/**
|
|
1012
|
+
* react-i18next 셋업을 emit. arch 에 따라 경로가 달라짐:
|
|
1013
|
+
* - fsd: src/shared/i18n/* + src/app/providers/I18nProvider.tsx
|
|
1014
|
+
* - flat: src/lib/i18n/* + src/components/providers/I18nProvider.tsx
|
|
1015
|
+
*
|
|
1016
|
+
* 1. i18n/config.ts + i18n/index.ts + locales/{locale}/common.json 생성
|
|
1017
|
+
* 2. providers/I18nProvider.tsx 생성 (<I18nextProvider> wrapper)
|
|
1018
|
+
* 3. GlobalProvider/index.tsx 를 I18nProvider 로 wrapping 하도록 rewrite
|
|
1019
|
+
* 4. package.json 에 i18next 패키지 deps 추가
|
|
1020
|
+
*
|
|
1021
|
+
* @param {string} targetDir — 앱 디렉토리 (standalone 이면 프로젝트 루트, monorepo 이면 apps/{name}/)
|
|
1022
|
+
* @param {object} opts
|
|
1023
|
+
* @param {object} opts.arch — arch descriptor (name + paths/aliases)
|
|
1024
|
+
* @param {string[]} opts.locales — 생성할 locale 코드 배열 (예: ['ko', 'en'])
|
|
1025
|
+
*/
|
|
1026
|
+
async function emitI18n(targetDir, { arch, locales }) {
|
|
1027
|
+
if (!locales || locales.length === 0) {
|
|
1028
|
+
throw new Error('emitI18n: locales 배열이 비어 있습니다.');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const isFsd = arch.name === 'fsd';
|
|
1032
|
+
const i18nDirRel = isFsd ? 'src/shared/i18n' : 'src/lib/i18n';
|
|
1033
|
+
const i18nAlias = isFsd ? '@/shared/i18n' : '@/lib/i18n';
|
|
1034
|
+
const providersDirRel = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
1035
|
+
const apiAlias = isFsd ? '@/shared/api/queryClient' : '@/lib/api/queryClient';
|
|
1036
|
+
|
|
1037
|
+
const i18nDir = path.join(targetDir, i18nDirRel);
|
|
1038
|
+
await fs.ensureDir(i18nDir);
|
|
1039
|
+
await fs.ensureDir(path.join(i18nDir, 'locales'));
|
|
1040
|
+
|
|
1041
|
+
const localesArr = JSON.stringify(locales);
|
|
1042
|
+
const fallbackLng = locales[0];
|
|
1043
|
+
const configTs = `import i18n from 'i18next';
|
|
1044
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
1045
|
+
import HttpBackend from 'i18next-http-backend';
|
|
1046
|
+
import { initReactI18next } from 'react-i18next';
|
|
1047
|
+
|
|
1048
|
+
// 클라이언트 측 lazy-load — 빌드 산출물에서 public/locales/{lng}/{ns}.json 경로로 fetch.
|
|
1049
|
+
// dev 에선 vite 가 ${i18nDirRel}/locales/* 를 /locales 로 serve (vite.config 의 publicDir 기본 'public').
|
|
1050
|
+
// 프로덕션 빌드 시 사용자가 vite-plugin-static-copy 등으로 public/locales 로 카피하거나
|
|
1051
|
+
// 처음부터 public/locales 에 두면 됨. 디폴트 경로 ${i18nDirRel}/locales 는 dev 편의용.
|
|
1052
|
+
|
|
1053
|
+
i18n
|
|
1054
|
+
.use(HttpBackend)
|
|
1055
|
+
.use(LanguageDetector)
|
|
1056
|
+
.use(initReactI18next)
|
|
1057
|
+
.init({
|
|
1058
|
+
fallbackLng: '${fallbackLng}',
|
|
1059
|
+
supportedLngs: ${localesArr},
|
|
1060
|
+
ns: ['common'],
|
|
1061
|
+
defaultNS: 'common',
|
|
1062
|
+
interpolation: { escapeValue: false },
|
|
1063
|
+
backend: {
|
|
1064
|
+
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
|
1065
|
+
},
|
|
1066
|
+
detection: {
|
|
1067
|
+
order: ['localStorage', 'navigator', 'htmlTag'],
|
|
1068
|
+
caches: ['localStorage'],
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
export default i18n;
|
|
1073
|
+
`;
|
|
1074
|
+
await fs.writeFile(path.join(i18nDir, 'config.ts'), configTs);
|
|
1075
|
+
|
|
1076
|
+
await fs.writeFile(
|
|
1077
|
+
path.join(i18nDir, 'index.ts'),
|
|
1078
|
+
`export { default } from './config';\n`,
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
for (const lng of locales) {
|
|
1082
|
+
const localeDir = path.join(i18nDir, 'locales', lng);
|
|
1083
|
+
await fs.ensureDir(localeDir);
|
|
1084
|
+
const seed = lng === fallbackLng
|
|
1085
|
+
? { greeting: 'Hello World', app_title: 'sh-ui app' }
|
|
1086
|
+
: {};
|
|
1087
|
+
await fs.writeFile(
|
|
1088
|
+
path.join(localeDir, 'common.json'),
|
|
1089
|
+
JSON.stringify(seed, null, 2) + '\n',
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const providersDir = path.join(targetDir, providersDirRel);
|
|
1094
|
+
await fs.ensureDir(providersDir);
|
|
1095
|
+
const i18nProvider = `import { type ReactNode } from 'react';
|
|
1096
|
+
import { I18nextProvider } from 'react-i18next';
|
|
1097
|
+
import i18n from '${i18nAlias}';
|
|
1098
|
+
|
|
1099
|
+
export function I18nProvider({ children }: { children: ReactNode }) {
|
|
1100
|
+
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
|
1101
|
+
}
|
|
1102
|
+
`;
|
|
1103
|
+
await fs.writeFile(path.join(providersDir, 'I18nProvider.tsx'), i18nProvider);
|
|
1104
|
+
|
|
1105
|
+
const globalProviderPath = path.join(targetDir, providersDirRel, 'GlobalProvider', 'index.tsx');
|
|
1106
|
+
const globalProvider = `import { QueryClientProvider } from '@tanstack/react-query';
|
|
1107
|
+
import { type ReactNode, useState } from 'react';
|
|
1108
|
+
import { createQueryClient } from '${apiAlias}';
|
|
1109
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
1110
|
+
import { I18nProvider } from '../I18nProvider';
|
|
1111
|
+
|
|
1112
|
+
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
1113
|
+
const [queryClient] = useState(() => createQueryClient());
|
|
1114
|
+
return (
|
|
1115
|
+
<I18nProvider>
|
|
1116
|
+
<ThemeProvider>
|
|
1117
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1118
|
+
</ThemeProvider>
|
|
1119
|
+
</I18nProvider>
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
`;
|
|
1123
|
+
await fs.writeFile(globalProviderPath, globalProvider);
|
|
1124
|
+
|
|
1125
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
1126
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1127
|
+
pkg.dependencies = pkg.dependencies ?? {};
|
|
1128
|
+
pkg.dependencies['i18next'] = '^23.15.1';
|
|
1129
|
+
pkg.dependencies['i18next-browser-languagedetector'] = '^8.0.0';
|
|
1130
|
+
pkg.dependencies['i18next-http-backend'] = '^2.7.1';
|
|
1131
|
+
pkg.dependencies['react-i18next'] = '^15.1.0';
|
|
1132
|
+
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
1133
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* locales 인자 (string or string[]) 를 정규화. comma-separated 또는 array 모두 받음.
|
|
1138
|
+
* 빈 값 / 잘못된 형식이면 디폴트 ['ko', 'en'] fallback.
|
|
1139
|
+
*/
|
|
1140
|
+
function parseLocales(input) {
|
|
1141
|
+
let arr;
|
|
1142
|
+
if (Array.isArray(input)) arr = input;
|
|
1143
|
+
else if (typeof input === 'string') arr = input.split(',');
|
|
1144
|
+
else arr = ['ko', 'en'];
|
|
1145
|
+
const cleaned = arr.map((s) => s.trim().toLowerCase()).filter((s) => /^[a-z]{2}(-[a-z]{2})?$/i.test(s));
|
|
1146
|
+
return cleaned.length > 0 ? cleaned : ['ko', 'en'];
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Sentry observability 셋업 emit (v0.93.0+). vite preset 전용 opt-in.
|
|
1151
|
+
*
|
|
1152
|
+
* - shared/observability/sentry.ts — Sentry.init (DSN 있을 때만)
|
|
1153
|
+
* - shared/observability/index.ts — Sentry + ErrorBoundary re-export
|
|
1154
|
+
* - app/providers/SentryProvider.tsx — ErrorBoundary wrapper
|
|
1155
|
+
* - GlobalProvider 재작성 — Sentry > [I18n?] > Theme > Query 순서
|
|
1156
|
+
* - package.json — @sentry/react + @sentry/vite-plugin 추가
|
|
1157
|
+
* - vite.config.ts — sentryVitePlugin 삽입 + sourcemap: true
|
|
1158
|
+
* - .env.example — Sentry 변수 안내 블록 추가
|
|
1159
|
+
*
|
|
1160
|
+
* @param {string} targetDir — 앱 디렉토리
|
|
1161
|
+
* @param {object} opts
|
|
1162
|
+
* @param {object} opts.arch — arch descriptor
|
|
1163
|
+
* @param {boolean} [opts.i18nActive] — i18n 도 같이 켜져 있는지 (GlobalProvider wrapping 순서)
|
|
1164
|
+
*/
|
|
1165
|
+
async function emitSentry(targetDir, { arch, i18nActive = false }) {
|
|
1166
|
+
const isFsd = arch.name === 'fsd';
|
|
1167
|
+
const obsDirRel = isFsd ? 'src/shared/observability' : 'src/lib/observability';
|
|
1168
|
+
const obsAlias = isFsd ? '@/shared/observability' : '@/lib/observability';
|
|
1169
|
+
const providersDirRel = isFsd ? 'src/app/providers' : 'src/components/providers';
|
|
1170
|
+
const apiAlias = isFsd ? '@/shared/api/queryClient' : '@/lib/api/queryClient';
|
|
1171
|
+
|
|
1172
|
+
const obsDir = path.join(targetDir, obsDirRel);
|
|
1173
|
+
await fs.ensureDir(obsDir);
|
|
1174
|
+
|
|
1175
|
+
const sentryTs = `/// <reference types="vite/client" />
|
|
1176
|
+
import * as Sentry from '@sentry/react';
|
|
1177
|
+
|
|
1178
|
+
// VITE_SENTRY_DSN 이 있을 때만 init — 로컬 dev 에선 자동 skip.
|
|
1179
|
+
// GlitchTip self-hosted 도 같은 SDK — DSN 만 변경.
|
|
1180
|
+
if (import.meta.env.VITE_SENTRY_DSN) {
|
|
1181
|
+
Sentry.init({
|
|
1182
|
+
dsn: import.meta.env.VITE_SENTRY_DSN,
|
|
1183
|
+
release: import.meta.env.VITE_APP_VERSION,
|
|
1184
|
+
environment: import.meta.env.MODE,
|
|
1185
|
+
tracesSampleRate: 0.1,
|
|
1186
|
+
replaysOnErrorSampleRate: 1.0,
|
|
1187
|
+
replaysSessionSampleRate: 0,
|
|
1188
|
+
integrations: [
|
|
1189
|
+
Sentry.browserTracingIntegration(),
|
|
1190
|
+
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
|
1191
|
+
],
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export { Sentry };
|
|
1196
|
+
`;
|
|
1197
|
+
await fs.writeFile(path.join(obsDir, 'sentry.ts'), sentryTs);
|
|
1198
|
+
|
|
1199
|
+
await fs.writeFile(
|
|
1200
|
+
path.join(obsDir, 'index.ts'),
|
|
1201
|
+
`export { Sentry } from './sentry';\nexport { ErrorBoundary } from '@sentry/react';\n`,
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
const providersDir = path.join(targetDir, providersDirRel);
|
|
1205
|
+
await fs.ensureDir(providersDir);
|
|
1206
|
+
const sentryProvider = `import { type ReactNode } from 'react';
|
|
1207
|
+
import { ErrorBoundary } from '${obsAlias}';
|
|
1208
|
+
|
|
1209
|
+
function Fallback({ error }: { error: unknown }) {
|
|
1210
|
+
return (
|
|
1211
|
+
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
1212
|
+
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>오류가 발생했습니다</h1>
|
|
1213
|
+
<p style={{ marginTop: '0.5rem', color: 'var(--foreground-muted)' }}>
|
|
1214
|
+
{error instanceof Error ? error.message : '알 수 없는 오류'}
|
|
1215
|
+
</p>
|
|
1216
|
+
</div>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export function SentryProvider({ children }: { children: ReactNode }) {
|
|
1221
|
+
return (
|
|
1222
|
+
<ErrorBoundary fallback={({ error }) => <Fallback error={error} />}>
|
|
1223
|
+
{children}
|
|
1224
|
+
</ErrorBoundary>
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
`;
|
|
1228
|
+
await fs.writeFile(path.join(providersDir, 'SentryProvider.tsx'), sentryProvider);
|
|
1229
|
+
|
|
1230
|
+
// GlobalProvider rewrite — Sentry outermost, then optional I18n, then Theme + Query.
|
|
1231
|
+
const globalProviderPath = path.join(targetDir, providersDirRel, 'GlobalProvider', 'index.tsx');
|
|
1232
|
+
const i18nImport = i18nActive ? `import { I18nProvider } from '../I18nProvider';\n` : '';
|
|
1233
|
+
const innerOpen = i18nActive ? ' <I18nProvider>\n ' : ' ';
|
|
1234
|
+
const innerClose = i18nActive ? '\n </I18nProvider>' : '';
|
|
1235
|
+
const globalProvider = `import { QueryClientProvider } from '@tanstack/react-query';
|
|
1236
|
+
import { type ReactNode, useState } from 'react';
|
|
1237
|
+
import { createQueryClient } from '${apiAlias}';
|
|
1238
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
1239
|
+
${i18nImport}import { SentryProvider } from '../SentryProvider';
|
|
1240
|
+
|
|
1241
|
+
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
1242
|
+
const [queryClient] = useState(() => createQueryClient());
|
|
1243
|
+
return (
|
|
1244
|
+
<SentryProvider>
|
|
1245
|
+
${innerOpen}<ThemeProvider>
|
|
1246
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1247
|
+
</ThemeProvider>${innerClose}
|
|
1248
|
+
</SentryProvider>
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
`;
|
|
1252
|
+
await fs.writeFile(globalProviderPath, globalProvider);
|
|
1253
|
+
|
|
1254
|
+
// package.json deps
|
|
1255
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
1256
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1257
|
+
pkg.dependencies = pkg.dependencies ?? {};
|
|
1258
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
1259
|
+
pkg.dependencies['@sentry/react'] = '^8.45.0';
|
|
1260
|
+
pkg.devDependencies['@sentry/vite-plugin'] = '^2.22.7';
|
|
1261
|
+
pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
1262
|
+
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
1263
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1264
|
+
|
|
1265
|
+
await patchViteConfigForSentry(targetDir);
|
|
1266
|
+
|
|
1267
|
+
// .env.example
|
|
1268
|
+
const envExamplePath = path.join(targetDir, '.env.example');
|
|
1269
|
+
const envBlock = `# Sentry observability (v0.93.0+) — 비워두면 init 자동 skip.
|
|
1270
|
+
VITE_SENTRY_DSN=
|
|
1271
|
+
VITE_APP_VERSION=
|
|
1272
|
+
# Source map upload (vite build 시).
|
|
1273
|
+
SENTRY_AUTH_TOKEN=
|
|
1274
|
+
SENTRY_ORG=
|
|
1275
|
+
SENTRY_PROJECT=
|
|
1276
|
+
`;
|
|
1277
|
+
if (await fs.pathExists(envExamplePath)) {
|
|
1278
|
+
const existing = await fs.readFile(envExamplePath, 'utf-8');
|
|
1279
|
+
if (!existing.includes('VITE_SENTRY_DSN')) {
|
|
1280
|
+
await fs.writeFile(envExamplePath, existing + '\n' + envBlock);
|
|
1281
|
+
}
|
|
1282
|
+
} else {
|
|
1283
|
+
await fs.writeFile(envExamplePath, envBlock);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function patchViteConfigForSentry(targetDir) {
|
|
1288
|
+
const viteCfgPath = path.join(targetDir, 'vite.config.ts');
|
|
1289
|
+
if (!(await fs.pathExists(viteCfgPath))) {
|
|
1290
|
+
throw new Error(`vite.config.ts 가 ${targetDir} 에 없습니다.`);
|
|
1291
|
+
}
|
|
1292
|
+
let cfg = await fs.readFile(viteCfgPath, 'utf-8');
|
|
1293
|
+
|
|
1294
|
+
if (!cfg.includes("@sentry/vite-plugin")) {
|
|
1295
|
+
cfg = cfg.replace(
|
|
1296
|
+
/import { defineConfig } from 'vite';/,
|
|
1297
|
+
`import { defineConfig } from 'vite';\nimport { sentryVitePlugin } from '@sentry/vite-plugin';`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (!cfg.includes("sentryVitePlugin(")) {
|
|
1302
|
+
cfg = cfg.replace(
|
|
1303
|
+
/(plugins:\s*\[[^\]]*?)(\s*\])/s,
|
|
1304
|
+
(_, before, close) => {
|
|
1305
|
+
const sep = /,\s*$/.test(before) ? ' ' : ', ';
|
|
1306
|
+
return `${before}${sep}sentryVitePlugin({\n org: process.env.SENTRY_ORG,\n project: process.env.SENTRY_PROJECT,\n authToken: process.env.SENTRY_AUTH_TOKEN,\n disable: !process.env.SENTRY_AUTH_TOKEN,\n })${close}`;
|
|
1307
|
+
},
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (!cfg.includes("sourcemap:")) {
|
|
1312
|
+
if (cfg.includes("server: {")) {
|
|
1313
|
+
cfg = cfg.replace(/(\n\s*server:\s*\{)/, `\n build: { sourcemap: true },$1`);
|
|
1314
|
+
} else if (cfg.includes("plugins: [")) {
|
|
1315
|
+
cfg = cfg.replace(
|
|
1316
|
+
/(plugins:\s*\[[^\]]*?\][^,\n]*[,;]?\n)/s,
|
|
1317
|
+
`$1 build: { sourcemap: true },\n`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
await fs.writeFile(viteCfgPath, cfg);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next', tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
962
1326
|
await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
|
|
963
1327
|
|
|
964
1328
|
// Update root package.json
|
|
@@ -990,7 +1354,7 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
990
1354
|
|
|
991
1355
|
const appsDir = path.join(targetDir, 'apps', appName);
|
|
992
1356
|
if (platform === 'vite') {
|
|
993
|
-
await generateViteApp(appsDir, appName, port, arch, css, { tauri });
|
|
1357
|
+
await generateViteApp(appsDir, appName, port, arch, css, { tauri, i18n, locales, observability });
|
|
994
1358
|
} else {
|
|
995
1359
|
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
996
1360
|
}
|
|
@@ -1091,7 +1455,7 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
|
|
|
1091
1455
|
}
|
|
1092
1456
|
}
|
|
1093
1457
|
|
|
1094
|
-
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { tauri = false } = {}) {
|
|
1458
|
+
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind', { tauri = false, i18n = 'none', locales = 'ko,en', observability = 'none' } = {}) {
|
|
1095
1459
|
// 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
|
|
1096
1460
|
await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
|
|
1097
1461
|
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
@@ -1159,6 +1523,15 @@ async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind',
|
|
|
1159
1523
|
await emitTauri(targetDir, appName, { devPort });
|
|
1160
1524
|
await patchViteForTauri(targetDir, { port: devPort });
|
|
1161
1525
|
}
|
|
1526
|
+
|
|
1527
|
+
if (i18n === 'react-i18next') {
|
|
1528
|
+
const localesArr = parseLocales(locales);
|
|
1529
|
+
await emitI18n(targetDir, { arch, locales: localesArr });
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (observability === 'sentry') {
|
|
1533
|
+
await emitSentry(targetDir, { arch, i18nActive: i18n === 'react-i18next' });
|
|
1534
|
+
}
|
|
1162
1535
|
}
|
|
1163
1536
|
|
|
1164
1537
|
/**
|
package/src/create/index.mjs
CHANGED
|
@@ -17,8 +17,8 @@ const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join('|');
|
|
|
17
17
|
export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
|
|
18
18
|
|
|
19
19
|
사용법:
|
|
20
|
-
sh-ui create [name] [options]
|
|
21
|
-
sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--tauri]
|
|
20
|
+
sh-ui create [name] [options] [--observability <none|sentry>]
|
|
21
|
+
sh-ui create add-app [name] [--port <n>] [--platform <next|vite>] [--plugins ..] [--theme ..] [--css ..] [--tauri] [--i18n <react-i18next|none>] [--locales ko,en] [--observability <none|sentry>]
|
|
22
22
|
sh-ui create add-component <name> [--app <name>]
|
|
23
23
|
|
|
24
24
|
옵션:
|
|
@@ -28,6 +28,9 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
28
28
|
--plugins <a,b> 플러그인 (${PLUGINS_LIST}). 미지정/"" → 없음
|
|
29
29
|
--theme <preset|base64> 프리셋 이름(${THEME_PRESETS_LIST}) 또는 playground base64. 선택
|
|
30
30
|
--css <${CSS_FRAMEWORKS_SUPPORTED.join('|')}> CSS 프레임워크. base 파일까지 분기 emit (tailwind/plain/css-modules)
|
|
31
|
+
--i18n <react-i18next|none> vite 전용 — react-i18next 셋업 emit (i18n config + I18nProvider). 기본 none (v0.92.0+)
|
|
32
|
+
--locales <ko,en> i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 'ko,en'
|
|
33
|
+
--observability <none|sentry> vite 전용 — Sentry 셋업 emit (@sentry/react + vite-plugin + SentryProvider). 기본 none (v0.93.0+)
|
|
31
34
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
32
35
|
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
33
36
|
-h, --help 이 도움말
|
|
@@ -75,6 +78,9 @@ export async function runCreate(rest) {
|
|
|
75
78
|
css: flags.css,
|
|
76
79
|
platform: flags.platform,
|
|
77
80
|
tauri: flags.tauri,
|
|
81
|
+
i18n: flags.i18n,
|
|
82
|
+
locales: flags.locales,
|
|
83
|
+
observability: flags.observability,
|
|
78
84
|
});
|
|
79
85
|
} else if (command === 'add-component') {
|
|
80
86
|
// 호환 별칭 — 신규 진입점은 `sh-ui add <name>` (bin/sh-ui.mjs 가 walk-up 으로 라우팅).
|
|
@@ -92,6 +98,9 @@ export async function runCreate(rest) {
|
|
|
92
98
|
theme: flags.theme,
|
|
93
99
|
css: flags.css,
|
|
94
100
|
tauri: flags.tauri,
|
|
101
|
+
i18n: flags.i18n,
|
|
102
|
+
locales: flags.locales,
|
|
103
|
+
observability: flags.observability,
|
|
95
104
|
yes: flags.yes,
|
|
96
105
|
dryRun: flags.dryRun,
|
|
97
106
|
});
|
package/src/mcp.mjs
CHANGED
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
THEME_RADII,
|
|
47
47
|
THEME_MODES,
|
|
48
48
|
CSS_FRAMEWORKS_SUPPORTED,
|
|
49
|
+
I18N_LIBRARIES,
|
|
50
|
+
I18N_DEFAULT_LOCALES,
|
|
51
|
+
OBSERVABILITY_PROVIDERS,
|
|
49
52
|
} from "./constants.js";
|
|
50
53
|
import { allPlugins } from "./create/plugins/index.js";
|
|
51
54
|
import { allArchitectures } from "./create/architectures/index.js";
|
|
@@ -402,6 +405,21 @@ export async function startMcpServer() {
|
|
|
402
405
|
"Rust toolchain (`cargo`/`rustc`) 가 시스템에 설치되어 있어야 첫 `pnpm tauri dev` 가 동작. " +
|
|
403
406
|
"기본 false.",
|
|
404
407
|
),
|
|
408
|
+
i18n: z.enum(I18N_LIBRARIES).optional()
|
|
409
|
+
.describe(
|
|
410
|
+
"i18n 라이브러리 — platform=vite 일 때만 의미. 'react-i18next' 로 설정 시 i18next + react-i18next + browser-languagedetector + http-backend 셋업 + " +
|
|
411
|
+
"providers/I18nProvider.tsx + locales/{lng}/common.json 자동 emit. 기본 'none'. v0.92.0+ 신규.",
|
|
412
|
+
),
|
|
413
|
+
locales: z.string().optional()
|
|
414
|
+
.describe(
|
|
415
|
+
`i18n 활성화 시 생성할 locale 코드 (comma-separated, 2글자 또는 'ko-KR' 류). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. ` +
|
|
416
|
+
"i18n='none' 이면 무시.",
|
|
417
|
+
),
|
|
418
|
+
observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
|
|
419
|
+
.describe(
|
|
420
|
+
"observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
|
|
421
|
+
"@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
|
|
422
|
+
),
|
|
405
423
|
},
|
|
406
424
|
},
|
|
407
425
|
async (input) => {
|
|
@@ -432,6 +450,24 @@ export async function startMcpServer() {
|
|
|
432
450
|
}],
|
|
433
451
|
};
|
|
434
452
|
}
|
|
453
|
+
if (input.i18n && input.i18n !== "none" && input.platform !== "vite") {
|
|
454
|
+
return {
|
|
455
|
+
isError: true,
|
|
456
|
+
content: [{
|
|
457
|
+
type: "text",
|
|
458
|
+
text: `i18n='${input.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
459
|
+
}],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (input.observability && input.observability !== "none" && input.platform !== "vite") {
|
|
463
|
+
return {
|
|
464
|
+
isError: true,
|
|
465
|
+
content: [{
|
|
466
|
+
type: "text",
|
|
467
|
+
text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
468
|
+
}],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
435
471
|
const targetParent = resolveCwd(input);
|
|
436
472
|
const targetDir = resolve(targetParent, input.name);
|
|
437
473
|
if (existsSync(targetDir) && !input.force) {
|
|
@@ -458,6 +494,9 @@ export async function startMcpServer() {
|
|
|
458
494
|
theme: input.theme,
|
|
459
495
|
css: input.cssFramework,
|
|
460
496
|
tauri: input.tauri,
|
|
497
|
+
i18n: input.i18n,
|
|
498
|
+
locales: input.locales,
|
|
499
|
+
observability: input.observability,
|
|
461
500
|
yes: true, // 사전 검사를 마쳤으니 generator 의 confirm 프롬프트 우회
|
|
462
501
|
}),
|
|
463
502
|
);
|
|
@@ -492,6 +531,20 @@ export async function startMcpServer() {
|
|
|
492
531
|
.describe("플랫폼 — next | vite. 미지정 시 기존 apps/* 의 deps 로 추론 (모든 앱이 같은 플랫폼이면 그것으로, 혼재면 명시 필요)."),
|
|
493
532
|
tauri: z.boolean().optional()
|
|
494
533
|
.describe("Tauri 2.x 데스크탑 셸 — platform=vite 일 때만 의미. apps/{name}/src-tauri/ 에 emit. 기본 false."),
|
|
534
|
+
i18n: z.enum(I18N_LIBRARIES).optional()
|
|
535
|
+
.describe(
|
|
536
|
+
"i18n 라이브러리 — platform=vite 일 때만 의미. 'react-i18next' 로 설정 시 i18next + react-i18next + " +
|
|
537
|
+
"browser-languagedetector + http-backend 셋업 + providers/I18nProvider.tsx 자동 emit. 기본 'none'. v0.92.0+ 신규.",
|
|
538
|
+
),
|
|
539
|
+
locales: z.string().optional()
|
|
540
|
+
.describe(
|
|
541
|
+
`i18n 활성화 시 생성할 locale 코드 (comma-separated). 기본 '${I18N_DEFAULT_LOCALES}'. 첫 locale 이 fallbackLng. i18n='none' 이면 무시.`,
|
|
542
|
+
),
|
|
543
|
+
observability: z.enum(OBSERVABILITY_PROVIDERS).optional()
|
|
544
|
+
.describe(
|
|
545
|
+
"observability provider — platform=vite 일 때만 의미. 'sentry' 로 설정 시 @sentry/react + " +
|
|
546
|
+
"@sentry/vite-plugin 셋업 + SentryProvider + .env.example 자동 emit. GlitchTip self-hosted 도 같은 SDK — DSN 만 변경. 기본 'none'.",
|
|
547
|
+
),
|
|
495
548
|
cwd: z.string().optional()
|
|
496
549
|
.describe("모노레포 루트 (pnpm-workspace.yaml 있는 곳). 기본 process.cwd()"),
|
|
497
550
|
},
|
|
@@ -511,6 +564,24 @@ export async function startMcpServer() {
|
|
|
511
564
|
}],
|
|
512
565
|
};
|
|
513
566
|
}
|
|
567
|
+
if (input.i18n && input.i18n !== "none" && input.platform && input.platform !== "vite") {
|
|
568
|
+
return {
|
|
569
|
+
isError: true,
|
|
570
|
+
content: [{
|
|
571
|
+
type: "text",
|
|
572
|
+
text: `i18n='${input.i18n}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
573
|
+
}],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (input.observability && input.observability !== "none" && input.platform && input.platform !== "vite") {
|
|
577
|
+
return {
|
|
578
|
+
isError: true,
|
|
579
|
+
content: [{
|
|
580
|
+
type: "text",
|
|
581
|
+
text: `observability='${input.observability}' 은 platform=vite 일 때만 지원합니다 (현재 platform=${input.platform}).`,
|
|
582
|
+
}],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
514
585
|
const text = await captureConsole(() =>
|
|
515
586
|
addApp({
|
|
516
587
|
name: input.name,
|
|
@@ -520,6 +591,9 @@ export async function startMcpServer() {
|
|
|
520
591
|
css: input.cssFramework,
|
|
521
592
|
platform: input.platform,
|
|
522
593
|
tauri: input.tauri,
|
|
594
|
+
i18n: input.i18n,
|
|
595
|
+
locales: input.locales,
|
|
596
|
+
observability: input.observability,
|
|
523
597
|
cwd: resolveCwd(input),
|
|
524
598
|
}),
|
|
525
599
|
);
|