sh-ui-cli 0.32.1 → 0.34.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 +7 -1
- package/src/api.d.ts +42 -0
- package/src/api.js +21 -0
- package/src/constants.js +31 -0
- package/src/create/cli-args.js +13 -6
- package/src/create/generator.js +39 -2
- package/src/create/index.mjs +11 -4
- package/src/create/plugins/authJwt.js +2 -0
- package/src/create/plugins/index.js +5 -0
- package/src/create/plugins/nextIntl.js +2 -0
- package/src/create/plugins/pluginSchema.js +81 -0
- package/src/create/plugins/sentry.js +2 -0
- package/src/init.mjs +13 -10
- package/src/mcp.mjs +18 -8
- package/templates/nextjs-app/package.json +2 -0
- package/templates/nextjs-app/src/shared/lib/formatDate.ts +22 -0
- package/templates/nextjs-app/src/shared/lib/formatPrice.ts +10 -0
- package/templates/nextjs-app/src/shared/lib/getQueryClient.ts +14 -0
- package/templates/nextjs-app/src/shared/test/createTestQueryClient.ts +18 -0
- package/templates/nextjs-app/src/shared/test/index.ts +2 -0
- package/templates/nextjs-app/src/shared/test/renderWithProviders.tsx +40 -0
- package/templates/nextjs-app/src/shared/ui/FallbackBoundary/index.tsx +89 -0
- package/templates/nextjs-app/src/shared/ui/PrefetchBoundary/index.tsx +35 -0
- package/templates/nextjs-standalone/package.json +2 -0
- package/templates/nextjs-standalone/src/shared/lib/formatDate.ts +22 -0
- package/templates/nextjs-standalone/src/shared/lib/formatPrice.ts +10 -0
- package/templates/nextjs-standalone/src/shared/lib/getQueryClient.ts +14 -0
- package/templates/nextjs-standalone/src/shared/test/createTestQueryClient.ts +18 -0
- package/templates/nextjs-standalone/src/shared/test/index.ts +2 -0
- package/templates/nextjs-standalone/src/shared/test/renderWithProviders.tsx +40 -0
- package/templates/nextjs-standalone/src/shared/ui/FallbackBoundary/index.tsx +89 -0
- package/templates/nextjs-standalone/src/shared/ui/PrefetchBoundary/index.tsx +35 -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.34.0",
|
|
7
|
+
"date": "2026-04-29",
|
|
8
|
+
"title": "Next 템플릿 — RSC prefetch / 에러 바운더리 / 테스트 헬퍼 + KRW·날짜 포맷 유틸 기본 탑재",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"shared/ui/PrefetchBoundary + shared/lib/getQueryClient — RSC 에서 prefetch 후 dehydrate → 클라이언트 hydrate. 단일/배열 fetchOptions 모두 지원, 이미 깔린 TanStack Query 인프라와 즉시 호환",
|
|
12
|
+
"shared/ui/FallbackBoundary — Suspense + ErrorBoundary 합성, QueryErrorResetBoundary 와 연동돼 errorFallback 의 resetErrorBoundary 가 쿼리까지 함께 리셋. onError 콜백으로 외부 옵저버빌리티 (Sentry 등) 훅인 자유",
|
|
13
|
+
"shared/test/renderWithProviders + createTestQueryClient — RTL render + QueryClientProvider + userEvent 한 번에. 기존 vitest 인프라에 컴포넌트 테스트 진입장벽 제거",
|
|
14
|
+
"shared/lib/formatDate / formatPrice — 로케일 기반 Intl 포맷 유틸. formatPrice 는 한국 원화(KRW, 소수점 없음) 기본",
|
|
15
|
+
"@testing-library/react + user-event devDeps 추가 (양쪽 nextjs 템플릿)"
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.34.0"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.33.0",
|
|
21
|
+
"date": "2026-04-29",
|
|
22
|
+
"title": "중앙관리형 SSOT — sh-ui-cli/api 노출 + 플러그인 스키마 + --dry-run + CI 스캐폴드 게이트",
|
|
23
|
+
"type": "minor",
|
|
24
|
+
"highlights": [
|
|
25
|
+
"신규 public API — `import { allPlugins, CREATE_PLATFORMS, ... } from 'sh-ui-cli/api'`. 다른 워크스페이스 (apps/docs 등) 가 플러그인 메타와 enum 을 동기화 없이 사용. CreateProjectDialog / 사이드바 / 플러그인 허브 가 모두 derive 하도록 변경 — 새 플러그인 추가 시 plugins/ 폴더 한 군데만 건드리면 됨",
|
|
26
|
+
"신규 --dry-run 플래그 — 실제 파일 쓰지 않고 스캐폴드 결과 파일 목록만 출력. 디버깅 + CI 검증 보조. tmpdir 에 생성 후 즉시 정리해 사용자 cwd 무영향",
|
|
27
|
+
"플러그인 manifest zod 스키마 검증 — 모듈 로드 시점에 모든 플러그인 형태 체크. 'src/proxy.ts' 같은 잘못된 경로는 빌드 타임 거부 (v0.32.0 에서 발생한 회귀 차단)",
|
|
28
|
+
"CI 스캐폴드 게이트 — publish 전에 5가지 플러그인 조합으로 실제 스캐폴드 → fresh install → tsc --noEmit 까지 돌리는 스모크 단계 추가. 사용자가 받게 될 결과물을 publish 직전에 검증",
|
|
29
|
+
"통합 테스트 7개 추가 — 각 플러그인 + 조합의 핵심 파일 위치 / 내용 / 합성 검증 (proxy.ts root 위치, 합성된 proxy.ts 의 intl + 인증 가드, observability 의 Sentry vs no-op, axios 부재 등)",
|
|
30
|
+
"내부 정리 — packages/cli/src/constants.js 신설로 PLATFORMS/STRUCTURES/THEME_* 단일화. cli-args / mcp / init / generator 도움말 모두 derive"
|
|
31
|
+
],
|
|
32
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.33.0"
|
|
33
|
+
},
|
|
5
34
|
{
|
|
6
35
|
"version": "0.32.1",
|
|
7
36
|
"date": "2026-04-29",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sh-ui-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
"bin": {
|
|
41
41
|
"sh-ui": "./bin/sh-ui.mjs"
|
|
42
42
|
},
|
|
43
|
+
"exports": {
|
|
44
|
+
"./api": {
|
|
45
|
+
"types": "./src/api.d.ts",
|
|
46
|
+
"default": "./src/api.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
43
49
|
"scripts": {
|
|
44
50
|
"bundle-data": "node scripts/copy-data.mjs",
|
|
45
51
|
"test": "vitest run",
|
package/src/api.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 외부 노출 API 의 타입 선언.
|
|
3
|
+
* apps/docs 등 TypeScript 사용자가 자동완성과 타입 안전을 받을 수 있게.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type CreatePlatform = 'next' | 'flutter';
|
|
7
|
+
export type CreateStructure = 'standalone' | 'monorepo';
|
|
8
|
+
export type InitPlatform = 'react' | 'flutter';
|
|
9
|
+
export type ThemeBase = 'neutral' | 'zinc' | 'slate';
|
|
10
|
+
export type ThemeRadius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
11
|
+
export type ThemeMode = 'light-dark' | 'light' | 'dark';
|
|
12
|
+
|
|
13
|
+
export const CREATE_PLATFORMS: readonly CreatePlatform[];
|
|
14
|
+
export const CREATE_STRUCTURES: readonly CreateStructure[];
|
|
15
|
+
export const INIT_PLATFORMS: readonly InitPlatform[];
|
|
16
|
+
export const THEME_BASES: readonly ThemeBase[];
|
|
17
|
+
export const THEME_RADII: readonly ThemeRadius[];
|
|
18
|
+
export const THEME_MODES: readonly ThemeMode[];
|
|
19
|
+
|
|
20
|
+
export const INIT_DEFAULTS: {
|
|
21
|
+
platform: InitPlatform;
|
|
22
|
+
base: ThemeBase;
|
|
23
|
+
radius: ThemeRadius;
|
|
24
|
+
mode: ThemeMode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PluginManifest = {
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
priority: number;
|
|
32
|
+
dependencies?: Record<string, string>;
|
|
33
|
+
devDependencies?: Record<string, string>;
|
|
34
|
+
envVars?: string[];
|
|
35
|
+
turboEnvVars?: string[];
|
|
36
|
+
imports?: string[];
|
|
37
|
+
providerImports?: string[];
|
|
38
|
+
providerWrappers?: Array<{ open: string; close: string } | string>;
|
|
39
|
+
files?: Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const allPlugins: readonly PluginManifest[];
|
package/src/api.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 외부 노출 API.
|
|
3
|
+
*
|
|
4
|
+
* apps/docs 같은 다른 워크스페이스 패키지가 플러그인 메타데이터와 enum 을
|
|
5
|
+
* 동기화 없이 사용하도록. package.json 의 "exports": { "./api": "./src/api.js" }
|
|
6
|
+
* 로 노출되며, 사용자는 다음과 같이 import:
|
|
7
|
+
*
|
|
8
|
+
* import { allPlugins, CREATE_PLATFORMS } from 'sh-ui-cli/api';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
CREATE_PLATFORMS,
|
|
13
|
+
CREATE_STRUCTURES,
|
|
14
|
+
INIT_PLATFORMS,
|
|
15
|
+
THEME_BASES,
|
|
16
|
+
THEME_RADII,
|
|
17
|
+
THEME_MODES,
|
|
18
|
+
INIT_DEFAULTS,
|
|
19
|
+
} from './constants.js';
|
|
20
|
+
|
|
21
|
+
export { allPlugins } from './create/plugins/index.js';
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-ui-cli 의 모든 enum / 상수 단일 진실.
|
|
3
|
+
*
|
|
4
|
+
* 다른 모듈 (cli-args, mcp, init, generator) 과 외부 패키지 (apps/docs) 는
|
|
5
|
+
* 이 파일을 import 해서 사용한다. 새 값 추가 시 여기만 고치면 된다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── 프로젝트 생성 (sh-ui-cli create) ───
|
|
9
|
+
|
|
10
|
+
export const CREATE_PLATFORMS = ['next', 'flutter'];
|
|
11
|
+
|
|
12
|
+
export const CREATE_STRUCTURES = ['standalone', 'monorepo'];
|
|
13
|
+
|
|
14
|
+
// ─── 초기화 (sh-ui-cli init) — sh-ui 컴포넌트 설정 ───
|
|
15
|
+
|
|
16
|
+
export const INIT_PLATFORMS = ['react', 'flutter'];
|
|
17
|
+
|
|
18
|
+
export const THEME_BASES = ['neutral', 'zinc', 'slate'];
|
|
19
|
+
|
|
20
|
+
export const THEME_RADII = ['none', 'sm', 'md', 'lg', 'xl', 'full'];
|
|
21
|
+
|
|
22
|
+
export const THEME_MODES = ['light-dark', 'light', 'dark'];
|
|
23
|
+
|
|
24
|
+
// ─── 기본값 ───
|
|
25
|
+
|
|
26
|
+
export const INIT_DEFAULTS = {
|
|
27
|
+
platform: 'react',
|
|
28
|
+
base: 'neutral',
|
|
29
|
+
radius: 'md',
|
|
30
|
+
mode: 'light-dark',
|
|
31
|
+
};
|
package/src/create/cli-args.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
|
|
2
|
+
import { allPlugins } from './plugins/index.js';
|
|
3
|
+
|
|
4
|
+
const VALID_PLATFORMS = CREATE_PLATFORMS;
|
|
5
|
+
const VALID_STRUCTURES = CREATE_STRUCTURES;
|
|
6
|
+
const VALID_PLUGINS = allPlugins.map((p) => p.name);
|
|
4
7
|
|
|
5
8
|
const VALUE_FLAGS = ['platform', 'structure', 'plugins', 'theme', 'app'];
|
|
6
|
-
const BOOL_FLAGS = ['yes', 'help'];
|
|
9
|
+
const BOOL_FLAGS = ['yes', 'help', 'dry-run'];
|
|
7
10
|
|
|
8
11
|
const SUBCOMMANDS = ['add-app', 'add-component'];
|
|
9
12
|
|
|
@@ -29,7 +32,9 @@ export const parseArgs = (argv) => {
|
|
|
29
32
|
}
|
|
30
33
|
const name = arg.slice(2);
|
|
31
34
|
if (BOOL_FLAGS.includes(name)) {
|
|
32
|
-
|
|
35
|
+
// dry-run 은 dryRun 으로 캐멀 케이스
|
|
36
|
+
const key = name === 'dry-run' ? 'dryRun' : name;
|
|
37
|
+
flags[key] = true;
|
|
33
38
|
continue;
|
|
34
39
|
}
|
|
35
40
|
if (!VALUE_FLAGS.includes(name)) {
|
|
@@ -44,7 +49,9 @@ export const parseArgs = (argv) => {
|
|
|
44
49
|
const list = value === '' ? [] : value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
45
50
|
for (const p of list) {
|
|
46
51
|
if (!VALID_PLUGINS.includes(p)) {
|
|
47
|
-
throw new Error(
|
|
52
|
+
throw new Error(
|
|
53
|
+
`알 수 없는 플러그인: ${p} (지원: ${VALID_PLUGINS.join(', ')})`,
|
|
54
|
+
);
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
flags.plugins = list;
|
package/src/create/generator.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { input, select, checkbox, confirm } from '@inquirer/prompts';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
|
|
6
7
|
import { decodeTheme } from './theme/decode.js';
|
|
@@ -53,9 +54,13 @@ export async function createProject(options = {}) {
|
|
|
53
54
|
|
|
54
55
|
const theme = options.theme ? decodeTheme(options.theme) : null;
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
// dry-run 은 tmpdir 에 그대로 생성한 뒤 파일 목록 출력 + 정리.
|
|
58
|
+
// 사용자 cwd 를 건드리지 않으면서 실제 generation 흐름을 그대로 검증한다.
|
|
59
|
+
const targetDir = options.dryRun
|
|
60
|
+
? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-dry-'))
|
|
61
|
+
: path.resolve(process.cwd(), projectName);
|
|
57
62
|
|
|
58
|
-
if (await fs.pathExists(targetDir)) {
|
|
63
|
+
if (!options.dryRun && await fs.pathExists(targetDir)) {
|
|
59
64
|
if (options.yes) {
|
|
60
65
|
await fs.remove(targetDir);
|
|
61
66
|
} else {
|
|
@@ -102,12 +107,44 @@ export async function createProject(options = {}) {
|
|
|
102
107
|
await generateMonorepo(targetDir, projectName, plugins, { yes: options.yes, theme });
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
if (options.dryRun) {
|
|
111
|
+
const files = await listAllFiles(targetDir);
|
|
112
|
+
console.log(`\n[DRY RUN] ${projectName} 스캐폴드 시 작성될 파일 (${files.length}개):\n`);
|
|
113
|
+
for (const f of files.sort()) {
|
|
114
|
+
console.log(` ${f}`);
|
|
115
|
+
}
|
|
116
|
+
await fs.remove(targetDir);
|
|
117
|
+
console.log(`\n실제 스캐폴드: --dry-run 제거 후 같은 명령 실행.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
console.log(`\n✅ ${projectName} 프로젝트가 생성되었습니다!`);
|
|
106
122
|
console.log(`\n cd ${projectName}`);
|
|
107
123
|
console.log(' pnpm install');
|
|
108
124
|
console.log(' pnpm dev\n');
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
/**
|
|
128
|
+
* targetDir 아래 모든 파일을 상대 경로로 나열. node_modules 등은 자동 스킵
|
|
129
|
+
* (스캐폴드 직후라 install 안 한 상태기 때문).
|
|
130
|
+
*/
|
|
131
|
+
async function listAllFiles(targetDir) {
|
|
132
|
+
const collected = [];
|
|
133
|
+
async function walk(dir) {
|
|
134
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const full = path.join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
await walk(full);
|
|
139
|
+
} else if (entry.isFile()) {
|
|
140
|
+
collected.push(path.relative(targetDir, full));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await walk(targetDir);
|
|
145
|
+
return collected;
|
|
146
|
+
}
|
|
147
|
+
|
|
111
148
|
// ─── Add app to existing monorepo ───
|
|
112
149
|
|
|
113
150
|
export async function addApp() {
|
package/src/create/index.mjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { parseArgs } from './cli-args.js';
|
|
4
4
|
import { createProject, addApp, addComponent } from './generator.js';
|
|
5
|
+
import { allPlugins } from './plugins/index.js';
|
|
6
|
+
import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
|
|
7
|
+
|
|
8
|
+
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
9
|
+
const PLUGINS_LIST = PLUGIN_NAMES.join(', ');
|
|
5
10
|
|
|
6
11
|
export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
|
|
7
12
|
|
|
@@ -11,11 +16,12 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
11
16
|
sh-ui create add-component <name> [--app <name>]
|
|
12
17
|
|
|
13
18
|
옵션:
|
|
14
|
-
--platform
|
|
15
|
-
--structure
|
|
16
|
-
--plugins <a,b> 플러그인 (
|
|
19
|
+
--platform <${CREATE_PLATFORMS.join('|')}> 타겟 플랫폼
|
|
20
|
+
--structure <${CREATE_STRUCTURES.join('|')}> Next.js 프로젝트 구조 (next 일 때)
|
|
21
|
+
--plugins <a,b> 플러그인 (${PLUGINS_LIST}). 미지정/"" → 없음
|
|
17
22
|
--theme <base64> 테마 JSON (base64). 선택
|
|
18
23
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
24
|
+
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
19
25
|
-h, --help 이 도움말
|
|
20
26
|
|
|
21
27
|
예 (대화형):
|
|
@@ -23,7 +29,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
23
29
|
|
|
24
30
|
예 (비대화형 / 에이전트 / CI):
|
|
25
31
|
sh-ui create my-app --platform next --structure standalone --yes
|
|
26
|
-
sh-ui create my-app --platform next --structure monorepo --plugins
|
|
32
|
+
sh-ui create my-app --platform next --structure monorepo --plugins ${PLUGIN_NAMES.slice(0, 3).join(',')} --yes
|
|
27
33
|
sh-ui create my-app --platform flutter --yes
|
|
28
34
|
|
|
29
35
|
비대화형 환경(TTY 없음)에서는 누락된 필수 인자가 있으면 prompt 대신 에러로 종료한다.
|
|
@@ -62,6 +68,7 @@ export async function runCreate(rest) {
|
|
|
62
68
|
plugins: flags.plugins,
|
|
63
69
|
theme: flags.theme,
|
|
64
70
|
yes: flags.yes,
|
|
71
|
+
dryRun: flags.dryRun,
|
|
65
72
|
});
|
|
66
73
|
}
|
|
67
74
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const authJwtPlugin = {
|
|
2
2
|
name: 'auth-jwt',
|
|
3
3
|
label: '쿠키 기반 JWT 인증 (refresh 자리표시자 포함)',
|
|
4
|
+
description:
|
|
5
|
+
'쿠키 기반 JWT 인증. Next 16 proxy.ts 미들웨어, refresh-aware BFF, withAuthRetry 헬퍼. refresh 본문은 placeholder — 백엔드 명세 확정 후 한 파일 채우면 자동 활성화.',
|
|
4
6
|
priority: 2,
|
|
5
7
|
|
|
6
8
|
// 의존성 추가 없음 — 베이스의 fetch + cookies + react-query 만 사용
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { sentryPlugin } from './sentry.js';
|
|
2
2
|
import { nextIntlPlugin } from './nextIntl.js';
|
|
3
3
|
import { authJwtPlugin } from './authJwt.js';
|
|
4
|
+
import { validatePlugins } from './pluginSchema.js';
|
|
4
5
|
|
|
5
6
|
export const allPlugins = [sentryPlugin, nextIntlPlugin, authJwtPlugin];
|
|
6
7
|
|
|
8
|
+
// 모듈 로드 시점에 모든 플러그인 manifest 검증 — 잘못된 형태가 있으면 즉시 실패.
|
|
9
|
+
// 예: src/proxy.ts 같은 잘못된 경로, name 이 kebab-case 가 아닌 경우 등.
|
|
10
|
+
validatePlugins(allPlugins);
|
|
11
|
+
|
|
7
12
|
export function getPluginChoices() {
|
|
8
13
|
return allPlugins.map((p) => ({
|
|
9
14
|
name: p.label,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 플러그인 manifest 스키마.
|
|
5
|
+
* plugins/index.js 로딩 시 모든 플러그인을 이 스키마로 validate 한다.
|
|
6
|
+
*
|
|
7
|
+
* 디자인 가드:
|
|
8
|
+
* - file path 가 'src/proxy.ts' 면 거부 — Next 16 은 root proxy.ts 만 인식하므로
|
|
9
|
+
* (베이스 템플릿이 app/ 을 root 에 두는 한). 이런 종류의 사고를 빌드 타임에 차단.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const filePath = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.refine((p) => p !== 'src/proxy.ts', {
|
|
16
|
+
message:
|
|
17
|
+
"proxy.ts must be at root (Next 16 convention with app/ at root). " +
|
|
18
|
+
"Use 'proxy.ts' instead of 'src/proxy.ts'.",
|
|
19
|
+
})
|
|
20
|
+
.refine((p) => p !== 'src/middleware.ts', {
|
|
21
|
+
message:
|
|
22
|
+
"middleware.ts (deprecated, use proxy.ts in Next 16+) must be at root. " +
|
|
23
|
+
"Use 'middleware.ts' instead of 'src/middleware.ts'.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const wrapperFn = z
|
|
27
|
+
.function()
|
|
28
|
+
.args(z.string())
|
|
29
|
+
.returns(z.string())
|
|
30
|
+
.optional();
|
|
31
|
+
|
|
32
|
+
export const PluginSchema = z.object({
|
|
33
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/, {
|
|
34
|
+
message: 'Plugin name must be lowercase kebab-case (e.g., "auth-jwt")',
|
|
35
|
+
}),
|
|
36
|
+
label: z.string().min(1),
|
|
37
|
+
description: z.string().min(1).optional(),
|
|
38
|
+
priority: z.number().int().nonnegative(),
|
|
39
|
+
|
|
40
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
41
|
+
devDependencies: z.record(z.string(), z.string()).optional(),
|
|
42
|
+
|
|
43
|
+
imports: z.array(z.string()).optional(),
|
|
44
|
+
wrapExport: wrapperFn,
|
|
45
|
+
|
|
46
|
+
envVars: z.array(z.string()).optional(),
|
|
47
|
+
turboEnvVars: z.array(z.string()).optional(),
|
|
48
|
+
|
|
49
|
+
providerImports: z.array(z.string()).optional(),
|
|
50
|
+
providerWrappers: z
|
|
51
|
+
.array(
|
|
52
|
+
z.union([
|
|
53
|
+
z.object({ open: z.string(), close: z.string() }),
|
|
54
|
+
z.string(),
|
|
55
|
+
]),
|
|
56
|
+
)
|
|
57
|
+
.optional(),
|
|
58
|
+
|
|
59
|
+
files: z.record(filePath, z.string()).optional(),
|
|
60
|
+
|
|
61
|
+
// 향후 확장 — moves, transforms, etc 는 nextIntl.js 에서 사용하므로 허용
|
|
62
|
+
moves: z.array(z.any()).optional(),
|
|
63
|
+
transforms: z.array(z.any()).optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 모든 플러그인을 검증. 실패 시 첫 번째 에러로 throw.
|
|
68
|
+
*/
|
|
69
|
+
export function validatePlugins(plugins) {
|
|
70
|
+
for (const plugin of plugins) {
|
|
71
|
+
const result = PluginSchema.safeParse(plugin);
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
const issues = result.error.issues
|
|
74
|
+
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid plugin manifest "${plugin?.name ?? '(unknown)'}":\n${issues}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -3,19 +3,22 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin, stdout } from "node:process";
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
INIT_PLATFORMS,
|
|
8
|
+
THEME_BASES,
|
|
9
|
+
THEME_RADII,
|
|
10
|
+
THEME_MODES,
|
|
11
|
+
INIT_DEFAULTS,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
|
|
6
14
|
const CHOICES = {
|
|
7
|
-
platform:
|
|
8
|
-
base:
|
|
9
|
-
radius:
|
|
10
|
-
mode:
|
|
15
|
+
platform: INIT_PLATFORMS,
|
|
16
|
+
base: THEME_BASES,
|
|
17
|
+
radius: THEME_RADII,
|
|
18
|
+
mode: THEME_MODES,
|
|
11
19
|
};
|
|
12
20
|
|
|
13
|
-
const DEFAULTS =
|
|
14
|
-
platform: "react",
|
|
15
|
-
base: "neutral",
|
|
16
|
-
radius: "md",
|
|
17
|
-
mode: "light-dark",
|
|
18
|
-
};
|
|
21
|
+
const DEFAULTS = INIT_DEFAULTS;
|
|
19
22
|
|
|
20
23
|
const PATHS = {
|
|
21
24
|
react: {
|
package/src/mcp.mjs
CHANGED
|
@@ -29,11 +29,21 @@ import {
|
|
|
29
29
|
getSummariesPath,
|
|
30
30
|
getVersionsPath,
|
|
31
31
|
} from "./paths.mjs";
|
|
32
|
+
import {
|
|
33
|
+
CREATE_PLATFORMS,
|
|
34
|
+
CREATE_STRUCTURES,
|
|
35
|
+
INIT_PLATFORMS,
|
|
36
|
+
THEME_BASES,
|
|
37
|
+
THEME_RADII,
|
|
38
|
+
THEME_MODES,
|
|
39
|
+
} from "./constants.js";
|
|
40
|
+
import { allPlugins } from "./create/plugins/index.js";
|
|
32
41
|
|
|
33
|
-
const PLATFORMS =
|
|
34
|
-
const BASES =
|
|
35
|
-
const RADII =
|
|
36
|
-
const MODES =
|
|
42
|
+
const PLATFORMS = INIT_PLATFORMS;
|
|
43
|
+
const BASES = THEME_BASES;
|
|
44
|
+
const RADII = THEME_RADII;
|
|
45
|
+
const MODES = THEME_MODES;
|
|
46
|
+
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
37
47
|
|
|
38
48
|
const INIT_DESCRIPTIONS = {
|
|
39
49
|
platform: {
|
|
@@ -161,12 +171,12 @@ export async function startMcpServer() {
|
|
|
161
171
|
inputSchema: {
|
|
162
172
|
name: z.string().min(1)
|
|
163
173
|
.describe("프로젝트 디렉토리 이름. 예: my-app"),
|
|
164
|
-
platform: z.enum(
|
|
174
|
+
platform: z.enum(CREATE_PLATFORMS)
|
|
165
175
|
.describe("타겟 플랫폼"),
|
|
166
|
-
structure: z.enum(
|
|
176
|
+
structure: z.enum(CREATE_STRUCTURES).optional()
|
|
167
177
|
.describe("Next.js 구조 — platform=next 일 때 필수. standalone(단독) | monorepo(Turborepo)"),
|
|
168
|
-
plugins: z.array(z.enum(
|
|
169
|
-
.describe(
|
|
178
|
+
plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
|
|
179
|
+
.describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
|
|
170
180
|
theme: z.string().optional()
|
|
171
181
|
.describe("base64 인코딩된 테마 JSON (선택)"),
|
|
172
182
|
cwd: z.string().optional()
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"@tailwindcss/postcss": "^4.1.18",
|
|
31
31
|
"@tanstack/react-query-devtools": "^5.91.3",
|
|
32
32
|
"@testing-library/jest-dom": "^6.9.1",
|
|
33
|
+
"@testing-library/react": "^16",
|
|
34
|
+
"@testing-library/user-event": "^14",
|
|
33
35
|
"@types/node": "^25.1.0",
|
|
34
36
|
"@types/react": "^19.2.10",
|
|
35
37
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date → 로케일 기반 날짜 포맷 (시간 없음).
|
|
3
|
+
*/
|
|
4
|
+
export const formatDate = (date: Date, locale = 'ko-KR'): string =>
|
|
5
|
+
new Intl.DateTimeFormat(locale, {
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: '2-digit',
|
|
8
|
+
day: '2-digit',
|
|
9
|
+
}).format(date);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Date → 로케일 기반 날짜 + 시간 포맷 (24h).
|
|
13
|
+
*/
|
|
14
|
+
export const formatDateTime = (date: Date, locale = 'ko-KR'): string =>
|
|
15
|
+
new Intl.DateTimeFormat(locale, {
|
|
16
|
+
year: 'numeric',
|
|
17
|
+
month: '2-digit',
|
|
18
|
+
day: '2-digit',
|
|
19
|
+
hour: '2-digit',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
hour12: false,
|
|
22
|
+
}).format(date);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 숫자 → 한국 원화(KRW) 포맷. 소수점 없음.
|
|
3
|
+
* 예: 12000 → "₩12,000"
|
|
4
|
+
*/
|
|
5
|
+
export const formatPrice = (amount: number, locale = 'ko-KR'): string =>
|
|
6
|
+
new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency',
|
|
8
|
+
currency: 'KRW',
|
|
9
|
+
maximumFractionDigits: 0,
|
|
10
|
+
}).format(amount);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isServer } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getBrowserQueryClient,
|
|
5
|
+
getServerQueryClient,
|
|
6
|
+
} from '@/src/shared/api/queryClient';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
|
|
10
|
+
* 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
|
|
11
|
+
*/
|
|
12
|
+
export default function getQueryClient() {
|
|
13
|
+
return isServer ? getServerQueryClient() : getBrowserQueryClient();
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 테스트 전용 QueryClient — retry/refetch 끄고 gcTime 0 으로 격리.
|
|
5
|
+
*/
|
|
6
|
+
export const createTestQueryClient = (): QueryClient =>
|
|
7
|
+
new QueryClient({
|
|
8
|
+
defaultOptions: {
|
|
9
|
+
queries: {
|
|
10
|
+
retry: false,
|
|
11
|
+
refetchOnWindowFocus: false,
|
|
12
|
+
gcTime: 0,
|
|
13
|
+
},
|
|
14
|
+
mutations: {
|
|
15
|
+
retry: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
render,
|
|
5
|
+
type RenderOptions,
|
|
6
|
+
type RenderResult,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
10
|
+
|
|
11
|
+
import { createTestQueryClient } from './createTestQueryClient';
|
|
12
|
+
|
|
13
|
+
type Options = Omit<RenderOptions, 'wrapper'> & {
|
|
14
|
+
queryClient?: QueryClient;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Result = RenderResult & {
|
|
18
|
+
user: ReturnType<typeof userEvent.setup>;
|
|
19
|
+
queryClient: QueryClient;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RTL render + QueryClientProvider + userEvent setup 한 번에.
|
|
24
|
+
* 추가 Provider 가 필요하면 이 파일을 직접 수정하거나 wrapper 옵션을 쓴다.
|
|
25
|
+
*/
|
|
26
|
+
export const renderWithProviders = (
|
|
27
|
+
ui: ReactElement,
|
|
28
|
+
options: Options = {},
|
|
29
|
+
): Result => {
|
|
30
|
+
const { queryClient = createTestQueryClient(), ...rtlOptions } = options;
|
|
31
|
+
|
|
32
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
33
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const result = render(ui, { wrapper: Wrapper, ...rtlOptions });
|
|
37
|
+
const user = userEvent.setup();
|
|
38
|
+
|
|
39
|
+
return { ...result, user, queryClient };
|
|
40
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Component,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
type ErrorInfo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
Suspense,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { QueryErrorResetBoundary } from '@tanstack/react-query';
|
|
11
|
+
|
|
12
|
+
export type ErrorFallbackProps = {
|
|
13
|
+
error: Error | null;
|
|
14
|
+
resetErrorBoundary: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ErrorBoundaryProps = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
fallback?: ComponentType<ErrorFallbackProps>;
|
|
20
|
+
onReset: () => void;
|
|
21
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ErrorBoundaryState = {
|
|
25
|
+
hasError: boolean;
|
|
26
|
+
error: Error | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
30
|
+
state: ErrorBoundaryState = { hasError: false, error: null };
|
|
31
|
+
|
|
32
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
33
|
+
return { hasError: true, error };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
37
|
+
this.props.onError?.(error, info);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resetErrorBoundary = () => {
|
|
41
|
+
this.props.onReset();
|
|
42
|
+
this.setState({ hasError: false, error: null });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
render() {
|
|
46
|
+
const { hasError, error } = this.state;
|
|
47
|
+
const { children, fallback: Fallback } = this.props;
|
|
48
|
+
|
|
49
|
+
if (hasError && Fallback) {
|
|
50
|
+
return (
|
|
51
|
+
<Fallback error={error} resetErrorBoundary={this.resetErrorBoundary} />
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return children;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type FallbackBoundaryProps = {
|
|
60
|
+
children: ReactNode;
|
|
61
|
+
errorFallback?: ComponentType<ErrorFallbackProps>;
|
|
62
|
+
suspenseFallback?: ReactNode;
|
|
63
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Suspense + ErrorBoundary 합성. React Query 의 reset 신호에 맞춰
|
|
68
|
+
* `errorFallback` 의 `resetErrorBoundary` 가 쿼리까지 함께 리셋한다.
|
|
69
|
+
*/
|
|
70
|
+
export function FallbackBoundary({
|
|
71
|
+
children,
|
|
72
|
+
errorFallback,
|
|
73
|
+
suspenseFallback,
|
|
74
|
+
onError,
|
|
75
|
+
}: FallbackBoundaryProps) {
|
|
76
|
+
return (
|
|
77
|
+
<QueryErrorResetBoundary>
|
|
78
|
+
{({ reset }) => (
|
|
79
|
+
<ErrorBoundary
|
|
80
|
+
onReset={reset}
|
|
81
|
+
fallback={errorFallback}
|
|
82
|
+
onError={onError}
|
|
83
|
+
>
|
|
84
|
+
<Suspense fallback={suspenseFallback}>{children}</Suspense>
|
|
85
|
+
</ErrorBoundary>
|
|
86
|
+
)}
|
|
87
|
+
</QueryErrorResetBoundary>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dehydrate,
|
|
5
|
+
type FetchQueryOptions,
|
|
6
|
+
HydrationBoundary,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
|
|
9
|
+
import getQueryClient from '@/src/shared/lib/getQueryClient';
|
|
10
|
+
|
|
11
|
+
export type FetchOptions = Pick<FetchQueryOptions, 'queryKey' | 'queryFn'>;
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
fetchOptions?: FetchOptions[] | FetchOptions | null;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* RSC 에서 prefetch 를 끝낸 뒤 dehydrated state 로 클라이언트에 hydrate.
|
|
20
|
+
* 단일/배열 둘 다 받는다.
|
|
21
|
+
*/
|
|
22
|
+
export async function PrefetchBoundary({ fetchOptions, children }: Props) {
|
|
23
|
+
const queryClient = getQueryClient();
|
|
24
|
+
|
|
25
|
+
if (fetchOptions) {
|
|
26
|
+
const list = Array.isArray(fetchOptions) ? fetchOptions : [fetchOptions];
|
|
27
|
+
await Promise.all(list.map((opt) => queryClient.prefetchQuery(opt)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
32
|
+
{children}
|
|
33
|
+
</HydrationBoundary>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"@tailwindcss/postcss": "^4.1.18",
|
|
36
36
|
"@tanstack/react-query-devtools": "^5.91.3",
|
|
37
37
|
"@testing-library/jest-dom": "^6.9.1",
|
|
38
|
+
"@testing-library/react": "^16",
|
|
39
|
+
"@testing-library/user-event": "^14",
|
|
38
40
|
"@types/node": "^25.1.0",
|
|
39
41
|
"@types/react": "^19.2.10",
|
|
40
42
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date → 로케일 기반 날짜 포맷 (시간 없음).
|
|
3
|
+
*/
|
|
4
|
+
export const formatDate = (date: Date, locale = 'ko-KR'): string =>
|
|
5
|
+
new Intl.DateTimeFormat(locale, {
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: '2-digit',
|
|
8
|
+
day: '2-digit',
|
|
9
|
+
}).format(date);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Date → 로케일 기반 날짜 + 시간 포맷 (24h).
|
|
13
|
+
*/
|
|
14
|
+
export const formatDateTime = (date: Date, locale = 'ko-KR'): string =>
|
|
15
|
+
new Intl.DateTimeFormat(locale, {
|
|
16
|
+
year: 'numeric',
|
|
17
|
+
month: '2-digit',
|
|
18
|
+
day: '2-digit',
|
|
19
|
+
hour: '2-digit',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
hour12: false,
|
|
22
|
+
}).format(date);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 숫자 → 한국 원화(KRW) 포맷. 소수점 없음.
|
|
3
|
+
* 예: 12000 → "₩12,000"
|
|
4
|
+
*/
|
|
5
|
+
export const formatPrice = (amount: number, locale = 'ko-KR'): string =>
|
|
6
|
+
new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency',
|
|
8
|
+
currency: 'KRW',
|
|
9
|
+
maximumFractionDigits: 0,
|
|
10
|
+
}).format(amount);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isServer } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getBrowserQueryClient,
|
|
5
|
+
getServerQueryClient,
|
|
6
|
+
} from '@/src/shared/api/queryClient';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
|
|
10
|
+
* 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
|
|
11
|
+
*/
|
|
12
|
+
export default function getQueryClient() {
|
|
13
|
+
return isServer ? getServerQueryClient() : getBrowserQueryClient();
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 테스트 전용 QueryClient — retry/refetch 끄고 gcTime 0 으로 격리.
|
|
5
|
+
*/
|
|
6
|
+
export const createTestQueryClient = (): QueryClient =>
|
|
7
|
+
new QueryClient({
|
|
8
|
+
defaultOptions: {
|
|
9
|
+
queries: {
|
|
10
|
+
retry: false,
|
|
11
|
+
refetchOnWindowFocus: false,
|
|
12
|
+
gcTime: 0,
|
|
13
|
+
},
|
|
14
|
+
mutations: {
|
|
15
|
+
retry: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
render,
|
|
5
|
+
type RenderOptions,
|
|
6
|
+
type RenderResult,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
10
|
+
|
|
11
|
+
import { createTestQueryClient } from './createTestQueryClient';
|
|
12
|
+
|
|
13
|
+
type Options = Omit<RenderOptions, 'wrapper'> & {
|
|
14
|
+
queryClient?: QueryClient;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Result = RenderResult & {
|
|
18
|
+
user: ReturnType<typeof userEvent.setup>;
|
|
19
|
+
queryClient: QueryClient;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RTL render + QueryClientProvider + userEvent setup 한 번에.
|
|
24
|
+
* 추가 Provider 가 필요하면 이 파일을 직접 수정하거나 wrapper 옵션을 쓴다.
|
|
25
|
+
*/
|
|
26
|
+
export const renderWithProviders = (
|
|
27
|
+
ui: ReactElement,
|
|
28
|
+
options: Options = {},
|
|
29
|
+
): Result => {
|
|
30
|
+
const { queryClient = createTestQueryClient(), ...rtlOptions } = options;
|
|
31
|
+
|
|
32
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
33
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const result = render(ui, { wrapper: Wrapper, ...rtlOptions });
|
|
37
|
+
const user = userEvent.setup();
|
|
38
|
+
|
|
39
|
+
return { ...result, user, queryClient };
|
|
40
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Component,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
type ErrorInfo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
Suspense,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { QueryErrorResetBoundary } from '@tanstack/react-query';
|
|
11
|
+
|
|
12
|
+
export type ErrorFallbackProps = {
|
|
13
|
+
error: Error | null;
|
|
14
|
+
resetErrorBoundary: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ErrorBoundaryProps = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
fallback?: ComponentType<ErrorFallbackProps>;
|
|
20
|
+
onReset: () => void;
|
|
21
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ErrorBoundaryState = {
|
|
25
|
+
hasError: boolean;
|
|
26
|
+
error: Error | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
30
|
+
state: ErrorBoundaryState = { hasError: false, error: null };
|
|
31
|
+
|
|
32
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
33
|
+
return { hasError: true, error };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
37
|
+
this.props.onError?.(error, info);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resetErrorBoundary = () => {
|
|
41
|
+
this.props.onReset();
|
|
42
|
+
this.setState({ hasError: false, error: null });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
render() {
|
|
46
|
+
const { hasError, error } = this.state;
|
|
47
|
+
const { children, fallback: Fallback } = this.props;
|
|
48
|
+
|
|
49
|
+
if (hasError && Fallback) {
|
|
50
|
+
return (
|
|
51
|
+
<Fallback error={error} resetErrorBoundary={this.resetErrorBoundary} />
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return children;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type FallbackBoundaryProps = {
|
|
60
|
+
children: ReactNode;
|
|
61
|
+
errorFallback?: ComponentType<ErrorFallbackProps>;
|
|
62
|
+
suspenseFallback?: ReactNode;
|
|
63
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Suspense + ErrorBoundary 합성. React Query 의 reset 신호에 맞춰
|
|
68
|
+
* `errorFallback` 의 `resetErrorBoundary` 가 쿼리까지 함께 리셋한다.
|
|
69
|
+
*/
|
|
70
|
+
export function FallbackBoundary({
|
|
71
|
+
children,
|
|
72
|
+
errorFallback,
|
|
73
|
+
suspenseFallback,
|
|
74
|
+
onError,
|
|
75
|
+
}: FallbackBoundaryProps) {
|
|
76
|
+
return (
|
|
77
|
+
<QueryErrorResetBoundary>
|
|
78
|
+
{({ reset }) => (
|
|
79
|
+
<ErrorBoundary
|
|
80
|
+
onReset={reset}
|
|
81
|
+
fallback={errorFallback}
|
|
82
|
+
onError={onError}
|
|
83
|
+
>
|
|
84
|
+
<Suspense fallback={suspenseFallback}>{children}</Suspense>
|
|
85
|
+
</ErrorBoundary>
|
|
86
|
+
)}
|
|
87
|
+
</QueryErrorResetBoundary>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dehydrate,
|
|
5
|
+
type FetchQueryOptions,
|
|
6
|
+
HydrationBoundary,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
|
|
9
|
+
import getQueryClient from '@/src/shared/lib/getQueryClient';
|
|
10
|
+
|
|
11
|
+
export type FetchOptions = Pick<FetchQueryOptions, 'queryKey' | 'queryFn'>;
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
fetchOptions?: FetchOptions[] | FetchOptions | null;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* RSC 에서 prefetch 를 끝낸 뒤 dehydrated state 로 클라이언트에 hydrate.
|
|
20
|
+
* 단일/배열 둘 다 받는다.
|
|
21
|
+
*/
|
|
22
|
+
export async function PrefetchBoundary({ fetchOptions, children }: Props) {
|
|
23
|
+
const queryClient = getQueryClient();
|
|
24
|
+
|
|
25
|
+
if (fetchOptions) {
|
|
26
|
+
const list = Array.isArray(fetchOptions) ? fetchOptions : [fetchOptions];
|
|
27
|
+
await Promise.all(list.map((opt) => queryClient.prefetchQuery(opt)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
32
|
+
{children}
|
|
33
|
+
</HydrationBoundary>
|
|
34
|
+
);
|
|
35
|
+
}
|