sh-ui-cli 0.86.1 → 0.87.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 +14 -0
- package/package.json +1 -1
- package/src/create/describeTemplate.js +53 -11
- package/src/create/generator.js +68 -7
- package/src/create/templateManifest.js +41 -0
- package/src/mcp.mjs +1 -1
- package/templates/vite-app/_arch/flat/src/App.tsx +13 -0
- package/templates/vite-app/_arch/flat/src/components/layouts/RootLayout.tsx +5 -0
- package/templates/vite-app/_arch/flat/src/components/providers/GlobalProvider/index.tsx +13 -0
- package/templates/vite-app/_arch/flat/src/components/providers/index.tsx +1 -0
- package/templates/vite-app/_arch/flat/src/components/providers/theme/ThemeProvider.tsx +59 -0
- package/templates/vite-app/_arch/flat/src/lib/api/queryClient.ts +12 -0
- package/templates/vite-app/_arch/flat/src/lib/hooks/useTheme.ts +8 -0
- package/templates/vite-app/_arch/flat/src/lib/utils/utils.ts +6 -0
- package/templates/vite-app/_arch/flat/src/main.tsx +10 -0
- package/templates/vite-app/_arch/flat/tsconfig.app.json +19 -0
- package/templates/vite-app/_arch/fsd/src/App.tsx +13 -0
- package/templates/vite-app/_arch/fsd/src/app/layouts/RootLayout.tsx +5 -0
- package/templates/vite-app/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +13 -0
- package/templates/vite-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +59 -0
- package/templates/vite-app/_arch/fsd/src/main.tsx +10 -0
- package/templates/vite-app/_arch/fsd/src/shared/api/queryClient.ts +12 -0
- package/templates/vite-app/_arch/fsd/src/shared/hooks/useTheme.ts +8 -0
- package/templates/vite-app/_arch/fsd/src/shared/lib/utils.ts +6 -0
- package/templates/vite-app/_arch/fsd/tsconfig.app.json +18 -0
- package/templates/vite-app/eslint.config.js +3 -0
- package/templates/vite-app/gitignore +8 -0
- package/templates/vite-app/index.html +22 -0
- package/templates/vite-app/package.json +48 -0
- package/templates/vite-app/src/App.tsx +5 -0
- package/templates/vite-app/src/Home.tsx +7 -0
- package/templates/vite-app/src/main.tsx +9 -0
- package/templates/vite-app/tsconfig.json +7 -0
- package/templates/vite-app/tsconfig.node.json +12 -0
- package/templates/vite-app/vite.config.ts +11 -0
- package/templates/vite-app/vitest.config.ts +13 -0
- package/templates/vite-app/vitest.setup.ts +1 -0
|
@@ -2,6 +2,20 @@
|
|
|
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.87.0",
|
|
7
|
+
"date": "2026-05-14",
|
|
8
|
+
"title": "Vite monorepo — apps/{name} + packages/ui/ui-core 공유 SoT",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`sh-ui-cli create --platform vite --structure monorepo`** — vite 도 monorepo 진입점이 됨. ai-org 같은 단일 vite SPA 시나리오를 넘어, 여러 vite 앱이 packages/ui/ui-core 의 컴포넌트를 공유하는 워크스페이스 구조까지 1급 지원. apps/{name}/ (vite-app 템플릿) + packages/ui/ui-core/ (v0.65 단일 SoT) + packages/ui/ui-apps/ui-{name}/ (tokens-only role) 자동 emit.",
|
|
12
|
+
"**vite-app 템플릿 신설** — packages/cli/templates/vite-app/ 추가 (31 파일, base + flat + fsd overlays). nextjs-app 과 동일 패턴이지만 vite 5 + @tailwindcss/vite + vite-tsconfig-paths 기반. `@workspace/ui-core` / `@workspace/ui-{name}` workspace 별칭은 tsconfig.app.json 단일 SoT 로 핀고정, vite 빌드 타임에는 vite-tsconfig-paths 가 그대로 해석. workspace 별칭과 빌드 별칭이 어긋날 일 없음.",
|
|
13
|
+
"**generateMonorepo 가 platform 분기** — { platform: 'next' | 'vite' } 옵션 추가. vite 일 때 generateViteApp 으로 위임 (next 의 plugin pipeline / next.config 분기 없이 vite.config.ts 의 server.port 만 patch). ui-app-template + monorepo 베이스 + ui-core 는 두 플랫폼이 공유 — 코드 중복 없음.",
|
|
14
|
+
"**describeTemplate vite monorepo 분기** — monorepo 루트 + apps/{name} 베이스 + arch 오버레이 + ui-app 패키지 4 개 그룹으로 미리보기 산출. apps/docs CreateProjectDialog 와 MCP `sh_ui_describe_template` 둘 다 동일 결과 제공.",
|
|
15
|
+
"**스모크 회귀 가드** — `scenario V6` 가 vite 모노레포 스캐폴드의 파일 트리 + tsconfig workspace 별칭 치환 + ui-app role: tokens-only 확정. 빌드 사이클(`pnpm install + pnpm build`)은 Task E manual 에서 fsd/flat 양쪽 PASS — `composite: true` 와 base.json 의 `incremental: false` 충돌 + `clsx`/`tailwind-merge` 누락 두 가지를 사전 차단."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.87.0"
|
|
18
|
+
},
|
|
5
19
|
{
|
|
6
20
|
"version": "0.86.1",
|
|
7
21
|
"date": "2026-05-14",
|
package/package.json
CHANGED
|
@@ -72,22 +72,64 @@ export function describeTemplate(opts = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
if (platform === 'vite') {
|
|
75
|
-
// standalone only in Phase 1. monorepo is added in Task 13.
|
|
76
|
-
const tpl = TEMPLATE_MANIFEST['vite-standalone'];
|
|
77
|
-
if (!tpl) {
|
|
78
|
-
throw new Error("Template manifest missing entry for 'vite-standalone'.");
|
|
79
|
-
}
|
|
80
75
|
const safeArchName = isKnownArch(archName) ? archName : DEFAULT_ARCH;
|
|
81
76
|
const archObj = getArchByName(safeArchName);
|
|
82
77
|
if (!archObj.platforms.includes('vite')) {
|
|
83
78
|
throw new Error(`Arch '${safeArchName}' is not compatible with vite.`);
|
|
84
79
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
|
|
81
|
+
if (structure === 'standalone') {
|
|
82
|
+
const tpl = TEMPLATE_MANIFEST['vite-standalone'];
|
|
83
|
+
if (!tpl) {
|
|
84
|
+
throw new Error("Template manifest missing entry for 'vite-standalone'.");
|
|
85
|
+
}
|
|
86
|
+
const baseFiles = tpl.base.slice();
|
|
87
|
+
const archFiles = (tpl.arches?.[safeArchName] ?? []).slice();
|
|
88
|
+
return finalize([
|
|
89
|
+
makeGroup('base', '베이스 (vite-standalone)', baseFiles),
|
|
90
|
+
makeGroup('arch', `Arch (${safeArchName})`, archFiles),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// monorepo — vite app 변형. Next monorepo 브랜치와 동일 구조이지만 vite-app 템플릿
|
|
95
|
+
// 사용 + 플러그인 없음 (vite 는 아직 plugin 시스템 없음).
|
|
96
|
+
const appName = rawAppName || 'web';
|
|
97
|
+
const viteAppTpl = TEMPLATE_MANIFEST['vite-app'];
|
|
98
|
+
if (!viteAppTpl) {
|
|
99
|
+
throw new Error("Template manifest missing entry for 'vite-app'.");
|
|
100
|
+
}
|
|
101
|
+
const groups = [];
|
|
102
|
+
|
|
103
|
+
groups.push(makeGroup(
|
|
104
|
+
'monorepo',
|
|
105
|
+
'모노레포 루트',
|
|
106
|
+
TEMPLATE_MANIFEST['monorepo'].base.slice(),
|
|
107
|
+
));
|
|
108
|
+
|
|
109
|
+
const prefix = `apps/${appName}/`;
|
|
110
|
+
groups.push(makeGroup(
|
|
111
|
+
`app-base`,
|
|
112
|
+
`apps/${appName} — vite-app 베이스`,
|
|
113
|
+
viteAppTpl.base.map((p) => prefix + p),
|
|
114
|
+
));
|
|
115
|
+
const appArchFiles = (viteAppTpl.arches?.[safeArchName] ?? []).map((p) => prefix + p);
|
|
116
|
+
if (appArchFiles.length > 0) {
|
|
117
|
+
groups.push(makeGroup(
|
|
118
|
+
`app-arch`,
|
|
119
|
+
`apps/${appName} — Arch (${safeArchName})`,
|
|
120
|
+
appArchFiles,
|
|
121
|
+
));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
groups.push(makeGroup(
|
|
125
|
+
'ui-app',
|
|
126
|
+
`packages/ui/ui-apps/ui-${appName}`,
|
|
127
|
+
TEMPLATE_MANIFEST['ui-app-template'].base.map(
|
|
128
|
+
(p) => `packages/ui/ui-apps/ui-${appName}/${p}`,
|
|
129
|
+
),
|
|
130
|
+
));
|
|
131
|
+
|
|
132
|
+
return finalize(groups);
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
// platform === 'next'
|
package/src/create/generator.js
CHANGED
|
@@ -335,11 +335,7 @@ export async function createProject(options = {}) {
|
|
|
335
335
|
if (projectType === 'standalone') {
|
|
336
336
|
await generateViteStandalone(targetDir, projectName, theme, cssFramework, arch, themeBase);
|
|
337
337
|
} else {
|
|
338
|
-
|
|
339
|
-
throw new Error(
|
|
340
|
-
'platform=vite + structure=monorepo 는 아직 구현되지 않았습니다 (Phase 2 — v0.87 예정). ' +
|
|
341
|
-
'standalone 을 사용하거나 platform=next 로 monorepo 를 만든 뒤 vite 앱을 수동으로 추가해주세요.',
|
|
342
|
-
);
|
|
338
|
+
await generateMonorepo(targetDir, projectName, [], { yes: options.yes, theme, css: cssFramework, arch, themeBase, platform: 'vite' });
|
|
343
339
|
}
|
|
344
340
|
|
|
345
341
|
await finalizeProject(targetDir, { dryRun: options.dryRun });
|
|
@@ -772,7 +768,7 @@ async function generateViteStandalone(targetDir, projectName, theme, css, arch,
|
|
|
772
768
|
await patchShUiConfig(path.join(targetDir, 'sh-ui.config.json'), css, themeBase);
|
|
773
769
|
}
|
|
774
770
|
|
|
775
|
-
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase } = {}) {
|
|
771
|
+
async function generateMonorepo(targetDir, projectName, plugins, { yes = false, theme, css, arch, themeBase, platform = 'next' } = {}) {
|
|
776
772
|
await fs.copy(path.join(TEMPLATES_DIR, 'monorepo'), targetDir);
|
|
777
773
|
|
|
778
774
|
// Update root package.json
|
|
@@ -803,7 +799,11 @@ async function generateMonorepo(targetDir, projectName, plugins, { yes = false,
|
|
|
803
799
|
});
|
|
804
800
|
|
|
805
801
|
const appsDir = path.join(targetDir, 'apps', appName);
|
|
806
|
-
|
|
802
|
+
if (platform === 'vite') {
|
|
803
|
+
await generateViteApp(appsDir, appName, port, arch, css);
|
|
804
|
+
} else {
|
|
805
|
+
await generateApp(appsDir, appName, port, plugins, arch, css);
|
|
806
|
+
}
|
|
807
807
|
// generateApp 이 ui-{app} 패키지의 cssFramework 변종까지 처리. 여기선 theme + sh-ui.config.json 만.
|
|
808
808
|
const uiAppDir = path.join(targetDir, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
|
|
809
809
|
await injectCssTheme(uiAppDir, theme);
|
|
@@ -901,6 +901,67 @@ async function generateApp(targetDir, appName, port, plugins, arch, css = 'tailw
|
|
|
901
901
|
}
|
|
902
902
|
}
|
|
903
903
|
|
|
904
|
+
async function generateViteApp(targetDir, appName, port, arch, css = 'tailwind') {
|
|
905
|
+
// 베이스 (arch-neutral) + arch 오버레이 — generateApp 과 동일 패턴.
|
|
906
|
+
await fs.copy(path.join(TEMPLATES_DIR, 'vite-app'), targetDir, {
|
|
907
|
+
filter: (src) => !src.includes(`${path.sep}_arch${path.sep}`) && !src.endsWith(`${path.sep}_arch`),
|
|
908
|
+
});
|
|
909
|
+
await ensureArchCleanup(targetDir);
|
|
910
|
+
await fs.copy(
|
|
911
|
+
path.join(TEMPLATES_DIR, 'vite-app', '_arch', arch.name),
|
|
912
|
+
targetDir,
|
|
913
|
+
{ overwrite: true },
|
|
914
|
+
);
|
|
915
|
+
// vite-app 의 flat overlay 는 src/ 하위 — arch.paths.layouts(next 관용) 앞에 src/ 보정.
|
|
916
|
+
// (generateViteStandalone 의 동일 인라인 가드와 같은 이유 — fsd 는 이미 src/app/layouts.)
|
|
917
|
+
const layoutsPath = arch.paths.layouts.startsWith('src/')
|
|
918
|
+
? arch.paths.layouts
|
|
919
|
+
: `src/${arch.paths.layouts}`;
|
|
920
|
+
const sentinelPath = path.join(targetDir, `${layoutsPath}/RootLayout.tsx`);
|
|
921
|
+
if (!(await fs.pathExists(sentinelPath))) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`arch 오버레이 누락: vite-app + ${arch.name} 의 sentinel 파일(${layoutsPath}/RootLayout.tsx) 이 ${targetDir} 에 없습니다.`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// 워크스페이스 placeholder 치환 — `ui-app-name` → `ui-{appName}`, `app-name` → `{appName}`.
|
|
928
|
+
await replaceInAllFiles(targetDir, 'ui-app-name', `ui-${appName}`);
|
|
929
|
+
await replaceInAllFiles(targetDir, 'app-name', appName);
|
|
930
|
+
|
|
931
|
+
// package.json — name + dep sort
|
|
932
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
933
|
+
const pkg = await fs.readJson(pkgPath);
|
|
934
|
+
pkg.name = appName;
|
|
935
|
+
if (pkg.dependencies) pkg.dependencies = sortObjectKeys(pkg.dependencies);
|
|
936
|
+
if (pkg.devDependencies) pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
|
|
937
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
938
|
+
|
|
939
|
+
// vite.config.ts 의 server.port 를 사용자 지정 port 로 patch.
|
|
940
|
+
// generateMonorepo 가 받은 port 가 next 의 --port 와 같은 의미로 흐른다.
|
|
941
|
+
const viteCfgPath = path.join(targetDir, 'vite.config.ts');
|
|
942
|
+
if (await fs.pathExists(viteCfgPath)) {
|
|
943
|
+
let viteCfg = await fs.readFile(viteCfgPath, 'utf-8');
|
|
944
|
+
viteCfg = viteCfg.replace(/port:\s*\d+/, `port: ${port}`);
|
|
945
|
+
await fs.writeFile(viteCfgPath, viteCfg);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ui-{appName} 패키지 생성 — generateApp 과 동일 패턴 (ui-app-template 카피 후 placeholder 치환).
|
|
949
|
+
const monorepoRoot = path.resolve(targetDir, '..', '..');
|
|
950
|
+
const uiPkgDir = path.join(monorepoRoot, 'packages', 'ui', 'ui-apps', `ui-${appName}`);
|
|
951
|
+
if (!(await fs.pathExists(uiPkgDir))) {
|
|
952
|
+
await fs.copy(path.join(TEMPLATES_DIR, 'ui-app-template'), uiPkgDir);
|
|
953
|
+
await replaceInAllFiles(uiPkgDir, 'ui-app-name', `ui-${appName}`);
|
|
954
|
+
await replaceInAllFiles(uiPkgDir, 'app-name', appName);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// cssFramework 변종 — vite app 디렉토리 + ui-app 패키지 양쪽.
|
|
958
|
+
// 플러그인 없음 (vite 는 아직 플러그인 시스템 없음 — v0.87 스코프 밖).
|
|
959
|
+
await applyCssFrameworkVariant(targetDir, css, { isMonorepo: true, plugins: [], arch });
|
|
960
|
+
if (await fs.pathExists(uiPkgDir)) {
|
|
961
|
+
await applyCssFrameworkVariant(uiPkgDir, css, { isMonorepo: true, plugins: [], arch, isUiPackage: true });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
904
965
|
/**
|
|
905
966
|
* 베이스 템플릿 카피 직후 `_arch/` 잔여 정리.
|
|
906
967
|
*
|
|
@@ -325,6 +325,47 @@ export const TEMPLATE_MANIFEST = {
|
|
|
325
325
|
"tsconfig.json"
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
|
+
"vite-app": {
|
|
329
|
+
"base": [
|
|
330
|
+
"eslint.config.js",
|
|
331
|
+
"gitignore",
|
|
332
|
+
"index.html",
|
|
333
|
+
"package.json",
|
|
334
|
+
"src/App.tsx",
|
|
335
|
+
"src/Home.tsx",
|
|
336
|
+
"src/main.tsx",
|
|
337
|
+
"tsconfig.json",
|
|
338
|
+
"tsconfig.node.json",
|
|
339
|
+
"vite.config.ts",
|
|
340
|
+
"vitest.config.ts",
|
|
341
|
+
"vitest.setup.ts"
|
|
342
|
+
],
|
|
343
|
+
"arches": {
|
|
344
|
+
"flat": [
|
|
345
|
+
"src/App.tsx",
|
|
346
|
+
"src/components/layouts/RootLayout.tsx",
|
|
347
|
+
"src/components/providers/GlobalProvider/index.tsx",
|
|
348
|
+
"src/components/providers/index.tsx",
|
|
349
|
+
"src/components/providers/theme/ThemeProvider.tsx",
|
|
350
|
+
"src/lib/api/queryClient.ts",
|
|
351
|
+
"src/lib/hooks/useTheme.ts",
|
|
352
|
+
"src/lib/utils/utils.ts",
|
|
353
|
+
"src/main.tsx",
|
|
354
|
+
"tsconfig.app.json"
|
|
355
|
+
],
|
|
356
|
+
"fsd": [
|
|
357
|
+
"src/App.tsx",
|
|
358
|
+
"src/app/layouts/RootLayout.tsx",
|
|
359
|
+
"src/app/providers/GlobalProvider/index.tsx",
|
|
360
|
+
"src/app/providers/theme/ThemeProvider.tsx",
|
|
361
|
+
"src/main.tsx",
|
|
362
|
+
"src/shared/api/queryClient.ts",
|
|
363
|
+
"src/shared/hooks/useTheme.ts",
|
|
364
|
+
"src/shared/lib/utils.ts",
|
|
365
|
+
"tsconfig.app.json"
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
},
|
|
328
369
|
"vite-standalone": {
|
|
329
370
|
"base": [
|
|
330
371
|
"CLAUDE.md",
|
package/src/mcp.mjs
CHANGED
|
@@ -366,7 +366,7 @@ export async function startMcpServer() {
|
|
|
366
366
|
"sh_ui_create_project",
|
|
367
367
|
{
|
|
368
368
|
description:
|
|
369
|
-
"빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone) | Flutter. " +
|
|
369
|
+
"빈 폴더에 sh-ui 프로젝트 스캐폴드 — Next.js (standalone/monorepo) | Vite (standalone/monorepo) | Flutter. " +
|
|
370
370
|
`FSD 폴더 구조 + 토큰 + sh-ui.config.json 일괄 생성. 사용자가 '새 프로젝트' / '빈 폴더' / '스캐폴드부터' 류 요청을 하면 이 툴 사용 (Bash 로 npx ${cliName} create 직접 호출보다 우선). ` +
|
|
371
371
|
"**단일 진입점** — theme/plugins/cssFramework/structure 모두 호출 시점에 정해서 한 번에 박는다. 호출 후 sh-ui.config.json/tokens.css 를 손으로 패치하지 말 것 (다음 재스캐폴드 시 유실). " +
|
|
372
372
|
"산출물: theme 인자가 프리셋이면 sh-ui.config.json 의 theme.base 가 그 이름, base64 면 'custom'. paths.styles · paths.tokens 도 자동 박혀서 sh_ui_add_component 가 사후 패치 없이 동작.",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RootLayout } from '@/components/layouts/RootLayout';
|
|
2
|
+
import { GlobalProvider } from '@/components/providers';
|
|
3
|
+
import Home from './Home';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
return (
|
|
7
|
+
<GlobalProvider>
|
|
8
|
+
<RootLayout>
|
|
9
|
+
<Home />
|
|
10
|
+
</RootLayout>
|
|
11
|
+
</GlobalProvider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode, useState } from 'react';
|
|
3
|
+
import { createQueryClient } from '@/lib/api/queryClient';
|
|
4
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
5
|
+
|
|
6
|
+
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
const [queryClient] = useState(() => createQueryClient());
|
|
8
|
+
return (
|
|
9
|
+
<ThemeProvider>
|
|
10
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
11
|
+
</ThemeProvider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GlobalProvider } from './GlobalProvider';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
4
|
+
|
|
5
|
+
type ThemeContextValue = {
|
|
6
|
+
theme: Theme;
|
|
7
|
+
resolvedTheme: 'light' | 'dark';
|
|
8
|
+
setTheme: (theme: Theme) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
const STORAGE_KEY = 'theme';
|
|
14
|
+
|
|
15
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
16
|
+
if (typeof window === 'undefined') return 'light';
|
|
17
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
|
21
|
+
return theme === 'system' ? getSystemTheme() : theme;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
25
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
26
|
+
if (typeof window === 'undefined') return 'system';
|
|
27
|
+
return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const resolved = resolveTheme(theme);
|
|
34
|
+
setResolvedTheme(resolved);
|
|
35
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
36
|
+
}, [theme]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (theme !== 'system') return;
|
|
40
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
41
|
+
const handler = () => {
|
|
42
|
+
const resolved = getSystemTheme();
|
|
43
|
+
setResolvedTheme(resolved);
|
|
44
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
45
|
+
};
|
|
46
|
+
mq.addEventListener('change', handler);
|
|
47
|
+
return () => mq.removeEventListener('change', handler);
|
|
48
|
+
}, [theme]);
|
|
49
|
+
|
|
50
|
+
const setTheme = useCallback((next: Theme) => {
|
|
51
|
+
setThemeState(next);
|
|
52
|
+
if (next === 'system') localStorage.removeItem(STORAGE_KEY);
|
|
53
|
+
else localStorage.setItem(STORAGE_KEY, next);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
|
|
57
|
+
|
|
58
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { ThemeContext } from '@/components/providers/theme/ThemeProvider';
|
|
3
|
+
|
|
4
|
+
export function useTheme() {
|
|
5
|
+
const ctx = useContext(ThemeContext);
|
|
6
|
+
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
|
|
7
|
+
return ctx;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import '@workspace/ui-app-name/globals.css';
|
|
5
|
+
|
|
6
|
+
createRoot(document.getElementById('root')!).render(
|
|
7
|
+
<StrictMode>
|
|
8
|
+
<App />
|
|
9
|
+
</StrictMode>,
|
|
10
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@workspace/typescript-config/react-library.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"incremental": true,
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"baseUrl": ".",
|
|
11
|
+
"paths": {
|
|
12
|
+
"@/lib/*": ["./src/lib/*"],
|
|
13
|
+
"@/components/*": ["./src/components/*"],
|
|
14
|
+
"@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
|
|
15
|
+
"@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GlobalProvider } from '@/app/providers/GlobalProvider';
|
|
2
|
+
import { RootLayout } from '@/app/layouts/RootLayout';
|
|
3
|
+
import Home from './Home';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
return (
|
|
7
|
+
<GlobalProvider>
|
|
8
|
+
<RootLayout>
|
|
9
|
+
<Home />
|
|
10
|
+
</RootLayout>
|
|
11
|
+
</GlobalProvider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode, useState } from 'react';
|
|
3
|
+
import { createQueryClient } from '@/shared/api/queryClient';
|
|
4
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
5
|
+
|
|
6
|
+
export function GlobalProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
const [queryClient] = useState(() => createQueryClient());
|
|
8
|
+
return (
|
|
9
|
+
<ThemeProvider>
|
|
10
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
11
|
+
</ThemeProvider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
4
|
+
|
|
5
|
+
type ThemeContextValue = {
|
|
6
|
+
theme: Theme;
|
|
7
|
+
resolvedTheme: 'light' | 'dark';
|
|
8
|
+
setTheme: (theme: Theme) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
const STORAGE_KEY = 'theme';
|
|
14
|
+
|
|
15
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
16
|
+
if (typeof window === 'undefined') return 'light';
|
|
17
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
|
21
|
+
return theme === 'system' ? getSystemTheme() : theme;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
25
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
26
|
+
if (typeof window === 'undefined') return 'system';
|
|
27
|
+
return (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system';
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => resolveTheme(theme));
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const resolved = resolveTheme(theme);
|
|
34
|
+
setResolvedTheme(resolved);
|
|
35
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
36
|
+
}, [theme]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (theme !== 'system') return;
|
|
40
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
41
|
+
const handler = () => {
|
|
42
|
+
const resolved = getSystemTheme();
|
|
43
|
+
setResolvedTheme(resolved);
|
|
44
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
45
|
+
};
|
|
46
|
+
mq.addEventListener('change', handler);
|
|
47
|
+
return () => mq.removeEventListener('change', handler);
|
|
48
|
+
}, [theme]);
|
|
49
|
+
|
|
50
|
+
const setTheme = useCallback((next: Theme) => {
|
|
51
|
+
setThemeState(next);
|
|
52
|
+
if (next === 'system') localStorage.removeItem(STORAGE_KEY);
|
|
53
|
+
else localStorage.setItem(STORAGE_KEY, next);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
|
|
57
|
+
|
|
58
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import '@workspace/ui-app-name/globals.css';
|
|
5
|
+
|
|
6
|
+
createRoot(document.getElementById('root')!).render(
|
|
7
|
+
<StrictMode>
|
|
8
|
+
<App />
|
|
9
|
+
</StrictMode>,
|
|
10
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { ThemeContext } from '@/app/providers/theme/ThemeProvider';
|
|
3
|
+
|
|
4
|
+
export function useTheme() {
|
|
5
|
+
const ctx = useContext(ThemeContext);
|
|
6
|
+
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
|
|
7
|
+
return ctx;
|
|
8
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@workspace/typescript-config/react-library.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"incremental": true,
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"baseUrl": ".",
|
|
11
|
+
"paths": {
|
|
12
|
+
"@/*": ["./src/*"],
|
|
13
|
+
"@workspace/ui-app-name/*": ["../../packages/ui/ui-apps/ui-app-name/src/*"],
|
|
14
|
+
"@workspace/ui-core/*": ["../../packages/ui/ui-core/src/*"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ko" suppressHydrationWarning>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>sh-ui app</title>
|
|
8
|
+
<script>
|
|
9
|
+
// FOUC 차단 — ThemeProvider mount 전에 첫 paint 에 dark class 박기.
|
|
10
|
+
// matrix: 'dark' → .dark, 'light' → (none), 'system'/unset → system pref.
|
|
11
|
+
try {
|
|
12
|
+
var t = localStorage.getItem('theme');
|
|
13
|
+
var d = t === 'dark' || ((!t || t === 'system') && matchMedia('(prefers-color-scheme:dark)').matches);
|
|
14
|
+
if (d) document.documentElement.classList.add('dark');
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
</script>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="root"></div>
|
|
20
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "app-name",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"lint:fix": "eslint . --fix",
|
|
12
|
+
"typecheck": "tsc -b --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@tanstack/react-query": "^5.90.21",
|
|
18
|
+
"@workspace/ui-app-name": "workspace:*",
|
|
19
|
+
"@workspace/ui-core": "workspace:*",
|
|
20
|
+
"clsx": "^2.1.1",
|
|
21
|
+
"lucide-react": "^0.563.0",
|
|
22
|
+
"react": "^19.2.4",
|
|
23
|
+
"react-dom": "^19.2.4",
|
|
24
|
+
"sonner": "^2.0.7",
|
|
25
|
+
"tailwind-merge": "^3.5.0",
|
|
26
|
+
"zod": "^4.3.6"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
30
|
+
"@tanstack/react-query-devtools": "^5.91.3",
|
|
31
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
32
|
+
"@testing-library/react": "^16",
|
|
33
|
+
"@testing-library/user-event": "^14",
|
|
34
|
+
"@types/node": "^25.1.0",
|
|
35
|
+
"@types/react": "^19.2.10",
|
|
36
|
+
"@types/react-dom": "^19.2.3",
|
|
37
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
38
|
+
"@workspace/eslint-config": "workspace:^",
|
|
39
|
+
"@workspace/typescript-config": "workspace:*",
|
|
40
|
+
"eslint": "^9.39.2",
|
|
41
|
+
"jsdom": "^29.0.0",
|
|
42
|
+
"tailwindcss": "^4.1.18",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"vite": "^5.4.0",
|
|
45
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
46
|
+
"vitest": "^4.1.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"composite": true,
|
|
4
|
+
"skipLibCheck": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"allowSyntheticDefaultImports": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"types": ["node"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["vite.config.ts", "vitest.config.ts", "vitest.setup.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react(), tailwindcss(), tsconfigPaths()],
|
|
8
|
+
server: {
|
|
9
|
+
port: 3000,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig, mergeConfig } from 'vitest/config';
|
|
2
|
+
import viteConfig from './vite.config';
|
|
3
|
+
|
|
4
|
+
export default mergeConfig(
|
|
5
|
+
viteConfig,
|
|
6
|
+
defineConfig({
|
|
7
|
+
test: {
|
|
8
|
+
environment: 'jsdom',
|
|
9
|
+
globals: true,
|
|
10
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
11
|
+
},
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|