sh-ui-cli 0.77.0 → 0.79.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 +45 -0
- package/data/registry/react/components/code-editor/index.module.tsx +7 -1
- package/data/registry/react/components/code-editor/index.tailwind.tsx +5 -2
- package/data/registry/react/components/code-editor/index.tsx +7 -1
- package/data/registry/react/components/markdown-editor/index.module.tsx +1 -1
- package/data/registry/react/components/markdown-editor/index.tailwind.tsx +1 -1
- package/data/registry/react/components/markdown-editor/index.tsx +1 -1
- package/data/registry/react/components/select/index.module.tsx +20 -9
- package/data/registry/react/components/select/index.tailwind.tsx +21 -8
- package/data/registry/react/components/select/index.tsx +28 -9
- package/data/registry/react/components/sidebar/index.module.tsx +10 -6
- package/data/registry/react/components/sidebar/index.tailwind.tsx +10 -6
- package/data/registry/react/components/sidebar/index.tsx +20 -4
- package/data/registry/react/registry.json +2 -1
- package/data/tokens/src/semantic.json +16 -2
- package/package.json +1 -1
- package/src/create/generator.js +61 -8
- package/src/create/plugins/authJwt.js +10 -0
- package/src/create/plugins/nextIntl.js +36 -2
- package/src/mcp.mjs +66 -1
- package/templates/monorepo/CLAUDE.md +40 -0
- package/templates/monorepo/packages/eslint-config/base.js +9 -0
- package/templates/monorepo/packages/ui/ui-core/package.json +1 -1
- package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +6 -0
- package/templates/nextjs-app/_arch/fsd/src/app/layouts/RootLayout.tsx +11 -0
- package/templates/nextjs-app/_arch/mes/src/components/layouts/RootLayout.tsx +6 -0
- package/templates/nextjs-standalone/CLAUDE.md +30 -0
- package/templates/nextjs-standalone/_arch/flat/app/globals.css +5 -0
- package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +6 -0
- package/templates/nextjs-standalone/_arch/flat/eslint.config.js +9 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/layouts/RootLayout.tsx +6 -0
- package/templates/nextjs-standalone/_arch/mes/app/globals.css +5 -0
- package/templates/nextjs-standalone/_arch/mes/eslint.config.js +9 -0
- package/templates/nextjs-standalone/_arch/mes/src/components/layouts/RootLayout.tsx +6 -0
- package/templates/nextjs-standalone/app/globals.css +5 -0
- package/templates/nextjs-standalone/eslint.config.js +9 -0
- package/templates/ui-app-template/src/styles/globals.css +5 -0
|
@@ -2,6 +2,51 @@
|
|
|
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.79.0",
|
|
7
|
+
"date": "2026-05-12",
|
|
8
|
+
"title": "no-restricted toLocaleDateString — SSR hydration mismatch 회귀 가드",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**ESLint `no-restricted-syntax` 룰 추가** — 인자 0개의 `.toLocaleDateString()` / `.toLocaleString()` / `.toLocaleTimeString()` 호출을 error 로 차단. Node 기본 로케일(en-US) 과 브라우저 로케일(ko-KR) 이 달라 SSR 출력이 hydrate 시 mismatch 나던 함정의 회귀 가드.",
|
|
12
|
+
"**대체 경로 안내가 에러 메시지에 박힘** — `@/src/shared/lib/formatDate` 의 `formatDate` / `formatDateTime` (default `ko-KR`), next-intl locale 추종이면 `useFormatDate` 훅, 숫자는 `formatPrice`. AST selector 가 `arguments.length === 0` 만 매칭하므로 의도된 locale 명시 호출 (`toLocaleDateString('ko-KR', { ... })`) 은 통과.",
|
|
13
|
+
"**적용 범위** — monorepo `@workspace/eslint-config/base` (next.js · react-internal 등 모든 워크스페이스 config 가 상속) + `nextjs-standalone` 의 fsd / flat / mes arch 오버레이 3종.",
|
|
14
|
+
"**템플릿 CLAUDE.md 추가** — `nextjs-standalone` / `monorepo` 시작 템플릿에 `CLAUDE.md` 신규. 날짜·숫자 포맷 컨벤션 + sh-ui 컴포넌트 우선 + 토큰 사용 원칙 명시. AI 에이전트가 코드 작성 시점에 컨텍스트로 읽고 미리 피할 수 있게."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.79.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.78.0",
|
|
20
|
+
"date": "2026-05-12",
|
|
21
|
+
"title": "minor — Next.js 호환성 fixes (exports map · relative imports · FOUC · SelectValue label · sidebar tokens)",
|
|
22
|
+
"type": "minor",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"**ui-core `package.json` exports map 수정** — `./components/*: ./src/components/*.tsx` → `./src/components/*/index.tsx`. 폴더+index 패턴에 맞춰 `@workspace/ui-core/components/<name>` import 가 즉시 resolve. v0.77 까지 scaffold 직후 매번 손으로 패치해야 했던 버그.",
|
|
25
|
+
"**registry typecheck 통과** — `markdown-editor` / `sidebar` 의 inter-component 상대 import 에 `/index.tsx` 명시 (node16/nodenext + bundler 둘 다 동작). `sidebar` 의 `PopoverTrigger` render callback 의 implicit `any` 도 명시 타입.",
|
|
26
|
+
"**`SidebarMenuButton` render 자식 `data-active` 보존** — `cloneElement` 가 사용자가 박은 `data-active` 를 sh-ui 자동값으로 덮어쓰던 버그. 이제 자식 명시값이 우선. 자기 디자인 시스템 active state 를 `[data-active]` 셀렉터로 다루는 흐름 가능.",
|
|
27
|
+
"**`SelectValue` children render 패턴 추가** — `<SelectValue>{(v) => labels[v as string]}</SelectValue>` 형태로 value → label 매핑. i18n 라벨 노출에 필수 (예: `light` → `라이트`).",
|
|
28
|
+
"**`CodeEditor` 에 `yaml` / `plaintext` 추가** — `@codemirror/lang-yaml` deps + language enum 확장. YAML convention 파일에 syntax highlighting.",
|
|
29
|
+
"**Sidebar 토큰 1급 노출** — `--sidebar-bg` / `--sidebar-fg` / `--sidebar-border` / `--sidebar-accent` / `--sidebar-accent-fg` 가 `tokens.css` 메인 셋과 globals.css 의 `@theme inline` 매핑에 정식 등재. Tailwind `bg-sidebar-bg` utility 직접 사용 가능.",
|
|
30
|
+
"**RootLayout FOUC 차단 inline script** — next-themes mount 전 첫 paint 에 dark/light class 적용. 다크모드 진입 시 light 한 frame 깜빡임 해소. 모든 arch (fsd/flat/mes) × (nextjs-app/nextjs-standalone) 템플릿에 적용.",
|
|
31
|
+
"**auth-jwt dev bypass flag** — `NEXT_PUBLIC_DEV_AUTH_BYPASS=true` 시 dev 환경에서 proxy.ts 가드 우회. `.env.example` 에 자리표시자 포함, production 빌드에선 무시.",
|
|
32
|
+
"**i18n boilerplate 확장** — `nav` / `app` / `form` 네임스페이스 + `common` 에 create/name/description/empty 추가.",
|
|
33
|
+
"**MCP 가이드 추가** — Base UI 합성 함정 (SSR hydration mismatch, `DropdownMenuItem` 안 `DialogTrigger` 가 안 열리는 패턴) 회피 코드 예시를 sh_ui_* 툴 description 에 박음."
|
|
34
|
+
],
|
|
35
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.78.0"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"version": "0.77.1",
|
|
39
|
+
"date": "2026-05-12",
|
|
40
|
+
"title": "security — `sh_ui_create_project` / `sh_ui_add_app` path traversal 차단",
|
|
41
|
+
"type": "patch",
|
|
42
|
+
"highlights": [
|
|
43
|
+
"**path traversal 차단** — MCP 호출자가 `name` 인자에 `../` / 절대경로 / 선행 `.` 을 넣어 부모 디렉토리 밖의 임의 경로를 지정하던 흐름 차단. 영숫자 + `_` / `-` / `.` 만 허용 (단, `.` 로 시작 불가).",
|
|
44
|
+
"**임의 디렉토리 삭제 봉쇄** — `force: true` + 내부 `yes: true` 조합으로 `fs.remove(targetDir)` 가 traversal 경로에 실행되던 흐름을 진입부 검증 + `assertWithin(parent, child)` 가드로 이중 차단.",
|
|
45
|
+
"CLI / MCP 두 경로 모두 `createProject` / `addApp` 진입부에서 일괄 검증 — 호출 표면 어느 쪽으로도 우회 불가.",
|
|
46
|
+
"회귀 가드 — `test/name-validation.test.js` 추가 (traversal, 절대경로, 숨김 디렉토리, 셸 메타문자, NUL 바이트 등 29 케이스)."
|
|
47
|
+
],
|
|
48
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.77.1"
|
|
49
|
+
},
|
|
5
50
|
{
|
|
6
51
|
"version": "0.77.0",
|
|
7
52
|
"date": "2026-05-11",
|
|
@@ -9,11 +9,13 @@ import { json } from "@codemirror/lang-json";
|
|
|
9
9
|
import { css as cssLang } from "@codemirror/lang-css";
|
|
10
10
|
import { html } from "@codemirror/lang-html";
|
|
11
11
|
import { markdown } from "@codemirror/lang-markdown";
|
|
12
|
+
import { yaml } from "@codemirror/lang-yaml";
|
|
12
13
|
import styles from "./styles.module.css";
|
|
13
14
|
|
|
14
15
|
import { cn } from "@SH_UI_UTILS@";
|
|
15
16
|
export type CodeEditorLanguage =
|
|
16
17
|
| "text"
|
|
18
|
+
| "plaintext"
|
|
17
19
|
| "javascript"
|
|
18
20
|
| "typescript"
|
|
19
21
|
| "jsx"
|
|
@@ -21,7 +23,8 @@ export type CodeEditorLanguage =
|
|
|
21
23
|
| "json"
|
|
22
24
|
| "css"
|
|
23
25
|
| "html"
|
|
24
|
-
| "markdown"
|
|
26
|
+
| "markdown"
|
|
27
|
+
| "yaml";
|
|
25
28
|
|
|
26
29
|
export interface CodeEditorProps {
|
|
27
30
|
/**
|
|
@@ -79,7 +82,10 @@ function languageExtension(language: CodeEditorLanguage): Extension {
|
|
|
79
82
|
return html();
|
|
80
83
|
case "markdown":
|
|
81
84
|
return markdown();
|
|
85
|
+
case "yaml":
|
|
86
|
+
return yaml();
|
|
82
87
|
case "text":
|
|
88
|
+
case "plaintext":
|
|
83
89
|
default:
|
|
84
90
|
return [];
|
|
85
91
|
}
|
|
@@ -9,11 +9,12 @@ import { json } from "@codemirror/lang-json";
|
|
|
9
9
|
import { css as cssLang } from "@codemirror/lang-css";
|
|
10
10
|
import { html } from "@codemirror/lang-html";
|
|
11
11
|
import { markdown } from "@codemirror/lang-markdown";
|
|
12
|
+
import { yaml } from "@codemirror/lang-yaml";
|
|
12
13
|
|
|
13
14
|
import { cn } from "@SH_UI_UTILS@";
|
|
14
15
|
export type CodeEditorLanguage =
|
|
15
|
-
| "text" | "javascript" | "typescript" | "jsx" | "tsx"
|
|
16
|
-
| "json" | "css" | "html" | "markdown";
|
|
16
|
+
| "text" | "plaintext" | "javascript" | "typescript" | "jsx" | "tsx"
|
|
17
|
+
| "json" | "css" | "html" | "markdown" | "yaml";
|
|
17
18
|
|
|
18
19
|
export interface CodeEditorProps {
|
|
19
20
|
value?: string;
|
|
@@ -42,7 +43,9 @@ function languageExtension(language: CodeEditorLanguage): Extension {
|
|
|
42
43
|
case "css": return cssLang();
|
|
43
44
|
case "html": return html();
|
|
44
45
|
case "markdown": return markdown();
|
|
46
|
+
case "yaml": return yaml();
|
|
45
47
|
case "text":
|
|
48
|
+
case "plaintext":
|
|
46
49
|
default: return [];
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -9,11 +9,13 @@ import { json } from "@codemirror/lang-json";
|
|
|
9
9
|
import { css as cssLang } from "@codemirror/lang-css";
|
|
10
10
|
import { html } from "@codemirror/lang-html";
|
|
11
11
|
import { markdown } from "@codemirror/lang-markdown";
|
|
12
|
+
import { yaml } from "@codemirror/lang-yaml";
|
|
12
13
|
import "./styles.css";
|
|
13
14
|
|
|
14
15
|
import { cn } from "@SH_UI_UTILS@";
|
|
15
16
|
export type CodeEditorLanguage =
|
|
16
17
|
| "text"
|
|
18
|
+
| "plaintext"
|
|
17
19
|
| "javascript"
|
|
18
20
|
| "typescript"
|
|
19
21
|
| "jsx"
|
|
@@ -21,7 +23,8 @@ export type CodeEditorLanguage =
|
|
|
21
23
|
| "json"
|
|
22
24
|
| "css"
|
|
23
25
|
| "html"
|
|
24
|
-
| "markdown"
|
|
26
|
+
| "markdown"
|
|
27
|
+
| "yaml";
|
|
25
28
|
|
|
26
29
|
export interface CodeEditorProps {
|
|
27
30
|
/**
|
|
@@ -79,7 +82,10 @@ function languageExtension(language: CodeEditorLanguage): Extension {
|
|
|
79
82
|
return html();
|
|
80
83
|
case "markdown":
|
|
81
84
|
return markdown();
|
|
85
|
+
case "yaml":
|
|
86
|
+
return yaml();
|
|
82
87
|
case "text":
|
|
88
|
+
case "plaintext":
|
|
83
89
|
default:
|
|
84
90
|
return [];
|
|
85
91
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import { CodeEditor } from "../code-editor";
|
|
6
|
+
import { CodeEditor } from "../code-editor/index.tsx";
|
|
7
7
|
import styles from "./styles.module.css";
|
|
8
8
|
|
|
9
9
|
import { cn } from "@SH_UI_UTILS@";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import { CodeEditor } from "../code-editor";
|
|
6
|
+
import { CodeEditor } from "../code-editor/index.tsx";
|
|
7
7
|
|
|
8
8
|
import { cn } from "@SH_UI_UTILS@";
|
|
9
9
|
export interface MarkdownEditorProps {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import { CodeEditor } from "../code-editor";
|
|
6
|
+
import { CodeEditor } from "../code-editor/index.tsx";
|
|
7
7
|
import "./styles.css";
|
|
8
8
|
|
|
9
9
|
import { cn } from "@SH_UI_UTILS@";
|
|
@@ -8,24 +8,35 @@ import styles from "./styles.module.css";
|
|
|
8
8
|
import { cn } from "@SH_UI_UTILS@";
|
|
9
9
|
export const Select = BaseSelect.Root;
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* shadcn 호환 SelectValue. children 으로 value → label 매핑 함수 전달 가능.
|
|
13
|
+
*
|
|
14
|
+
* <SelectValue placeholder="모드">
|
|
15
|
+
* {(v) => ({ light: "라이트", dark: "다크" })[v as string] ?? null}
|
|
16
|
+
* </SelectValue>
|
|
17
|
+
*/
|
|
12
18
|
export function SelectValue({
|
|
13
19
|
placeholder,
|
|
14
20
|
className,
|
|
21
|
+
children,
|
|
15
22
|
...props
|
|
16
|
-
}: {
|
|
23
|
+
}: {
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
children?: (value: unknown) => React.ReactNode;
|
|
27
|
+
} & Omit<
|
|
17
28
|
React.ComponentPropsWithoutRef<typeof BaseSelect.Value>,
|
|
18
29
|
"children"
|
|
19
30
|
>) {
|
|
20
31
|
return (
|
|
21
32
|
<BaseSelect.Value className={cn(styles.select__value, className)} {...props}>
|
|
22
|
-
{(value) =>
|
|
23
|
-
value
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
33
|
+
{(value) => {
|
|
34
|
+
if (value === null || value === undefined || value === "") {
|
|
35
|
+
return <span className={styles.select__placeholder}>{placeholder}</span>;
|
|
36
|
+
}
|
|
37
|
+
if (children) return children(value);
|
|
38
|
+
return value as React.ReactNode;
|
|
39
|
+
}}
|
|
29
40
|
</BaseSelect.Value>
|
|
30
41
|
);
|
|
31
42
|
}
|
|
@@ -7,18 +7,31 @@ import { Select as BaseSelect } from "@base-ui/react/select";
|
|
|
7
7
|
import { cn } from "@SH_UI_UTILS@";
|
|
8
8
|
export const Select = BaseSelect.Root;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Select 트리거 안에 현재 선택값을 표시. children 으로 value→label 매핑 함수
|
|
12
|
+
* 전달 가능 (i18n / 다른 표시 텍스트). 미지정 시 value 를 그대로 렌더.
|
|
13
|
+
*/
|
|
14
|
+
export function SelectValue({
|
|
15
|
+
placeholder,
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: {
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
children?: (value: unknown) => React.ReactNode;
|
|
23
|
+
} & Omit<
|
|
11
24
|
React.ComponentPropsWithoutRef<typeof BaseSelect.Value>, "children"
|
|
12
25
|
>) {
|
|
13
26
|
return (
|
|
14
27
|
<BaseSelect.Value className={cn("flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap", className)} {...props}>
|
|
15
|
-
{(value) =>
|
|
16
|
-
value
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
28
|
+
{(value) => {
|
|
29
|
+
if (value === null || value === undefined || value === "") {
|
|
30
|
+
return <span className="text-foreground-subtle">{placeholder}</span>;
|
|
31
|
+
}
|
|
32
|
+
if (children) return children(value);
|
|
33
|
+
return value as React.ReactNode;
|
|
34
|
+
}}
|
|
22
35
|
</BaseSelect.Value>
|
|
23
36
|
);
|
|
24
37
|
}
|
|
@@ -8,24 +8,43 @@ import "./styles.css";
|
|
|
8
8
|
import { cn } from "@SH_UI_UTILS@";
|
|
9
9
|
export const Select = BaseSelect.Root;
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Select 트리거 안에 현재 선택값을 표시. shadcn 호환 형태.
|
|
13
|
+
*
|
|
14
|
+
* 기본은 raw value 를 그대로 렌더. value 와 label 이 다르면 (예: 다국어 라벨)
|
|
15
|
+
* children 으로 매핑 함수를 전달해 직접 표시할 노드를 만든다.
|
|
16
|
+
*
|
|
17
|
+
* // 1) 기본: value 그대로 표시
|
|
18
|
+
* <SelectValue placeholder="선택" />
|
|
19
|
+
*
|
|
20
|
+
* // 2) value → label 매핑 (i18n)
|
|
21
|
+
* <SelectValue placeholder="모드">
|
|
22
|
+
* {(v) => ({ light: "라이트", dark: "다크" })[v as string] ?? null}
|
|
23
|
+
* </SelectValue>
|
|
24
|
+
*/
|
|
12
25
|
export function SelectValue({
|
|
13
26
|
placeholder,
|
|
14
27
|
className,
|
|
28
|
+
children,
|
|
15
29
|
...props
|
|
16
|
-
}: {
|
|
30
|
+
}: {
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
/** value 를 표시 노드로 매핑. 미지정 시 value 를 그대로 렌더. */
|
|
34
|
+
children?: (value: unknown) => React.ReactNode;
|
|
35
|
+
} & Omit<
|
|
17
36
|
React.ComponentPropsWithoutRef<typeof BaseSelect.Value>,
|
|
18
37
|
"children"
|
|
19
38
|
>) {
|
|
20
39
|
return (
|
|
21
40
|
<BaseSelect.Value className={cn("sh-ui-select__value", className)} {...props}>
|
|
22
|
-
{(value) =>
|
|
23
|
-
value
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
41
|
+
{(value) => {
|
|
42
|
+
if (value === null || value === undefined || value === "") {
|
|
43
|
+
return <span className="sh-ui-select__placeholder">{placeholder}</span>;
|
|
44
|
+
}
|
|
45
|
+
if (children) return children(value);
|
|
46
|
+
return value as React.ReactNode;
|
|
47
|
+
}}
|
|
29
48
|
</BaseSelect.Value>
|
|
30
49
|
);
|
|
31
50
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "@SH_UI_UTILS@";
|
|
5
5
|
import { ChevronRightIcon, PanelLeftIcon } from "lucide-react";
|
|
6
|
-
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../popover/index.tsx";
|
|
7
7
|
import styles from "./styles.module.css";
|
|
8
8
|
|
|
9
9
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
|
@@ -643,7 +643,9 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
643
643
|
className);
|
|
644
644
|
|
|
645
645
|
if (render && React.isValidElement(render)) {
|
|
646
|
-
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
|
|
646
|
+
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode; "data-active"?: string | boolean }>;
|
|
647
|
+
const childDataActive = child.props["data-active"];
|
|
648
|
+
const dataActive = childDataActive !== undefined ? childDataActive : resolvedIsActive || undefined;
|
|
647
649
|
return React.cloneElement(
|
|
648
650
|
child,
|
|
649
651
|
{
|
|
@@ -651,7 +653,7 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
651
653
|
...props,
|
|
652
654
|
onClick: handleClick,
|
|
653
655
|
className: cn(child.props.className, cls),
|
|
654
|
-
"data-active":
|
|
656
|
+
"data-active": dataActive,
|
|
655
657
|
} as Record<string, unknown>,
|
|
656
658
|
children ?? child.props.children,
|
|
657
659
|
);
|
|
@@ -724,14 +726,16 @@ export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarM
|
|
|
724
726
|
className);
|
|
725
727
|
|
|
726
728
|
if (render && React.isValidElement(render)) {
|
|
727
|
-
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
|
|
729
|
+
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode; "data-active"?: string | boolean }>;
|
|
730
|
+
const childDataActive = child.props["data-active"];
|
|
731
|
+
const dataActive = childDataActive !== undefined ? childDataActive : resolvedIsActive || undefined;
|
|
728
732
|
return React.cloneElement(
|
|
729
733
|
child,
|
|
730
734
|
{
|
|
731
735
|
ref,
|
|
732
736
|
...props,
|
|
733
737
|
className: cn(child.props.className, cls),
|
|
734
|
-
"data-active":
|
|
738
|
+
"data-active": dataActive,
|
|
735
739
|
} as Record<string, unknown>,
|
|
736
740
|
children ?? child.props.children,
|
|
737
741
|
);
|
|
@@ -876,7 +880,7 @@ export function SidebarCollapsibleTrigger({
|
|
|
876
880
|
openOnHover
|
|
877
881
|
delay={0}
|
|
878
882
|
closeDelay={150}
|
|
879
|
-
render={(triggerProps) => (
|
|
883
|
+
render={(triggerProps: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
880
884
|
<button
|
|
881
885
|
{...triggerProps}
|
|
882
886
|
{...props}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "@SH_UI_UTILS@";
|
|
5
5
|
import { ChevronRightIcon, PanelLeftIcon } from "lucide-react";
|
|
6
|
-
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../popover/index.tsx";
|
|
7
7
|
|
|
8
8
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
|
9
9
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
@@ -384,7 +384,9 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
384
384
|
const cls = cn(menuButtonBase, menuButtonSize[size], className);
|
|
385
385
|
|
|
386
386
|
if (render && React.isValidElement(render)) {
|
|
387
|
-
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
|
|
387
|
+
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode; "data-active"?: string | boolean }>;
|
|
388
|
+
const childDataActive = child.props["data-active"];
|
|
389
|
+
const dataActive = childDataActive !== undefined ? childDataActive : resolvedIsActive || undefined;
|
|
388
390
|
return React.cloneElement(
|
|
389
391
|
child,
|
|
390
392
|
{
|
|
@@ -392,7 +394,7 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
392
394
|
...props,
|
|
393
395
|
onClick: handleClick,
|
|
394
396
|
className: cn(child.props.className, cls),
|
|
395
|
-
"data-active":
|
|
397
|
+
"data-active": dataActive,
|
|
396
398
|
} as Record<string, unknown>,
|
|
397
399
|
children ?? child.props.children,
|
|
398
400
|
);
|
|
@@ -440,14 +442,16 @@ export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarM
|
|
|
440
442
|
const cls = cn(menuSubButtonBase, size === "sm" && "text-[length:var(--text-xs)]", className);
|
|
441
443
|
|
|
442
444
|
if (render && React.isValidElement(render)) {
|
|
443
|
-
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
|
|
445
|
+
const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode; "data-active"?: string | boolean }>;
|
|
446
|
+
const childDataActive = child.props["data-active"];
|
|
447
|
+
const dataActive = childDataActive !== undefined ? childDataActive : resolvedIsActive || undefined;
|
|
444
448
|
return React.cloneElement(
|
|
445
449
|
child,
|
|
446
450
|
{
|
|
447
451
|
ref,
|
|
448
452
|
...props,
|
|
449
453
|
className: cn(child.props.className, cls),
|
|
450
|
-
"data-active":
|
|
454
|
+
"data-active": dataActive,
|
|
451
455
|
} as Record<string, unknown>,
|
|
452
456
|
children ?? child.props.children,
|
|
453
457
|
);
|
|
@@ -524,7 +528,7 @@ export function SidebarCollapsibleTrigger({ className, size = "md", children, on
|
|
|
524
528
|
openOnHover
|
|
525
529
|
delay={0}
|
|
526
530
|
closeDelay={150}
|
|
527
|
-
render={(triggerProps) => (
|
|
531
|
+
render={(triggerProps: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
528
532
|
<button {...triggerProps} {...props} type="button" className={cls} data-state={isOpen ? "open" : "closed"}>
|
|
529
533
|
{content}
|
|
530
534
|
</button>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "@SH_UI_UTILS@";
|
|
5
5
|
import { ChevronRightIcon, PanelLeftIcon } from "lucide-react";
|
|
6
|
-
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../popover/index.tsx";
|
|
7
7
|
import "./styles.css";
|
|
8
8
|
|
|
9
9
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
|
@@ -647,7 +647,16 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
647
647
|
const child = render as React.ReactElement<{
|
|
648
648
|
className?: string;
|
|
649
649
|
children?: React.ReactNode;
|
|
650
|
+
"data-active"?: string | boolean;
|
|
650
651
|
}>;
|
|
652
|
+
// 자식이 명시한 data-active 가 있으면 보존 (사용자가 자기 디자인 시스템의
|
|
653
|
+
// active state 를 data-active 셀렉터로 다루는 경우). 그 외엔 sh-ui 의
|
|
654
|
+
// 자동 추론값 사용.
|
|
655
|
+
const childDataActive = child.props["data-active"];
|
|
656
|
+
const dataActive =
|
|
657
|
+
childDataActive !== undefined
|
|
658
|
+
? childDataActive
|
|
659
|
+
: resolvedIsActive || undefined;
|
|
651
660
|
return React.cloneElement(
|
|
652
661
|
child,
|
|
653
662
|
{
|
|
@@ -655,7 +664,7 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
|
|
|
655
664
|
...props,
|
|
656
665
|
onClick: handleClick,
|
|
657
666
|
className: cn(child.props.className, cls),
|
|
658
|
-
"data-active":
|
|
667
|
+
"data-active": dataActive,
|
|
659
668
|
} as Record<string, unknown>,
|
|
660
669
|
children ?? child.props.children,
|
|
661
670
|
);
|
|
@@ -734,14 +743,21 @@ export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarM
|
|
|
734
743
|
const child = render as React.ReactElement<{
|
|
735
744
|
className?: string;
|
|
736
745
|
children?: React.ReactNode;
|
|
746
|
+
"data-active"?: string | boolean;
|
|
737
747
|
}>;
|
|
748
|
+
// 자식이 명시한 data-active 우선 (자기 디자인 시스템 호환).
|
|
749
|
+
const childDataActive = child.props["data-active"];
|
|
750
|
+
const dataActive =
|
|
751
|
+
childDataActive !== undefined
|
|
752
|
+
? childDataActive
|
|
753
|
+
: resolvedIsActive || undefined;
|
|
738
754
|
return React.cloneElement(
|
|
739
755
|
child,
|
|
740
756
|
{
|
|
741
757
|
ref,
|
|
742
758
|
...props,
|
|
743
759
|
className: cn(child.props.className, cls),
|
|
744
|
-
"data-active":
|
|
760
|
+
"data-active": dataActive,
|
|
745
761
|
} as Record<string, unknown>,
|
|
746
762
|
children ?? child.props.children,
|
|
747
763
|
);
|
|
@@ -886,7 +902,7 @@ export function SidebarCollapsibleTrigger({
|
|
|
886
902
|
openOnHover
|
|
887
903
|
delay={0}
|
|
888
904
|
closeDelay={150}
|
|
889
|
-
render={(triggerProps) => (
|
|
905
|
+
render={(triggerProps: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
890
906
|
<button
|
|
891
907
|
{...triggerProps}
|
|
892
908
|
{...props}
|
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
"foreground": { "$value": "{color.white}", "$type": "color" },
|
|
29
29
|
"hover": { "$value": "{color.red.700}", "$type": "color" }
|
|
30
30
|
},
|
|
31
|
-
"ring": { "$value": "{color.{base}.400}", "$type": "color" }
|
|
31
|
+
"ring": { "$value": "{color.{base}.400}", "$type": "color" },
|
|
32
|
+
"sidebar": {
|
|
33
|
+
"bg": { "$value": "{color.{base}.50}", "$type": "color" },
|
|
34
|
+
"fg": { "$value": "{color.{base}.950}", "$type": "color" },
|
|
35
|
+
"border": { "$value": "{color.{base}.200}", "$type": "color" },
|
|
36
|
+
"accent": { "$value": "{color.{base}.100}", "$type": "color" },
|
|
37
|
+
"accent-fg": { "$value": "{color.{base}.950}", "$type": "color" }
|
|
38
|
+
}
|
|
32
39
|
},
|
|
33
40
|
|
|
34
41
|
"dark": {
|
|
@@ -58,7 +65,14 @@
|
|
|
58
65
|
"foreground": { "$value": "{color.white}", "$type": "color" },
|
|
59
66
|
"hover": { "$value": "{color.red.500}", "$type": "color" }
|
|
60
67
|
},
|
|
61
|
-
"ring": { "$value": "{color.{base}.500}", "$type": "color" }
|
|
68
|
+
"ring": { "$value": "{color.{base}.500}", "$type": "color" },
|
|
69
|
+
"sidebar": {
|
|
70
|
+
"bg": { "$value": "{color.{base}.900}", "$type": "color" },
|
|
71
|
+
"fg": { "$value": "{color.{base}.50}", "$type": "color" },
|
|
72
|
+
"border": { "$value": "{color.{base}.800}", "$type": "color" },
|
|
73
|
+
"accent": { "$value": "{color.{base}.800}", "$type": "color" },
|
|
74
|
+
"accent-fg": { "$value": "{color.{base}.50}", "$type": "color" }
|
|
75
|
+
}
|
|
62
76
|
},
|
|
63
77
|
|
|
64
78
|
"radius": {
|
package/package.json
CHANGED
package/src/create/generator.js
CHANGED
|
@@ -152,6 +152,41 @@ function assertNoTtyFlag(value, flagLabel) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// 프로젝트/앱 이름을 디렉토리명으로 안전하게 쓸 수 있는지 검증.
|
|
156
|
+
// MCP (sh_ui_create_project / sh_ui_add_app) 는 LLM/외부 호출자가 임의의 문자열을 넘길 수
|
|
157
|
+
// 있는데, 그 값이 그대로 `path.resolve(parent, name)` 에 들어간다. 검증이 없으면 '../'
|
|
158
|
+
// 시퀀스로 부모 디렉토리를 벗어나 임의 위치에 파일 쓰기 / 기존 디렉토리 삭제(force+yes 경로)
|
|
159
|
+
// 까지 가능. 진입부에서 한 번 막아 두면 CLI 와 MCP 양쪽 모두 자동 차단.
|
|
160
|
+
export function validateProjectName(name, label = 'name') {
|
|
161
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
162
|
+
throw new Error(`${label} 가 비어있습니다.`);
|
|
163
|
+
}
|
|
164
|
+
if (name.length > 214) {
|
|
165
|
+
throw new Error(`${label} 가 너무 깁니다 (최대 214자).`);
|
|
166
|
+
}
|
|
167
|
+
// 선행 '.' 차단 → '..', '.ssh', '.git' 등 숨김/특수 디렉토리 봉쇄.
|
|
168
|
+
// 영숫자 + '.' + '_' + '-' 만 허용 → 경로 구분자('/', '\\'), NUL, 셸 메타문자 일괄 차단.
|
|
169
|
+
if (name.startsWith('.') || !/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`${label} '${name}' 가 유효하지 않습니다 — ` +
|
|
172
|
+
`영숫자, '_', '-', '.' 만 허용하며 '.' 로 시작할 수 없습니다 (디렉토리 이름만 허용, 경로 불가).`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return name;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 방어 가드 — validateProjectName 이 이미 traversal 을 차단하지만, 파괴적 작업(`fs.remove`)
|
|
179
|
+
// 직전에 한 번 더 봉쇄해 향후 다른 진입점이 추가되거나 검증을 우회하는 코드 패스가 생겨도
|
|
180
|
+
// 부모 디렉토리 밖을 건드리지 않도록 보장. parent 의 진짜 하위가 아니면 throw.
|
|
181
|
+
function assertWithin(parent, child) {
|
|
182
|
+
const rel = path.relative(parent, child);
|
|
183
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`safety: 대상 경로 '${child}' 가 부모 '${parent}' 외부 또는 부모 그 자체입니다 — 처리 거부.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
155
190
|
export async function createProject(options = {}) {
|
|
156
191
|
if (!process.stdin.isTTY) {
|
|
157
192
|
assertNoTtyFlag(options.name, '<project-name> (positional)');
|
|
@@ -161,10 +196,13 @@ export async function createProject(options = {}) {
|
|
|
161
196
|
}
|
|
162
197
|
}
|
|
163
198
|
|
|
164
|
-
const projectName =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
199
|
+
const projectName = validateProjectName(
|
|
200
|
+
options.name ?? await input({
|
|
201
|
+
message: '프로젝트 이름:',
|
|
202
|
+
default: 'my-app',
|
|
203
|
+
}),
|
|
204
|
+
'프로젝트 이름',
|
|
205
|
+
);
|
|
168
206
|
|
|
169
207
|
const platform = options.platform ?? await select({
|
|
170
208
|
message: '플랫폼:',
|
|
@@ -247,6 +285,12 @@ export async function createProject(options = {}) {
|
|
|
247
285
|
? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-dry-'))
|
|
248
286
|
: path.resolve(process.cwd(), projectName);
|
|
249
287
|
|
|
288
|
+
// 방어 가드 — projectName 검증을 이미 통과했어도 `fs.remove` 직전에 한 번 더 확인.
|
|
289
|
+
// dry-run 은 tmpdir 이라 parent 가 cwd 가 아니므로 스킵.
|
|
290
|
+
if (!options.dryRun) {
|
|
291
|
+
assertWithin(process.cwd(), targetDir);
|
|
292
|
+
}
|
|
293
|
+
|
|
250
294
|
if (!options.dryRun && await fs.pathExists(targetDir)) {
|
|
251
295
|
if (options.yes) {
|
|
252
296
|
await fs.remove(targetDir);
|
|
@@ -361,10 +405,13 @@ export async function addApp(options = {}) {
|
|
|
361
405
|
throw new Error('비대화형 환경(TTY 없음)에서는 name 이 필요합니다.');
|
|
362
406
|
}
|
|
363
407
|
|
|
364
|
-
const appName =
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
408
|
+
const appName = validateProjectName(
|
|
409
|
+
options.name ?? await input({
|
|
410
|
+
message: '앱 이름:',
|
|
411
|
+
default: 'web',
|
|
412
|
+
}),
|
|
413
|
+
'앱 이름',
|
|
414
|
+
);
|
|
368
415
|
|
|
369
416
|
const port = options.port ?? (process.stdin.isTTY
|
|
370
417
|
? await input({ message: '포트 번호:', default: '3000' })
|
|
@@ -1504,8 +1551,13 @@ const stripLocalePrefix = (pathname: string): string => {
|
|
|
1504
1551
|
* - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트 (인증 가드보다 먼저)
|
|
1505
1552
|
* - intl 이 로케일 prefix 처리 + NEXT_LOCALE 쿠키 set
|
|
1506
1553
|
* - 그 위에 인증 가드 — 토큰 없고 인증 라우트도 아니면 /sign-in 으로 redirect
|
|
1554
|
+
* - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
|
|
1507
1555
|
* - AT 만료 검사나 refresh 는 하지 않는다 (BFF 가 처리)
|
|
1508
1556
|
*/
|
|
1557
|
+
const DEV_BYPASS =
|
|
1558
|
+
process.env.NODE_ENV !== 'production' &&
|
|
1559
|
+
process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
|
|
1560
|
+
|
|
1509
1561
|
export default function proxy(req: NextRequest) {
|
|
1510
1562
|
const intlRes = intl(req);
|
|
1511
1563
|
const pathname = stripLocalePrefix(req.nextUrl.pathname);
|
|
@@ -1516,6 +1568,7 @@ export default function proxy(req: NextRequest) {
|
|
|
1516
1568
|
return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
|
|
1517
1569
|
}
|
|
1518
1570
|
|
|
1571
|
+
if (DEV_BYPASS) return intlRes;
|
|
1519
1572
|
if (isAuthRoute) return intlRes;
|
|
1520
1573
|
if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
|
|
1521
1574
|
|
|
@@ -16,10 +16,14 @@ export const authJwtPlugin = {
|
|
|
16
16
|
envVars: [
|
|
17
17
|
'# Auth (auth-jwt)',
|
|
18
18
|
'COOKIE_SECURE=false',
|
|
19
|
+
'# Dev 시 인증 가드 우회 — proxy.ts 가 이 flag 를 보면 /sign-in 으로 redirect 안 함.',
|
|
20
|
+
'# 실제 백엔드 연동 후엔 반드시 비워야 함 (또는 NODE_ENV 가 production 이면 무시).',
|
|
21
|
+
'NEXT_PUBLIC_DEV_AUTH_BYPASS=false',
|
|
19
22
|
],
|
|
20
23
|
|
|
21
24
|
turboEnvVars: [
|
|
22
25
|
'COOKIE_SECURE',
|
|
26
|
+
'NEXT_PUBLIC_DEV_AUTH_BYPASS',
|
|
23
27
|
],
|
|
24
28
|
|
|
25
29
|
providerImports: [],
|
|
@@ -101,10 +105,15 @@ const HOME_REDIRECT = '';
|
|
|
101
105
|
* - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트
|
|
102
106
|
* - AT 쿠키 없음 + 인증 라우트 아님 → /sign-in 으로 리다이렉트
|
|
103
107
|
* - AT 쿠키 있음 또는 인증 라우트 → 통과
|
|
108
|
+
* - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
|
|
104
109
|
*
|
|
105
110
|
* AT 가 만료된 채 통과한 요청은 BFF (/api/proxy) 가 401 을 받아
|
|
106
111
|
* refreshSession 으로 갱신을 시도한다.
|
|
107
112
|
*/
|
|
113
|
+
const DEV_BYPASS =
|
|
114
|
+
process.env.NODE_ENV !== 'production' &&
|
|
115
|
+
process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
|
|
116
|
+
|
|
108
117
|
export default function proxy(req: NextRequest) {
|
|
109
118
|
const { pathname } = req.nextUrl;
|
|
110
119
|
const hasToken = !!req.cookies.get('accessToken')?.value;
|
|
@@ -114,6 +123,7 @@ export default function proxy(req: NextRequest) {
|
|
|
114
123
|
return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
if (DEV_BYPASS) return NextResponse.next();
|
|
117
127
|
if (isAuthRoute) return NextResponse.next();
|
|
118
128
|
if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
|
|
119
129
|
|
|
@@ -271,8 +271,25 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
|
|
|
271
271
|
"save": "저장",
|
|
272
272
|
"delete": "삭제",
|
|
273
273
|
"edit": "수정",
|
|
274
|
+
"create": "만들기",
|
|
274
275
|
"search": "검색",
|
|
275
|
-
"back": "뒤로"
|
|
276
|
+
"back": "뒤로",
|
|
277
|
+
"name": "이름",
|
|
278
|
+
"description": "설명",
|
|
279
|
+
"empty": "아직 항목이 없습니다."
|
|
280
|
+
},
|
|
281
|
+
"nav": {
|
|
282
|
+
"home": "홈",
|
|
283
|
+
"settings": "설정"
|
|
284
|
+
},
|
|
285
|
+
"app": {
|
|
286
|
+
"title": "App"
|
|
287
|
+
},
|
|
288
|
+
"form": {
|
|
289
|
+
"required": "필수 항목입니다.",
|
|
290
|
+
"invalid": "올바른 값을 입력하세요.",
|
|
291
|
+
"submit": "제출",
|
|
292
|
+
"reset": "초기화"
|
|
276
293
|
},
|
|
277
294
|
"error": {
|
|
278
295
|
"title": "오류가 발생했습니다",
|
|
@@ -302,8 +319,25 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
|
|
|
302
319
|
"save": "Save",
|
|
303
320
|
"delete": "Delete",
|
|
304
321
|
"edit": "Edit",
|
|
322
|
+
"create": "Create",
|
|
305
323
|
"search": "Search",
|
|
306
|
-
"back": "Back"
|
|
324
|
+
"back": "Back",
|
|
325
|
+
"name": "Name",
|
|
326
|
+
"description": "Description",
|
|
327
|
+
"empty": "No items yet."
|
|
328
|
+
},
|
|
329
|
+
"nav": {
|
|
330
|
+
"home": "Home",
|
|
331
|
+
"settings": "Settings"
|
|
332
|
+
},
|
|
333
|
+
"app": {
|
|
334
|
+
"title": "App"
|
|
335
|
+
},
|
|
336
|
+
"form": {
|
|
337
|
+
"required": "This field is required.",
|
|
338
|
+
"invalid": "Please enter a valid value.",
|
|
339
|
+
"submit": "Submit",
|
|
340
|
+
"reset": "Reset"
|
|
307
341
|
},
|
|
308
342
|
"error": {
|
|
309
343
|
"title": "Something went wrong",
|
package/src/mcp.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { list } from "./list.mjs";
|
|
|
32
32
|
import { remove } from "./remove.mjs";
|
|
33
33
|
import { renameApp } from "./rename-app.mjs";
|
|
34
34
|
import { migrateToV065 } from "./migrate-v065.mjs";
|
|
35
|
-
import { createProject, addApp } from "./create/generator.js";
|
|
35
|
+
import { createProject, addApp, validateProjectName } from "./create/generator.js";
|
|
36
36
|
import {
|
|
37
37
|
getRegistryRoot,
|
|
38
38
|
getSummariesPath,
|
|
@@ -263,6 +263,59 @@ CLI \`sh-ui add <name>\` 은 monorepo 의 어느 디렉토리에서든 (apps/web
|
|
|
263
263
|
- 다이얼로그 cancel 버튼을 \`<Button onClick={() => setOpen(false)}>\` 로 우회 → 정석은 \`<DialogClose render={<Button>취소</Button>} />\` (Base UI render prop)
|
|
264
264
|
- table 외관의 카드 그리드를 raw \`<div>\` 로 → \`Card\` / \`CardHeader\` / \`CardContent\` / \`CardFooter\` 사용
|
|
265
265
|
|
|
266
|
+
## Base UI 합성 함정 (Next.js App Router)
|
|
267
|
+
|
|
268
|
+
Base UI 위에 빌드된 sh-ui 컴포넌트 (\`DropdownMenu\` / \`Select\` / \`Dialog\` / \`Popover\` / \`Tooltip\` / \`Combobox\`) 두 가지 알려진 패턴:
|
|
269
|
+
|
|
270
|
+
### 1. SSR hydration warning (auto-id)
|
|
271
|
+
|
|
272
|
+
Base UI 의 \`useId()\` 가 서버/클라이언트 ID 가 다를 수 있어 hydration mismatch 경고. 동작은 무해하지만 콘솔 노이즈.
|
|
273
|
+
|
|
274
|
+
**회피 패턴 — mounted gate** (sidebar header 등 항상 보이는 trigger 에 적용):
|
|
275
|
+
|
|
276
|
+
\`\`\`tsx
|
|
277
|
+
const [mounted, setMounted] = useState(false);
|
|
278
|
+
useEffect(() => setMounted(true), []);
|
|
279
|
+
|
|
280
|
+
if (!mounted) {
|
|
281
|
+
// trigger 외형만 그대로, dropdown wrapping 없이 placeholder 렌더
|
|
282
|
+
return <div className={TRIGGER_CLS}>…</div>;
|
|
283
|
+
}
|
|
284
|
+
return (
|
|
285
|
+
<DropdownMenu>
|
|
286
|
+
<DropdownMenuTrigger className={TRIGGER_CLS}>…</DropdownMenuTrigger>
|
|
287
|
+
<DropdownMenuContent>…</DropdownMenuContent>
|
|
288
|
+
</DropdownMenu>
|
|
289
|
+
);
|
|
290
|
+
\`\`\`
|
|
291
|
+
|
|
292
|
+
### 2. \`DropdownMenuItem\` 안에 \`DialogTrigger\` render 시 dialog 안 열림
|
|
293
|
+
|
|
294
|
+
두 Base UI primitive 의 render-prop 체인이 onClick / onSelect 충돌.
|
|
295
|
+
|
|
296
|
+
**잘못된 패턴**:
|
|
297
|
+
\`\`\`tsx
|
|
298
|
+
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
299
|
+
<DialogTrigger render={<Button>…</Button>} /> // 안 열림
|
|
300
|
+
</DropdownMenuItem>
|
|
301
|
+
\`\`\`
|
|
302
|
+
|
|
303
|
+
**정석 패턴 — controlled dialog 를 sibling 으로**:
|
|
304
|
+
\`\`\`tsx
|
|
305
|
+
const [open, setOpen] = useState(false);
|
|
306
|
+
return (
|
|
307
|
+
<>
|
|
308
|
+
<DropdownMenu>
|
|
309
|
+
…
|
|
310
|
+
<DropdownMenuItem onClick={() => setOpen(true)}>…</DropdownMenuItem>
|
|
311
|
+
</DropdownMenu>
|
|
312
|
+
<MyDialog open={open} onOpenChange={setOpen} />
|
|
313
|
+
</>
|
|
314
|
+
);
|
|
315
|
+
\`\`\`
|
|
316
|
+
|
|
317
|
+
다이얼로그 컴포넌트는 controlled (\`open\`/\`onOpenChange\`) 모드를 지원하도록 작성해 둘 것.
|
|
318
|
+
|
|
266
319
|
## 앱 이름 변경 (monorepo)
|
|
267
320
|
|
|
268
321
|
사용자가 "apps/web 을 apps/dashboard 로 바꿔줘" 같이 모노레포 앱 이름 변경을 요청하면 \`sh_ui_rename_app\` 사용 — 손으로 6~10 군데 (디렉토리, package.json name, tsconfig paths, Dockerfile WORKDIR, next.config transpilePackages, sh-ui.config aliases, README, .github/workflows) 갈아엎지 않도록 자동화. \`dryRun: true\` 로 먼저 변경 매트릭스 보여주고 사용자 확인 후 실행 권장.
|
|
@@ -354,6 +407,13 @@ export async function startMcpServer() {
|
|
|
354
407
|
],
|
|
355
408
|
};
|
|
356
409
|
}
|
|
410
|
+
// path traversal 차단 — existsSync probe / fs.remove 흐름이 임의 경로로
|
|
411
|
+
// 흘러가지 않도록 입력 검증을 가장 먼저.
|
|
412
|
+
try {
|
|
413
|
+
validateProjectName(input.name, "name");
|
|
414
|
+
} catch (e) {
|
|
415
|
+
return { isError: true, content: [{ type: "text", text: e.message }] };
|
|
416
|
+
}
|
|
357
417
|
const targetParent = resolveCwd(input);
|
|
358
418
|
const targetDir = resolve(targetParent, input.name);
|
|
359
419
|
if (existsSync(targetDir) && !input.force) {
|
|
@@ -413,6 +473,11 @@ export async function startMcpServer() {
|
|
|
413
473
|
},
|
|
414
474
|
},
|
|
415
475
|
async (input) => {
|
|
476
|
+
try {
|
|
477
|
+
validateProjectName(input.name, "name");
|
|
478
|
+
} catch (e) {
|
|
479
|
+
return { isError: true, content: [{ type: "text", text: e.message }] };
|
|
480
|
+
}
|
|
416
481
|
const text = await captureConsole(() =>
|
|
417
482
|
addApp({
|
|
418
483
|
name: input.name,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 프로젝트 작업 규칙
|
|
2
|
+
|
|
3
|
+
sh-ui CLI 가 스캐폴드한 monorepo (Turborepo + pnpm workspace). AI 에이전트
|
|
4
|
+
(Claude / Cursor / Codex 등) 가 이 파일을 컨텍스트로 읽고 아래 규칙을 따른다.
|
|
5
|
+
|
|
6
|
+
## 구조
|
|
7
|
+
|
|
8
|
+
- `apps/<name>/` — Next.js 앱. 라우트 + 비즈니스 로직.
|
|
9
|
+
- `packages/ui/ui-core/` — 모든 앱이 공유하는 sh-ui 컴포넌트 / 훅 / 유틸 SoT.
|
|
10
|
+
컴포넌트 추가는 여기에 한 번만.
|
|
11
|
+
- `packages/ui/ui-apps/ui-<name>/` — 앱별 토큰 (color/spacing/font) 만 보관.
|
|
12
|
+
컴포넌트는 두지 않음 (v0.65+ `tokens-only` 마커).
|
|
13
|
+
- `packages/eslint-config/` · `packages/typescript-config/` — 공용 설정.
|
|
14
|
+
|
|
15
|
+
## 날짜 / 숫자 포맷
|
|
16
|
+
|
|
17
|
+
- raw `Date.prototype.toLocaleDateString()` / `toLocaleString()` / `toLocaleTimeString()`
|
|
18
|
+
호출 **금지** — SSR(Node) 와 브라우저의 기본 로케일이 달라 hydration mismatch 의
|
|
19
|
+
원인. ESLint `no-restricted-syntax` 룰이 인자 0개의 호출을 막는다
|
|
20
|
+
(`@workspace/eslint-config/base` 에 정의).
|
|
21
|
+
- 대신 `@/src/shared/lib/formatDate` 의 `formatDate(date)` / `formatDateTime(date)`
|
|
22
|
+
사용 (default locale `ko-KR`, 서버·클라이언트 동일 출력 보장).
|
|
23
|
+
- next-intl locale 추종이 필요하면 `@/src/shared/hooks/useFormatDate` 훅 사용.
|
|
24
|
+
- 동일 원칙이 숫자에도 적용 — raw `Number.prototype.toLocaleString()` 금지,
|
|
25
|
+
`formatPrice` 사용.
|
|
26
|
+
- 인자가 명시된 호출 (`toLocaleDateString('ko-KR', { ... })`) 은 의도된 사용이므로
|
|
27
|
+
허용. 다만 SSR 출력 결정성을 위해서는 util 경유가 안전.
|
|
28
|
+
|
|
29
|
+
## 새 앱 추가
|
|
30
|
+
|
|
31
|
+
`sh_ui_add_app` MCP 툴 또는 `npx sh-ui-cli add app <name>` — `apps/<name>/` +
|
|
32
|
+
`packages/ui/ui-apps/ui-<name>/` 를 한 번에 만든다. 앱별로 다른 톤 가능 (예:
|
|
33
|
+
marketing = rose, admin = emerald). 컴포넌트는 `ui-core` 단일 SoT 라 두 앱이
|
|
34
|
+
자동 공유.
|
|
35
|
+
|
|
36
|
+
## 토큰 사용
|
|
37
|
+
|
|
38
|
+
- 색상 / 간격 / 폰트 크기는 항상 토큰 변수 경유 (`var(--space-3)`, `bg-fg`,
|
|
39
|
+
`text-fg-muted` 등). 매직 px / hex 직접 하드코딩 금지.
|
|
40
|
+
- 토큰 정의부는 `packages/ui/ui-apps/ui-<name>/src/styles/tokens.css`.
|
|
@@ -18,6 +18,15 @@ export const config = [
|
|
|
18
18
|
"warn",
|
|
19
19
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
20
20
|
],
|
|
21
|
+
"no-restricted-syntax": [
|
|
22
|
+
"error",
|
|
23
|
+
{
|
|
24
|
+
selector:
|
|
25
|
+
"CallExpression[arguments.length=0][callee.type='MemberExpression'][callee.property.name=/^toLocale(Date|Time)?String$/]",
|
|
26
|
+
message:
|
|
27
|
+
"Argument-less .toLocaleDateString() / .toLocaleString() / .toLocaleTimeString() causes SSR hydration mismatch (Node default locale ≠ browser locale). Use `formatDate` from `@/src/shared/lib/formatDate`, or the `useFormatDate` hook for i18n-aware locale (numbers: `formatPrice`).",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
21
30
|
},
|
|
22
31
|
},
|
|
23
32
|
{
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/components/providers';
|
|
2
2
|
|
|
3
|
+
/** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
|
|
4
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
5
|
+
|
|
3
6
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
7
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
8
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
9
|
return (
|
|
7
10
|
<html lang='ko' suppressHydrationWarning>
|
|
11
|
+
<head>
|
|
12
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
13
|
+
</head>
|
|
8
14
|
<body>
|
|
9
15
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
16
|
</body>
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/src/app/providers';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 첫 paint 전에 localStorage 의 theme 값을 읽어 <html> 에 class 를 박는
|
|
5
|
+
* FOUC 차단 inline script. next-themes 의 ThemeProvider 가 client mount 후
|
|
6
|
+
* 동일 작업을 하지만, mount 전 한 frame 동안 light/dark 깜빡임이 생긴다.
|
|
7
|
+
* 이걸 막으려고 SSR 응답 head 안쪽에 동기 실행 script 박음.
|
|
8
|
+
*/
|
|
9
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
10
|
+
|
|
3
11
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
12
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
13
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
14
|
return (
|
|
7
15
|
<html lang='ko' suppressHydrationWarning>
|
|
16
|
+
<head>
|
|
17
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
18
|
+
</head>
|
|
8
19
|
<body>
|
|
9
20
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
21
|
</body>
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/components/providers';
|
|
2
2
|
|
|
3
|
+
/** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
|
|
4
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
5
|
+
|
|
3
6
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
7
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
8
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
9
|
return (
|
|
7
10
|
<html lang='ko' suppressHydrationWarning>
|
|
11
|
+
<head>
|
|
12
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
13
|
+
</head>
|
|
8
14
|
<body>
|
|
9
15
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
16
|
</body>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 프로젝트 작업 규칙
|
|
2
|
+
|
|
3
|
+
sh-ui CLI 가 스캐폴드한 Next.js standalone 프로젝트. AI 에이전트 (Claude / Cursor /
|
|
4
|
+
Codex 등) 가 이 파일을 컨텍스트로 읽고 아래 규칙을 따른다.
|
|
5
|
+
|
|
6
|
+
## 날짜 / 숫자 포맷
|
|
7
|
+
|
|
8
|
+
- raw `Date.prototype.toLocaleDateString()` / `toLocaleString()` / `toLocaleTimeString()`
|
|
9
|
+
호출 **금지** — SSR(Node) 와 브라우저의 기본 로케일이 달라 hydration mismatch 의
|
|
10
|
+
원인. ESLint `no-restricted-syntax` 룰이 인자 0개의 호출을 막는다.
|
|
11
|
+
- 대신 `@/src/shared/lib/formatDate` 의 `formatDate(date)` / `formatDateTime(date)`
|
|
12
|
+
사용 (default locale `ko-KR`, 서버·클라이언트 동일 출력 보장).
|
|
13
|
+
- next-intl locale 추종이 필요하면 `@/src/shared/hooks/useFormatDate` 훅 사용.
|
|
14
|
+
- 동일 원칙이 숫자에도 적용 — raw `Number.prototype.toLocaleString()` 금지,
|
|
15
|
+
`@/src/shared/lib/formatPrice` 의 `formatPrice` 사용.
|
|
16
|
+
- 인자가 명시된 호출 (`toLocaleDateString('ko-KR', { ... })`) 은 의도된 사용이므로
|
|
17
|
+
허용. 다만 SSR 출력 결정성을 위해서는 util 경유가 안전.
|
|
18
|
+
|
|
19
|
+
## sh-ui 컴포넌트 우선
|
|
20
|
+
|
|
21
|
+
- shadcn/ui 류 외부 라이브러리 대신 이 프로젝트의 `components/ui/*` (sh-ui 레지스트리)
|
|
22
|
+
사용. Base UI (`@base-ui-components/react`) 위에 빌드되어 있음.
|
|
23
|
+
- 새 컴포넌트가 필요하면 `npx sh-ui-cli add <name>` 또는 sh-ui MCP 의
|
|
24
|
+
`sh_ui_add_component` 사용.
|
|
25
|
+
|
|
26
|
+
## 토큰 사용
|
|
27
|
+
|
|
28
|
+
- 색상 / 간격 / 폰트 크기는 항상 토큰 변수 경유 (`var(--space-3)`, `bg-fg`,
|
|
29
|
+
`text-fg-muted` 등). 매직 px / hex 직접 하드코딩 금지.
|
|
30
|
+
- 토큰 정의부는 `app/globals.css` 또는 `src/shared/styles/tokens.css`.
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
--color-warning-foreground: var(--warning-foreground);
|
|
36
36
|
--color-info: var(--info);
|
|
37
37
|
--color-info-foreground: var(--info-foreground);
|
|
38
|
+
--color-sidebar-bg: var(--sidebar-bg);
|
|
39
|
+
--color-sidebar-fg: var(--sidebar-fg);
|
|
40
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
41
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
42
|
+
--color-sidebar-accent-fg: var(--sidebar-accent-fg);
|
|
38
43
|
--radius-sm: calc(var(--radius) - 2px);
|
|
39
44
|
--radius-md: var(--radius);
|
|
40
45
|
--radius-lg: calc(var(--radius) + 2px);
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/components/providers';
|
|
2
2
|
|
|
3
|
+
/** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
|
|
4
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
5
|
+
|
|
3
6
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
7
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
8
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
9
|
return (
|
|
7
10
|
<html lang='ko' suppressHydrationWarning>
|
|
11
|
+
<head>
|
|
12
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
13
|
+
</head>
|
|
8
14
|
<body>
|
|
9
15
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
16
|
</body>
|
|
@@ -27,6 +27,15 @@ export default [
|
|
|
27
27
|
"warn",
|
|
28
28
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
29
29
|
],
|
|
30
|
+
"no-restricted-syntax": [
|
|
31
|
+
"error",
|
|
32
|
+
{
|
|
33
|
+
selector:
|
|
34
|
+
"CallExpression[arguments.length=0][callee.type='MemberExpression'][callee.property.name=/^toLocale(Date|Time)?String$/]",
|
|
35
|
+
message:
|
|
36
|
+
"Argument-less .toLocaleDateString() / .toLocaleString() / .toLocaleTimeString() causes SSR hydration mismatch (Node default locale ≠ browser locale). Use `formatDate` from `lib/utils/formatDate`, or the `useFormatDate` hook for i18n-aware locale (numbers: `formatPrice`).",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
30
39
|
},
|
|
31
40
|
},
|
|
32
41
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/src/app/providers';
|
|
2
2
|
|
|
3
|
+
/** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
|
|
4
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
5
|
+
|
|
3
6
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
7
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
8
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
9
|
return (
|
|
7
10
|
<html lang='ko' suppressHydrationWarning>
|
|
11
|
+
<head>
|
|
12
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
13
|
+
</head>
|
|
8
14
|
<body>
|
|
9
15
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
16
|
</body>
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
--color-warning-foreground: var(--warning-foreground);
|
|
36
36
|
--color-info: var(--info);
|
|
37
37
|
--color-info-foreground: var(--info-foreground);
|
|
38
|
+
--color-sidebar-bg: var(--sidebar-bg);
|
|
39
|
+
--color-sidebar-fg: var(--sidebar-fg);
|
|
40
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
41
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
42
|
+
--color-sidebar-accent-fg: var(--sidebar-accent-fg);
|
|
38
43
|
--radius-sm: calc(var(--radius) - 2px);
|
|
39
44
|
--radius-md: var(--radius);
|
|
40
45
|
--radius-lg: calc(var(--radius) + 2px);
|
|
@@ -27,6 +27,15 @@ export default [
|
|
|
27
27
|
"warn",
|
|
28
28
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
29
29
|
],
|
|
30
|
+
"no-restricted-syntax": [
|
|
31
|
+
"error",
|
|
32
|
+
{
|
|
33
|
+
selector:
|
|
34
|
+
"CallExpression[arguments.length=0][callee.type='MemberExpression'][callee.property.name=/^toLocale(Date|Time)?String$/]",
|
|
35
|
+
message:
|
|
36
|
+
"Argument-less .toLocaleDateString() / .toLocaleString() / .toLocaleTimeString() causes SSR hydration mismatch (Node default locale ≠ browser locale). Use `formatDate` from `src/lib/utils/formatDate`, or the `useFormatDate` hook for i18n-aware locale (numbers: `formatPrice`).",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
30
39
|
},
|
|
31
40
|
},
|
|
32
41
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/components/providers';
|
|
2
2
|
|
|
3
|
+
/** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
|
|
4
|
+
const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
|
|
5
|
+
|
|
3
6
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
7
|
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
8
|
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
6
9
|
return (
|
|
7
10
|
<html lang='ko' suppressHydrationWarning>
|
|
11
|
+
<head>
|
|
12
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
13
|
+
</head>
|
|
8
14
|
<body>
|
|
9
15
|
<GlobalProvider>{children}</GlobalProvider>
|
|
10
16
|
</body>
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
--color-warning-foreground: var(--warning-foreground);
|
|
32
32
|
--color-info: var(--info);
|
|
33
33
|
--color-info-foreground: var(--info-foreground);
|
|
34
|
+
--color-sidebar-bg: var(--sidebar-bg);
|
|
35
|
+
--color-sidebar-fg: var(--sidebar-fg);
|
|
36
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
37
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
38
|
+
--color-sidebar-accent-fg: var(--sidebar-accent-fg);
|
|
34
39
|
--radius-sm: calc(var(--radius) - 2px);
|
|
35
40
|
--radius-md: var(--radius);
|
|
36
41
|
--radius-lg: calc(var(--radius) + 2px);
|
|
@@ -27,6 +27,15 @@ export default [
|
|
|
27
27
|
"warn",
|
|
28
28
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
29
29
|
],
|
|
30
|
+
"no-restricted-syntax": [
|
|
31
|
+
"error",
|
|
32
|
+
{
|
|
33
|
+
selector:
|
|
34
|
+
"CallExpression[arguments.length=0][callee.type='MemberExpression'][callee.property.name=/^toLocale(Date|Time)?String$/]",
|
|
35
|
+
message:
|
|
36
|
+
"Argument-less .toLocaleDateString() / .toLocaleString() / .toLocaleTimeString() causes SSR hydration mismatch (Node default locale ≠ browser locale). Use `formatDate` from `@/src/shared/lib/formatDate`, or the `useFormatDate` hook for i18n-aware locale (numbers: `formatPrice`).",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
30
39
|
},
|
|
31
40
|
},
|
|
32
41
|
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
--color-warning-foreground: var(--warning-foreground);
|
|
36
36
|
--color-info: var(--info);
|
|
37
37
|
--color-info-foreground: var(--info-foreground);
|
|
38
|
+
--color-sidebar-bg: var(--sidebar-bg);
|
|
39
|
+
--color-sidebar-fg: var(--sidebar-fg);
|
|
40
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
41
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
42
|
+
--color-sidebar-accent-fg: var(--sidebar-accent-fg);
|
|
38
43
|
--radius-sm: calc(var(--radius) - 2px);
|
|
39
44
|
--radius-md: var(--radius);
|
|
40
45
|
--radius-lg: calc(var(--radius) + 2px);
|