sh-ui-cli 0.72.0 → 0.74.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/bin/sh-ui.mjs +7 -0
- package/data/changelog/versions.json +27 -0
- package/package.json +2 -1
- package/sh-ui.schema.json +135 -0
- package/src/init.mjs +1 -1
- package/src/theme-extract.mjs +104 -8
- package/src/tokens-cmd.mjs +19 -9
- package/src/tokens-diff-dart.mjs +147 -0
- package/src/upgrade-cli.mjs +180 -0
- package/templates/flutter-standalone/sh-ui.config.json +1 -0
- package/templates/monorepo/packages/ui/ui-core/sh-ui.config.json +1 -0
- package/templates/nextjs-standalone/_arch/flat/sh-ui.config.json +1 -0
- package/templates/nextjs-standalone/_arch/fsd/sh-ui.config.json +1 -0
- package/templates/ui-app-template/sh-ui.config.json +1 -0
package/bin/sh-ui.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const usage = `사용법:
|
|
|
16
16
|
특수값: tokens → 설정 기반 토큰 파일 생성
|
|
17
17
|
sh-ui list 현재 설치된 컴포넌트 목록 표시
|
|
18
18
|
sh-ui doctor 프로젝트 정합성 점검 (config / tokens / 설치된 컴포넌트)
|
|
19
|
+
sh-ui upgrade-cli [--apply] sh-ui-cli 자체를 최신으로 업그레이드 + 설치 후 진단
|
|
19
20
|
sh-ui tokens diff tokens.css 와 buildTokens 결과 비교 (added/changed/removed)
|
|
20
21
|
sh-ui tokens upgrade [--apply|--replace]
|
|
21
22
|
--apply: 추가만 incremental (사용자 편집 보존)
|
|
@@ -155,6 +156,12 @@ try {
|
|
|
155
156
|
if (anyFailed) process.exit(1);
|
|
156
157
|
break;
|
|
157
158
|
}
|
|
159
|
+
case "upgrade-cli": {
|
|
160
|
+
const apply = rest.includes("--apply");
|
|
161
|
+
const { runUpgradeCli } = await import("../src/upgrade-cli.mjs");
|
|
162
|
+
await runUpgradeCli({ cwd: process.cwd(), apply });
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
158
165
|
case "theme": {
|
|
159
166
|
const sub = rest[0];
|
|
160
167
|
const flags = rest.slice(1);
|
|
@@ -2,6 +2,33 @@
|
|
|
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.74.0",
|
|
7
|
+
"date": "2026-05-10",
|
|
8
|
+
"title": "DX — sh-ui.config.json JSON Schema + sh-ui upgrade-cli",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**JSON Schema for sh-ui.config.json** — `packages/cli/sh-ui.schema.json` 신설. `$schema` 필드를 GitHub raw URL 로 통일 (`https://raw.githubusercontent.com/sanghyeonKim0201/sh-ui/live/packages/cli/sh-ui.schema.json`). VS Code / Cursor 등이 자동 fetch 해서 키 자동완성 + enum 값 제안 + 오타 빨간 줄. platform / cssFramework / cssStrategy / theme.base / paths.* / aliases.* 모두 description 과 examples 포함.",
|
|
12
|
+
"**조건부 require** — `cssStrategy: \"bundled\"` 면 `paths.cssBundle` 필수, JSON Schema if/then 으로 자동 검증.",
|
|
13
|
+
"**`sh-ui upgrade-cli` 명령** — npm registry 의 latest 버전과 사용자 node_modules 의 설치본 비교. 사이의 changelog highlights (versions.json) 자동 출력. `--apply` 시 사용자 패키지 매니저로 자동 install + 설치 후 doctor / tokens diff 권장 흐름 안내.",
|
|
14
|
+
"**모든 템플릿 + init/create + apps/docs 의 sh-ui.config.json 에 \\$schema 일괄 적용** — 신규 프로젝트는 처음부터 IDE validation 동작."
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.74.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.73.0",
|
|
20
|
+
"date": "2026-05-10",
|
|
21
|
+
"title": "Flutter — theme extract + tokens diff/upgrade Dart 지원",
|
|
22
|
+
"type": "minor",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"**`sh-ui theme extract` Flutter 지원** — `sh_ui_tokens.dart` 의 `static const light/dark` 블록에서 `Color(0xFFRRGGBB)` 를 추출해 sh-ui base64 로 인코딩. alpha 채널은 무시 (#RRGGBB 만 인코딩), `defaultRadius` 픽셀값을 16 으로 나눠 rem 변환.",
|
|
25
|
+
"**`sh-ui tokens diff` Flutter 지원** — Dart 의 `static const` 블록을 `<ClassName>.<staticName>` 키로 평탄화 후 field-level 비교. nested 함수 호출 (`Color(0x...)`, `Cubic(0.4, 0, 0.2, 1)`, `Duration(milliseconds: 120)`, `<BoxShadow>[...]`) 를 깊이 카운터로 안전하게 split — 간단 정규식 파서가 깨지지 않음.",
|
|
26
|
+
"**`sh-ui tokens upgrade --replace` Flutter 지원** — buildTokensDart 결과로 통째 덮어쓰기.",
|
|
27
|
+
"**`--apply` 는 Flutter 미지원** — Dart 클래스에 필드 추가는 (1) class field 선언, (2) constructor 파라미터, (3) 모든 static const 인스턴스화 3 군데를 동시에 수정해야 해서 incremental 적용이 위험. 친절 에러로 안내 + `--replace` 권장.",
|
|
28
|
+
"**dispatch 통합** — tokens-cmd / theme-extract 가 platform 으로 분기. React (CSS) 와 Flutter (Dart) 가 같은 명령 표면 공유."
|
|
29
|
+
],
|
|
30
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.73.0"
|
|
31
|
+
},
|
|
5
32
|
{
|
|
6
33
|
"version": "0.72.0",
|
|
7
34
|
"date": "2026-05-10",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sh-ui-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"src",
|
|
57
57
|
"data",
|
|
58
58
|
"templates",
|
|
59
|
+
"sh-ui.schema.json",
|
|
59
60
|
"LICENSE",
|
|
60
61
|
"README.md"
|
|
61
62
|
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/sanghyeonKim0201/sh-ui/live/packages/cli/sh-ui.schema.json",
|
|
4
|
+
"title": "sh-ui project configuration",
|
|
5
|
+
"description": "sh-ui CLI 가 읽는 프로젝트 설정. 컴포넌트 추가/제거/토큰 빌드의 모든 동작이 이 파일을 따라간다.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["platform", "paths"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "JSON Schema URL — IDE 자동완성용. 그대로 두는 게 안전."
|
|
13
|
+
},
|
|
14
|
+
"platform": {
|
|
15
|
+
"enum": ["react", "flutter"],
|
|
16
|
+
"description": "타겟 플랫폼. React (웹) 또는 Flutter."
|
|
17
|
+
},
|
|
18
|
+
"cssFramework": {
|
|
19
|
+
"enum": ["plain", "tailwind", "css-modules", "vanilla-extract"],
|
|
20
|
+
"description": "CSS 변종. plain = 표준 CSS + var(--*), tailwind = utility class, css-modules = .module.css, vanilla-extract = .css.ts (실험). React 전용 — Flutter 면 무시.",
|
|
21
|
+
"default": "plain"
|
|
22
|
+
},
|
|
23
|
+
"cssStrategy": {
|
|
24
|
+
"enum": ["per-component", "bundled"],
|
|
25
|
+
"description": "컴포넌트 CSS 전략. per-component = 각 컴포넌트 폴더에 styles.css (기본). bundled = paths.cssBundle 단일 파일에 마커 섹션 (v0.71+). React + plain 만 지원.",
|
|
26
|
+
"default": "per-component"
|
|
27
|
+
},
|
|
28
|
+
"role": {
|
|
29
|
+
"enum": ["tokens-only"],
|
|
30
|
+
"description": "v0.65+ 모노레포 ui-app 에 부여 — 컴포넌트 추가는 sibling ui-core 로 라우팅하고 tokens 만 허용."
|
|
31
|
+
},
|
|
32
|
+
"theme": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "디자인 토큰의 색·radius·모드 설정. buildTokens 가 이 값으로 tokens.css 또는 sh_ui_tokens.dart 를 생성.",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"properties": {
|
|
37
|
+
"base": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "기본 색 스케일. buildable preset (neutral/zinc/slate) 은 buildTokens 로 재생성 가능. rich preset (rose/emerald/violet) 또는 'custom' (base64 originated) 은 보존-only.",
|
|
40
|
+
"examples": ["neutral", "zinc", "slate", "rose", "emerald", "violet", "custom"]
|
|
41
|
+
},
|
|
42
|
+
"radius": {
|
|
43
|
+
"enum": ["none", "sm", "md", "lg", "xl", "full"],
|
|
44
|
+
"description": "기본 코너 반경 — buildTokens 가 --radius CSS 변수로 emit."
|
|
45
|
+
},
|
|
46
|
+
"mode": {
|
|
47
|
+
"enum": ["light", "dark", "light-dark"],
|
|
48
|
+
"description": "다크 모드 전략. light-dark = OS 자동 + .light/.dark 클래스 토글, light/dark = 강제 단일 모드."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"paths": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"description": "프로젝트 내 파일/디렉토리 위치. CLI 가 컴포넌트 dest, tokens 위치, alias placeholder 해석에 사용.",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"properties": {
|
|
57
|
+
"tokens": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "tokens.css (React) 또는 sh_ui_tokens.dart (Flutter) 파일 경로.",
|
|
60
|
+
"examples": ["src/styles/tokens.css", "src/shared/styles/tokens.css", "lib/styles/tokens.css", "lib/sh_ui/foundation/sh_ui_tokens.dart"]
|
|
61
|
+
},
|
|
62
|
+
"cssEntry": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "tokens.css 를 import 하는 globals.css 의 경로 (선택). doctor 가 import 정합성 검증에 사용. v0.68+.",
|
|
65
|
+
"examples": ["app/globals.css", "src/styles/globals.css"]
|
|
66
|
+
},
|
|
67
|
+
"cssBundle": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "cssStrategy=bundled 일 때 컴포넌트 CSS 가 누적되는 단일 파일 경로. v0.71+.",
|
|
70
|
+
"examples": ["src/styles/sh-ui-components.css"]
|
|
71
|
+
},
|
|
72
|
+
"styles": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"description": "styles 디렉토리 (tokens, base 등 공유 CSS 위치).",
|
|
75
|
+
"examples": ["src/styles", "src/shared/styles"]
|
|
76
|
+
},
|
|
77
|
+
"components": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "컴포넌트가 복사될 디렉토리. CLI 의 add 가 이 아래 <name>/index.tsx 등을 작성.",
|
|
80
|
+
"examples": ["src/components/ui", "src/shared/ui", "lib/sh_ui/widgets"]
|
|
81
|
+
},
|
|
82
|
+
"utils": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": "cn 유틸 등을 둘 utils 파일 경로 (React)."
|
|
85
|
+
},
|
|
86
|
+
"hooks": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "hooks 디렉토리 (선택)."
|
|
89
|
+
},
|
|
90
|
+
"foundation": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"description": "Flutter foundation 디렉토리 (tokens.dart 등이 있는 곳)."
|
|
93
|
+
},
|
|
94
|
+
"widgets": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "Flutter widgets 디렉토리."
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"aliases": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"description": "TS module alias — 컴포넌트가 cn 유틸 등을 import 할 때 치환. tsconfig.json 의 paths 와 정합되어야 함.",
|
|
103
|
+
"additionalProperties": true,
|
|
104
|
+
"properties": {
|
|
105
|
+
"components": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"examples": ["@/components/ui", "@workspace/ui-core/components"]
|
|
108
|
+
},
|
|
109
|
+
"utils": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"examples": ["@/lib/utils", "@workspace/ui-core/lib/utils"]
|
|
112
|
+
},
|
|
113
|
+
"ui": {
|
|
114
|
+
"type": "string"
|
|
115
|
+
},
|
|
116
|
+
"hooks": {
|
|
117
|
+
"type": "string"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"allOf": [
|
|
123
|
+
{
|
|
124
|
+
"if": {
|
|
125
|
+
"properties": { "cssStrategy": { "const": "bundled" } },
|
|
126
|
+
"required": ["cssStrategy"]
|
|
127
|
+
},
|
|
128
|
+
"then": {
|
|
129
|
+
"properties": {
|
|
130
|
+
"paths": { "required": ["cssBundle"] }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -152,7 +152,7 @@ function labelFor(key) {
|
|
|
152
152
|
|
|
153
153
|
function buildConfig({ platform, base, radius, mode, cssFramework }) {
|
|
154
154
|
return {
|
|
155
|
-
$schema: "https://
|
|
155
|
+
$schema: "https://raw.githubusercontent.com/sanghyeonKim0201/sh-ui/live/packages/cli/sh-ui.schema.json",
|
|
156
156
|
platform,
|
|
157
157
|
cssFramework,
|
|
158
158
|
theme: { base, radius, mode },
|
package/src/theme-extract.mjs
CHANGED
|
@@ -17,6 +17,30 @@ import { resolve, relative } from "node:path";
|
|
|
17
17
|
import { parseBlocks } from "./tokens-diff.mjs";
|
|
18
18
|
import { encodeTheme } from "./create/theme/encode.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Dart 의 ShUiColorTokens 필드명 ↔ TOKEN_KEYS (hyphen) 매핑.
|
|
22
|
+
* inject.js 의 DART_FIELD_SOURCES 와 같은 매핑을 reverse 로 사용.
|
|
23
|
+
*/
|
|
24
|
+
const DART_FIELD_TO_KEY = {
|
|
25
|
+
background: "background",
|
|
26
|
+
backgroundSubtle: "background-subtle",
|
|
27
|
+
backgroundMuted: "background-muted",
|
|
28
|
+
backgroundInverse: "background-inverse",
|
|
29
|
+
foreground: "foreground",
|
|
30
|
+
foregroundMuted: "foreground-muted",
|
|
31
|
+
foregroundSubtle: "foreground-subtle",
|
|
32
|
+
foregroundInverse: "foreground-inverse",
|
|
33
|
+
border: "border",
|
|
34
|
+
borderStrong: "border-strong",
|
|
35
|
+
primary: "primary",
|
|
36
|
+
primaryForeground: "primary-foreground",
|
|
37
|
+
primaryHover: "primary-hover",
|
|
38
|
+
danger: "danger",
|
|
39
|
+
dangerForeground: "danger-foreground",
|
|
40
|
+
dangerHover: "danger-hover",
|
|
41
|
+
ring: "ring",
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
const TOKEN_KEYS_REQUIRED = [
|
|
21
45
|
"background", "background-subtle", "background-muted", "background-inverse",
|
|
22
46
|
"foreground", "foreground-muted", "foreground-subtle", "foreground-inverse",
|
|
@@ -150,6 +174,79 @@ export function extractThemeFromCss(cssText) {
|
|
|
150
174
|
};
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Dart sh_ui_tokens.dart 텍스트에서 light/dark + radius 추출 → base64.
|
|
179
|
+
*
|
|
180
|
+
* 파싱 전략:
|
|
181
|
+
* - `static const light = ShUiColorTokens(...)` 블록 찾기 (dark 도 동일)
|
|
182
|
+
* - 본문에서 `field: Color(0xAARRGGBB)` 추출, alpha 무시 (#RRGGBB 만 인코딩)
|
|
183
|
+
* - `static const tokens = ShUiRadiusTokens(...)` 에서 `defaultRadius: X.X,` 추출,
|
|
184
|
+
* X.X / 16 를 rem 으로 사용 (buildTokensDart 가 rem×16 으로 픽셀화한 값)
|
|
185
|
+
*/
|
|
186
|
+
export function extractThemeFromDart(dartText) {
|
|
187
|
+
const light = extractDartColorBlock(dartText, "light");
|
|
188
|
+
const dark = extractDartColorBlock(dartText, "dark");
|
|
189
|
+
|
|
190
|
+
const radiusMatch = /ShUiRadiusTokens\s*\([^)]*defaultRadius:\s*(\d+(?:\.\d+)?)/s.exec(
|
|
191
|
+
dartText,
|
|
192
|
+
);
|
|
193
|
+
if (!radiusMatch) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"theme extract 실패: ShUiRadiusTokens.defaultRadius 를 찾지 못했습니다.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const radiusPx = parseFloat(radiusMatch[1]);
|
|
199
|
+
const radius = Number((radiusPx / 16).toFixed(4));
|
|
200
|
+
|
|
201
|
+
const theme = { light, dark, radius };
|
|
202
|
+
const base64 = encodeTheme(theme);
|
|
203
|
+
return {
|
|
204
|
+
base64,
|
|
205
|
+
theme,
|
|
206
|
+
summary: {
|
|
207
|
+
requiredKeys: Object.keys(light).length,
|
|
208
|
+
optionalKeys: 0,
|
|
209
|
+
optionalEmitted: [],
|
|
210
|
+
radius,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `static const light = ShUiColorTokens(...)` 의 본문에서 field: hex 맵 빌드.
|
|
217
|
+
* Dart `Color(0xFFRRGGBB)` 또는 `Color(0xAARRGGBB)` (alpha 비-FF 는 의미 손실되지만
|
|
218
|
+
* sh-ui base64 는 #RRGGBB 만 지원해 alpha 잘라냄).
|
|
219
|
+
*/
|
|
220
|
+
function extractDartColorBlock(dartText, mode) {
|
|
221
|
+
const blockRe = new RegExp(
|
|
222
|
+
`static\\s+const\\s+${mode}\\s*=\\s*ShUiColorTokens\\s*\\(([\\s\\S]*?)\\);`,
|
|
223
|
+
"m",
|
|
224
|
+
);
|
|
225
|
+
const m = blockRe.exec(dartText);
|
|
226
|
+
if (!m) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`theme extract 실패: \`static const ${mode} = ShUiColorTokens(...)\` 블록을 찾지 못했습니다.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const body = m[1];
|
|
232
|
+
const fieldRe = /([a-zA-Z_]\w*):\s*Color\(0x([0-9a-fA-F]{8})\)/g;
|
|
233
|
+
const out = {};
|
|
234
|
+
let fm;
|
|
235
|
+
while ((fm = fieldRe.exec(body))) {
|
|
236
|
+
const field = fm[1];
|
|
237
|
+
const hexFull = fm[2].toUpperCase();
|
|
238
|
+
const key = DART_FIELD_TO_KEY[field];
|
|
239
|
+
if (!key) continue; // 알 수 없는 필드는 무시 (사용자가 ShUiColorTokens 확장한 경우)
|
|
240
|
+
out[key] = `#${hexFull.slice(2)}`;
|
|
241
|
+
}
|
|
242
|
+
if (Object.keys(out).length === 0) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`theme extract 실패: ${mode} 블록에서 Color 필드를 추출하지 못했습니다.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
153
250
|
async function loadConfig(cwd) {
|
|
154
251
|
const configPath = resolve(cwd, "sh-ui.config.json");
|
|
155
252
|
if (!existsSync(configPath)) {
|
|
@@ -162,22 +259,21 @@ async function loadConfig(cwd) {
|
|
|
162
259
|
|
|
163
260
|
export async function runThemeExtract({ cwd, output }) {
|
|
164
261
|
const config = await loadConfig(cwd);
|
|
165
|
-
if (config.platform !== "react") {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`theme extract 는 React 만 지원합니다 (현재: ${config.platform}). Flutter 는 별도 도구 필요.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
262
|
const tokensRel = config.paths?.tokens;
|
|
171
263
|
if (!tokensRel) throw new Error("paths.tokens 가 sh-ui.config.json 에 없습니다.");
|
|
172
264
|
const tokensPath = resolve(cwd, tokensRel);
|
|
173
265
|
if (!existsSync(tokensPath)) {
|
|
174
266
|
throw new Error(
|
|
175
|
-
|
|
267
|
+
`토큰 파일이 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
|
|
176
268
|
);
|
|
177
269
|
}
|
|
178
270
|
|
|
179
|
-
const
|
|
180
|
-
const
|
|
271
|
+
const tokensText = await readFile(tokensPath, "utf8");
|
|
272
|
+
const result =
|
|
273
|
+
config.platform === "flutter"
|
|
274
|
+
? extractThemeFromDart(tokensText)
|
|
275
|
+
: extractThemeFromCss(tokensText);
|
|
276
|
+
const { base64, summary } = result;
|
|
181
277
|
|
|
182
278
|
if (output) {
|
|
183
279
|
await writeFile(resolve(cwd, output), base64 + "\n", "utf8");
|
package/src/tokens-cmd.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { pathToFileURL } from "node:url";
|
|
|
15
15
|
import { getTokensRoot } from "./paths.mjs";
|
|
16
16
|
import { THEME_BASES } from "./constants.js";
|
|
17
17
|
import { parseBlocks, diffBlocks, applyAdditions } from "./tokens-diff.mjs";
|
|
18
|
+
import { parseDartTokens, diffDartTokens } from "./tokens-diff-dart.mjs";
|
|
18
19
|
|
|
19
20
|
async function loadTokensBuilder() {
|
|
20
21
|
const url = pathToFileURL(resolve(getTokensRoot(), "build.mjs")).href;
|
|
@@ -48,12 +49,6 @@ async function buildExpected(config) {
|
|
|
48
49
|
"diff/upgrade 는 base 가 neutral/zinc/slate 일 때만 사용 가능합니다.",
|
|
49
50
|
);
|
|
50
51
|
}
|
|
51
|
-
if (config.platform !== "react") {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`tokens diff/upgrade 는 React 만 지원합니다 (현재: ${config.platform}). ` +
|
|
54
|
-
"Flutter 는 향후 추가 예정.",
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
52
|
const { buildTokens } = await loadTokensBuilder();
|
|
58
53
|
return buildTokens(config);
|
|
59
54
|
}
|
|
@@ -74,7 +69,7 @@ function renderDiffReport({ added, removed, changed, unchangedCount }, rel) {
|
|
|
74
69
|
console.log(`\nsh-ui tokens diff — ${rel}\n`);
|
|
75
70
|
|
|
76
71
|
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
77
|
-
console.log(`✓ 변경 없음 — 사용자
|
|
72
|
+
console.log(`✓ 변경 없음 — 사용자 토큰 파일이 buildTokens 결과와 동일 (${unchangedCount} 항목).`);
|
|
78
73
|
return;
|
|
79
74
|
}
|
|
80
75
|
|
|
@@ -113,7 +108,10 @@ export async function runTokensDiff({ cwd }) {
|
|
|
113
108
|
const config = await loadConfig(cwd);
|
|
114
109
|
const expectedText = await buildExpected(config);
|
|
115
110
|
const { text: currentText, rel } = await loadCurrent(config, cwd);
|
|
116
|
-
const diff =
|
|
111
|
+
const diff =
|
|
112
|
+
config.platform === "flutter"
|
|
113
|
+
? diffDartTokens(parseDartTokens(currentText), parseDartTokens(expectedText))
|
|
114
|
+
: diffBlocks(parseBlocks(currentText), parseBlocks(expectedText));
|
|
117
115
|
renderDiffReport(diff, rel);
|
|
118
116
|
}
|
|
119
117
|
|
|
@@ -121,7 +119,19 @@ export async function runTokensUpgrade({ cwd, mode }) {
|
|
|
121
119
|
const config = await loadConfig(cwd);
|
|
122
120
|
const expectedText = await buildExpected(config);
|
|
123
121
|
const current = await loadCurrent(config, cwd);
|
|
124
|
-
const
|
|
122
|
+
const isFlutter = config.platform === "flutter";
|
|
123
|
+
|
|
124
|
+
if (isFlutter && mode === "apply") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"Flutter 에선 `tokens upgrade --apply` 미지원 — Dart 클래스에 필드 추가는 " +
|
|
127
|
+
"선언/생성자/static const 3 군데를 동시에 수정해야 해서 incremental 적용이 위험합니다.\n" +
|
|
128
|
+
" 대신 `sh-ui tokens upgrade --replace` 로 통째 재생성하세요.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const diff = isFlutter
|
|
133
|
+
? diffDartTokens(parseDartTokens(current.text), parseDartTokens(expectedText))
|
|
134
|
+
: diffBlocks(parseBlocks(current.text), parseBlocks(expectedText));
|
|
125
135
|
|
|
126
136
|
console.log(`\nsh-ui tokens upgrade — ${current.rel} (${mode})\n`);
|
|
127
137
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Flutter sh_ui_tokens.dart 와 buildTokensDart 결과를 비교한다.
|
|
2
|
+
//
|
|
3
|
+
// Dart 의 토큰 파일 구조:
|
|
4
|
+
// class ShUiColorTokens { final Color background; ...
|
|
5
|
+
// static const light = ShUiColorTokens(
|
|
6
|
+
// background: Color(0xFFFFFFFF),
|
|
7
|
+
// ...
|
|
8
|
+
// );
|
|
9
|
+
// static const dark = ShUiColorTokens(...);
|
|
10
|
+
// }
|
|
11
|
+
// class ShUiSpacingTokens { ...
|
|
12
|
+
// static const tokens = ShUiSpacingTokens(s0: 0.0, s1: 4.0, ...);
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// 비교 단위는 `<ClassName>.<staticName>` (예: ShUiColorTokens.light) — 그 안의
|
|
16
|
+
// field: value 맵으로 평탄화 후 added / changed / removed 분류.
|
|
17
|
+
//
|
|
18
|
+
// Apply 정책:
|
|
19
|
+
// Dart 클래스에 새 필드 추가는 (1) class field 선언, (2) constructor 파라미터,
|
|
20
|
+
// (3) 모든 static const 인스턴스화 — 3 군데 동기 수정 필요. 위험성/구현 복잡도가
|
|
21
|
+
// CSS 마커 기반과 비교 불가. v1 Flutter 는 --replace 만 지원, --apply 는 안내 에러.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `static const NAME = ClassName(...)` 블록들을 추출.
|
|
25
|
+
* @returns { [`ClassName.staticName`]: { field: rawValue } }
|
|
26
|
+
*/
|
|
27
|
+
export function parseDartTokens(dartText) {
|
|
28
|
+
const out = {};
|
|
29
|
+
// 의도적으로 단순 — `static const NAME = ClassName(...)` 의 본문 (마지막 `;` 까지) 을 캡처.
|
|
30
|
+
// 본문엔 nested 함수 호출 (Color(0x...), Cubic(...), Duration(...)) 이 가능하므로
|
|
31
|
+
// 간단한 깊이 카운터로 닫는 `)` 매칭.
|
|
32
|
+
const re = /static\s+const\s+([a-zA-Z_]\w*)\s*=\s*(ShUi[A-Z]\w*)\s*\(/g;
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = re.exec(dartText))) {
|
|
35
|
+
const staticName = m[1];
|
|
36
|
+
const className = m[2];
|
|
37
|
+
const bodyStart = m.index + m[0].length;
|
|
38
|
+
const bodyEnd = findClosingParen(dartText, bodyStart);
|
|
39
|
+
if (bodyEnd < 0) continue;
|
|
40
|
+
const body = dartText.slice(bodyStart, bodyEnd);
|
|
41
|
+
const fields = parseDartFieldList(body);
|
|
42
|
+
if (Object.keys(fields).length === 0) continue;
|
|
43
|
+
out[`${className}.${staticName}`] = fields;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findClosingParen(text, start) {
|
|
49
|
+
let depth = 1;
|
|
50
|
+
let i = start;
|
|
51
|
+
const n = text.length;
|
|
52
|
+
while (i < n) {
|
|
53
|
+
const c = text[i];
|
|
54
|
+
if (c === "(") depth++;
|
|
55
|
+
else if (c === ")") {
|
|
56
|
+
depth--;
|
|
57
|
+
if (depth === 0) return i;
|
|
58
|
+
}
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `field: value, field: value, ...` 본문을 { field: value-string } 으로.
|
|
66
|
+
* 값은 trim 된 raw 문자열 — `Color(0xFF...)`, `0.5`, `Duration(milliseconds: 120)`,
|
|
67
|
+
* `Cubic(0.4, 0, 0.2, 1)`, `<BoxShadow>[...]` 등 그대로 보존.
|
|
68
|
+
*/
|
|
69
|
+
function parseDartFieldList(body) {
|
|
70
|
+
const out = {};
|
|
71
|
+
const items = splitByTopLevelComma(body);
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
const trimmed = item.trim();
|
|
74
|
+
if (!trimmed) continue;
|
|
75
|
+
const colonIdx = findTopLevelColon(trimmed);
|
|
76
|
+
if (colonIdx < 0) continue;
|
|
77
|
+
const field = trimmed.slice(0, colonIdx).trim();
|
|
78
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
79
|
+
if (field && value) out[field] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 깊이 0 콤마로 split. (), [], {} 안의 콤마는 보호. */
|
|
85
|
+
function splitByTopLevelComma(text) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let start = 0;
|
|
89
|
+
for (let i = 0; i < text.length; i++) {
|
|
90
|
+
const c = text[i];
|
|
91
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
92
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
93
|
+
else if (c === "," && depth === 0) {
|
|
94
|
+
parts.push(text.slice(start, i));
|
|
95
|
+
start = i + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
parts.push(text.slice(start));
|
|
99
|
+
return parts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 깊이 0 의 첫 ':' — `field: Color(0x...)` 형식 분리용. */
|
|
103
|
+
function findTopLevelColon(text) {
|
|
104
|
+
let depth = 0;
|
|
105
|
+
for (let i = 0; i < text.length; i++) {
|
|
106
|
+
const c = text[i];
|
|
107
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
108
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
109
|
+
else if (c === ":" && depth === 0) return i;
|
|
110
|
+
}
|
|
111
|
+
return -1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 두 Dart 토큰 트리 비교. CSS 측 diffBlocks 와 같은 결과 모양.
|
|
116
|
+
*/
|
|
117
|
+
export function diffDartTokens(currentTokens, expectedTokens) {
|
|
118
|
+
const added = [];
|
|
119
|
+
const removed = [];
|
|
120
|
+
const changed = [];
|
|
121
|
+
let unchangedCount = 0;
|
|
122
|
+
|
|
123
|
+
const allKeys = new Set([
|
|
124
|
+
...Object.keys(currentTokens),
|
|
125
|
+
...Object.keys(expectedTokens),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
for (const blockKey of allKeys) {
|
|
129
|
+
const cur = currentTokens[blockKey] ?? {};
|
|
130
|
+
const exp = expectedTokens[blockKey] ?? {};
|
|
131
|
+
const fieldKeys = new Set([...Object.keys(cur), ...Object.keys(exp)]);
|
|
132
|
+
for (const field of fieldKeys) {
|
|
133
|
+
const cv = cur[field];
|
|
134
|
+
const ev = exp[field];
|
|
135
|
+
if (cv === undefined && ev !== undefined) {
|
|
136
|
+
added.push({ selector: blockKey, name: field, value: ev });
|
|
137
|
+
} else if (ev === undefined && cv !== undefined) {
|
|
138
|
+
removed.push({ selector: blockKey, name: field, value: cv });
|
|
139
|
+
} else if (cv !== ev) {
|
|
140
|
+
changed.push({ selector: blockKey, name: field, expected: ev, current: cv });
|
|
141
|
+
} else {
|
|
142
|
+
unchangedCount++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { added, removed, changed, unchangedCount };
|
|
147
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `sh-ui upgrade-cli` — sh-ui-cli 자체 업그레이드 + 설치 후 자동 진단.
|
|
2
|
+
//
|
|
3
|
+
// 동작:
|
|
4
|
+
// 1) npm registry 에서 sh-ui-cli 의 latest 버전 조회.
|
|
5
|
+
// 2) 현재 실행 중인 CLI 의 package.json 에서 버전 읽음.
|
|
6
|
+
// 3) 동일하면 "이미 최신" 안내 + 설치된 sh-ui-cli 패키지 (사용자의 node_modules)
|
|
7
|
+
// 가 다른지 확인 — devDep 갱신 안내.
|
|
8
|
+
// 4) 다르면 install 명령 출력 (사용자의 패키지 매니저에 맞춰).
|
|
9
|
+
// 5) --apply 면 실제 install 실행.
|
|
10
|
+
// 6) install 후 next-step 안내 — sh-ui doctor / tokens diff.
|
|
11
|
+
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const REGISTRY_URL = "https://registry.npmjs.org/sh-ui-cli/latest";
|
|
20
|
+
|
|
21
|
+
async function readCliVersion() {
|
|
22
|
+
const pkg = JSON.parse(
|
|
23
|
+
await readFile(resolve(CLI_ROOT, "package.json"), "utf8"),
|
|
24
|
+
);
|
|
25
|
+
return pkg.version;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 사용자 프로젝트의 node_modules/sh-ui-cli/package.json 버전 (devDep 으로 설치된 것) */
|
|
29
|
+
async function readInstalledVersion(cwd) {
|
|
30
|
+
let dir = resolve(cwd);
|
|
31
|
+
while (true) {
|
|
32
|
+
const candidate = resolve(dir, "node_modules/sh-ui-cli/package.json");
|
|
33
|
+
if (existsSync(candidate)) {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(await readFile(candidate, "utf8"));
|
|
36
|
+
return { version: pkg.version, path: candidate };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(dir);
|
|
42
|
+
if (parent === dir) return null;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchLatestVersion() {
|
|
48
|
+
// node 18+ 는 fetch 기본. 의존성 없는 단순 GET.
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(REGISTRY_URL, {
|
|
51
|
+
headers: { accept: "application/json" },
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`HTTP ${res.status}`);
|
|
55
|
+
}
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
return data.version;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`npm registry 에서 latest 버전을 가져오지 못했습니다 (${err.message}). ` +
|
|
61
|
+
"오프라인이거나 registry 접근이 막혔을 수 있습니다.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectPackageManager(cwd) {
|
|
67
|
+
let dir = resolve(cwd);
|
|
68
|
+
while (true) {
|
|
69
|
+
if (existsSync(resolve(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
70
|
+
if (existsSync(resolve(dir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
71
|
+
if (
|
|
72
|
+
existsSync(resolve(dir, "bun.lockb")) ||
|
|
73
|
+
existsSync(resolve(dir, "bun.lock"))
|
|
74
|
+
)
|
|
75
|
+
return "bun";
|
|
76
|
+
if (existsSync(resolve(dir, "yarn.lock"))) return "yarn";
|
|
77
|
+
if (existsSync(resolve(dir, "package-lock.json"))) return "npm";
|
|
78
|
+
const parent = dirname(dir);
|
|
79
|
+
if (parent === dir) return "npm";
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function installCommand(pm, version) {
|
|
85
|
+
const addCmd = pm === "npm" ? "install -D" : "add -D";
|
|
86
|
+
return `${pm} ${addCmd} sh-ui-cli@${version}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runInstall(pm, version, cwd) {
|
|
90
|
+
const args = pm === "npm" ? ["install", "-D"] : ["add", "-D"];
|
|
91
|
+
args.push(`sh-ui-cli@${version}`);
|
|
92
|
+
console.log(`\n실행: ${pm} ${args.join(" ")}\n`);
|
|
93
|
+
const isWin = process.platform === "win32";
|
|
94
|
+
return new Promise((ok, bad) => {
|
|
95
|
+
const child = spawn(pm, args, { cwd, stdio: "inherit", shell: isWin });
|
|
96
|
+
child.on("exit", (code) =>
|
|
97
|
+
code === 0 ? ok() : bad(new Error(`${pm} exited with code ${code}`)),
|
|
98
|
+
);
|
|
99
|
+
child.on("error", bad);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* versions.json 에서 from..to 사이의 highlights 를 추출.
|
|
105
|
+
* from = 사용자 현재, to = latest. 없으면 최근 N개만.
|
|
106
|
+
*/
|
|
107
|
+
async function readChangelogRange(fromVersion, toVersion) {
|
|
108
|
+
const candidates = [
|
|
109
|
+
resolve(CLI_ROOT, "data/changelog/versions.json"), // bundled
|
|
110
|
+
resolve(CLI_ROOT, "../changelog/versions.json"), // monorepo dev
|
|
111
|
+
];
|
|
112
|
+
let path;
|
|
113
|
+
for (const p of candidates) {
|
|
114
|
+
if (existsSync(p)) {
|
|
115
|
+
path = p;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!path) return null;
|
|
120
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
121
|
+
const versions = data.versions ?? [];
|
|
122
|
+
if (!fromVersion || !toVersion) return versions.slice(0, 5);
|
|
123
|
+
// versions 는 최신 → 옛날 순. from 보다 새로운 것만 추출 (from 자기 자신은 제외).
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const v of versions) {
|
|
126
|
+
if (v.version === fromVersion) break;
|
|
127
|
+
out.push(v);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function runUpgradeCli({ cwd, apply }) {
|
|
133
|
+
const installed = await readInstalledVersion(cwd);
|
|
134
|
+
console.log(`\nsh-ui-cli 업그레이드 점검\n`);
|
|
135
|
+
|
|
136
|
+
const latest = await fetchLatestVersion();
|
|
137
|
+
console.log(` npm latest : v${latest}`);
|
|
138
|
+
if (installed) {
|
|
139
|
+
console.log(` 현재 설치본 : v${installed.version}`);
|
|
140
|
+
} else {
|
|
141
|
+
console.log(` 현재 설치본 : (찾지 못함 — npx 로 실행 중일 수 있음)`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const reference = installed?.version ?? (await readCliVersion());
|
|
145
|
+
if (reference === latest) {
|
|
146
|
+
console.log(`\n✓ 이미 최신.\n`);
|
|
147
|
+
console.log(`다음 단계 권장:\n sh-ui doctor — 프로젝트 정합성 점검`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const changelog = await readChangelogRange(reference, latest);
|
|
152
|
+
if (changelog && changelog.length > 0) {
|
|
153
|
+
console.log(`\n변경 highlights (${changelog.length}개 릴리즈):`);
|
|
154
|
+
for (const v of changelog) {
|
|
155
|
+
const type = v.type ? ` [${v.type}]` : "";
|
|
156
|
+
console.log(` v${v.version}${type} — ${v.title}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const pm = detectPackageManager(cwd);
|
|
161
|
+
const cmd = installCommand(pm, latest);
|
|
162
|
+
|
|
163
|
+
if (!apply) {
|
|
164
|
+
console.log(`\n설치 명령:\n ${cmd}\n`);
|
|
165
|
+
console.log(
|
|
166
|
+
`실제 실행하려면: \`sh-ui upgrade-cli --apply\` (자동 install + 설치 후 doctor)`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --apply
|
|
172
|
+
await runInstall(pm, latest, cwd);
|
|
173
|
+
console.log(`\n✓ sh-ui-cli@${latest} 설치 완료.\n`);
|
|
174
|
+
console.log(
|
|
175
|
+
`다음 단계 (필요 시):\n` +
|
|
176
|
+
` sh-ui doctor — 신규 토큰 누락 / config 이슈 진단\n` +
|
|
177
|
+
` sh-ui tokens diff — buildTokens 와 비교 미리보기\n` +
|
|
178
|
+
` sh-ui tokens upgrade --apply — 추가만 incremental 적용 (사용자 편집 보존)`,
|
|
179
|
+
);
|
|
180
|
+
}
|