sh-ui-cli 0.72.0 → 0.73.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.
@@ -2,6 +2,20 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.73.0",
7
+ "date": "2026-05-10",
8
+ "title": "Flutter — theme extract + tokens diff/upgrade Dart 지원",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`sh-ui theme extract` Flutter 지원** — `sh_ui_tokens.dart` 의 `static const light/dark` 블록에서 `Color(0xFFRRGGBB)` 를 추출해 sh-ui base64 로 인코딩. alpha 채널은 무시 (#RRGGBB 만 인코딩), `defaultRadius` 픽셀값을 16 으로 나눠 rem 변환.",
12
+ "**`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 — 간단 정규식 파서가 깨지지 않음.",
13
+ "**`sh-ui tokens upgrade --replace` Flutter 지원** — buildTokensDart 결과로 통째 덮어쓰기.",
14
+ "**`--apply` 는 Flutter 미지원** — Dart 클래스에 필드 추가는 (1) class field 선언, (2) constructor 파라미터, (3) 모든 static const 인스턴스화 3 군데를 동시에 수정해야 해서 incremental 적용이 위험. 친절 에러로 안내 + `--replace` 권장.",
15
+ "**dispatch 통합** — tokens-cmd / theme-extract 가 platform 으로 분기. React (CSS) 와 Flutter (Dart) 가 같은 명령 표면 공유."
16
+ ],
17
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.73.0"
18
+ },
5
19
  {
6
20
  "version": "0.72.0",
7
21
  "date": "2026-05-10",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- `tokens.css 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
267
+ `토큰 파일이 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
176
268
  );
177
269
  }
178
270
 
179
- const cssText = await readFile(tokensPath, "utf8");
180
- const { base64, summary } = extractThemeFromCss(cssText);
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");
@@ -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(`✓ 변경 없음 — 사용자 tokens.css buildTokens 결과와 동일 (${unchangedCount} 변수).`);
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 = diffBlocks(parseBlocks(currentText), parseBlocks(expectedText));
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 diff = diffBlocks(parseBlocks(current.text), parseBlocks(expectedText));
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
+ }