sh-ui-cli 0.71.2 → 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.
package/bin/sh-ui.mjs CHANGED
@@ -29,6 +29,9 @@ const usage = `사용법:
29
29
  디렉토리 + 모든 import/path 패턴)
30
30
  sh-ui migrate-v065 v0.64.x → v0.65 자동 마이그레이션
31
31
  (--dry-run 기본, --apply 로 실제 적용)
32
+ sh-ui migrate bundled cssStrategy=bundled 로 전환 (per-component
33
+ styles.css → 단일 sh-ui-components.css)
34
+ (--apply 로 실제 적용)
32
35
  sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
33
36
  sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
34
37
  (claude-code | cursor | claude-desktop)
@@ -224,6 +227,22 @@ try {
224
227
  await renameApp({ cwd: process.cwd(), oldName, newName, yes, dryRun, skipInstall });
225
228
  break;
226
229
  }
230
+ case "migrate": {
231
+ // sh-ui migrate bundled [--apply] [--bundle <path>]
232
+ const sub = rest[0];
233
+ const flags = rest.slice(1);
234
+ if (sub === "bundled") {
235
+ const apply = flags.includes("--apply");
236
+ const bIdx = flags.indexOf("--bundle");
237
+ const bundleArg = bIdx !== -1 ? flags[bIdx + 1] : null;
238
+ const { runMigrateBundled } = await import("../src/migrate-bundled.mjs");
239
+ await runMigrateBundled({ cwd: process.cwd(), apply, bundleArg });
240
+ } else {
241
+ console.error(`에러: 알 수 없는 migrate 서브명령 '${sub ?? ""}'. 'bundled' 만 지원.\n`);
242
+ process.exit(1);
243
+ }
244
+ break;
245
+ }
227
246
  case "migrate-v065": {
228
247
  const apply = rest.includes("--apply");
229
248
  const skipImportRewrite = rest.includes("--skip-import-rewrite");
@@ -2,6 +2,34 @@
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
+ },
19
+ {
20
+ "version": "0.72.0",
21
+ "date": "2026-05-10",
22
+ "title": "sh-ui migrate bundled — per-component → bundled 자동 전환 (Phase D 후속)",
23
+ "type": "minor",
24
+ "highlights": [
25
+ "**`sh-ui migrate bundled` 명령** — 기존 per-component 프로젝트를 bundled 로 한 번에 전환. paths.components 아래 모든 styles.css 를 bundle 의 마커 섹션으로 누적 + 원본 파일 삭제 + .tsx import 제거 + config 갱신.",
26
+ "**dry-run 기본** — 변경 매트릭스 (대상 컴포넌트 + 파일 목록) 출력 후 사용자 확인. `--apply` 로만 실제 변경.",
27
+ "**`--bundle <path>` 옵션** — bundle 파일 위치 명시. 미지정 시 paths.styles 또는 paths.tokens 디렉토리 + sh-ui-components.css 를 자동 사용.",
28
+ "**완료 후 안내** — globals.css 에 `@import './sh-ui-components.css';` 한 줄 추가하라는 메시지 (자동 편집 안 함 — 사용자 entry CSS 위치/스타일 다양해서).",
29
+ "**제약** — cssFramework: plain + React 만. tailwind/css-modules/vanilla-extract 면 친절 에러. 이미 bundled 면 noop."
30
+ ],
31
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.72.0"
32
+ },
5
33
  {
6
34
  "version": "0.71.2",
7
35
  "date": "2026-05-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.71.2",
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": {
@@ -0,0 +1,172 @@
1
+ // `sh-ui migrate bundled` — 기존 per-component 프로젝트를 cssStrategy: bundled 로 전환.
2
+ //
3
+ // 동작:
4
+ // 1) sh-ui.config.json 의 cssStrategy 가 미설정/per-component 인지 확인.
5
+ // 2) 사용자에게 paths.cssBundle 위치 안내 (기본값 자동 결정 — paths.styles 또는
6
+ // paths.tokens 의 디렉토리 + `sh-ui-components.css`).
7
+ // 3) paths.components 아래 모든 `<name>/styles.css` 를 읽어 bundle 에 섹션으로 누적.
8
+ // 4) 각 컴포넌트 .tsx 의 `import "./styles.css";` 라인 제거.
9
+ // 5) per-component styles.css 파일 삭제.
10
+ // 6) sh-ui.config.json 에 `cssStrategy: "bundled"` + `paths.cssBundle` 저장.
11
+ //
12
+ // dry-run 기본 — 실제 변경은 --apply 로만. 실행 전 사용자에게 변경 매트릭스 안내.
13
+
14
+ import { readFile, writeFile, readdir, rm, mkdir } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { resolve, relative, dirname, join } from "node:path";
17
+ import { upsertSection, stripStylesImport } from "./css-bundle.mjs";
18
+
19
+ async function loadConfig(cwd) {
20
+ const configPath = resolve(cwd, "sh-ui.config.json");
21
+ if (!existsSync(configPath)) {
22
+ throw new Error(
23
+ "sh-ui.config.json 을 찾을 수 없습니다. 먼저 `sh-ui init` 또는 `sh-ui create`.",
24
+ );
25
+ }
26
+ return { configPath, config: JSON.parse(await readFile(configPath, "utf8")) };
27
+ }
28
+
29
+ /**
30
+ * paths.cssBundle 기본값 — 기존 paths.styles 또는 paths.tokens 의 부모 디렉토리에
31
+ * `sh-ui-components.css` 를 둔다.
32
+ */
33
+ function defaultBundlePath(config) {
34
+ const stylesDir = config.paths?.styles
35
+ ?? (config.paths?.tokens && dirname(config.paths.tokens));
36
+ if (!stylesDir) {
37
+ throw new Error(
38
+ "paths.styles 또는 paths.tokens 를 설정에서 찾을 수 없어 cssBundle 기본 경로를 정할 수 없습니다.\n" +
39
+ " --bundle <path> 로 명시하세요 (예: --bundle src/styles/sh-ui-components.css).",
40
+ );
41
+ }
42
+ return join(stylesDir, "sh-ui-components.css");
43
+ }
44
+
45
+ /**
46
+ * paths.components 아래 컴포넌트 폴더 (직속 디렉토리 한 단계만).
47
+ */
48
+ async function listComponentDirs(componentsAbs) {
49
+ if (!existsSync(componentsAbs)) return [];
50
+ const entries = await readdir(componentsAbs, { withFileTypes: true });
51
+ return entries
52
+ .filter((e) => e.isDirectory())
53
+ .map((e) => ({ name: e.name, dir: resolve(componentsAbs, e.name) }));
54
+ }
55
+
56
+ /**
57
+ * 컴포넌트 디렉토리에서 styles.css / styles.module.css 를 찾아 (있으면) 반환.
58
+ * .tsx 변종은 별도 — `migrateOne` 이 처리.
59
+ */
60
+ function findStyleFiles(componentDir) {
61
+ return [
62
+ resolve(componentDir, "styles.css"),
63
+ resolve(componentDir, "styles.module.css"),
64
+ ].filter((p) => existsSync(p));
65
+ }
66
+
67
+ function findTsxFiles(componentDir) {
68
+ return [
69
+ resolve(componentDir, "index.tsx"),
70
+ resolve(componentDir, "index.module.tsx"),
71
+ ].filter((p) => existsSync(p));
72
+ }
73
+
74
+ export async function runMigrateBundled({ cwd, apply, bundleArg }) {
75
+ const { configPath, config } = await loadConfig(cwd);
76
+
77
+ if (config.cssStrategy === "bundled") {
78
+ console.log("이미 cssStrategy='bundled' 로 설정돼 있습니다 — 마이그레이션 불필요.");
79
+ return;
80
+ }
81
+
82
+ if ((config.cssFramework ?? "plain") !== "plain") {
83
+ throw new Error(
84
+ `cssFramework='${config.cssFramework}' 에선 bundled 모드가 동작하지 않습니다 (plain 만 지원).`,
85
+ );
86
+ }
87
+
88
+ if (config.platform !== "react") {
89
+ throw new Error("bundled 마이그레이션은 React 만 지원합니다.");
90
+ }
91
+
92
+ const componentsRel = config.paths?.components;
93
+ if (!componentsRel) {
94
+ throw new Error("paths.components 가 sh-ui.config.json 에 없습니다.");
95
+ }
96
+ const componentsAbs = resolve(cwd, componentsRel);
97
+ const components = await listComponentDirs(componentsAbs);
98
+ if (components.length === 0) {
99
+ console.log(`${componentsRel} 아래에 컴포넌트가 없습니다. 마이그레이션할 게 없음.`);
100
+ }
101
+
102
+ const bundleRel = bundleArg ?? defaultBundlePath(config);
103
+ const bundleAbs = resolve(cwd, bundleRel);
104
+
105
+ // 변경 매트릭스 계산
106
+ const plan = []; // { name, styleFiles: [], tsxFiles: [] }
107
+ for (const c of components) {
108
+ const styleFiles = findStyleFiles(c.dir);
109
+ const tsxFiles = findTsxFiles(c.dir);
110
+ if (styleFiles.length === 0 && tsxFiles.length === 0) continue;
111
+ plan.push({ name: c.name, dir: c.dir, styleFiles, tsxFiles });
112
+ }
113
+
114
+ console.log(`\n── bundled 마이그레이션 ${apply ? "(실행)" : "(dry-run)"} ──`);
115
+ console.log(` bundle 파일: ${bundleRel}`);
116
+ console.log(` 대상 컴포넌트: ${plan.length}개`);
117
+ for (const p of plan) {
118
+ const styleNames = p.styleFiles.map((f) => relative(cwd, f));
119
+ const tsxNames = p.tsxFiles.map((f) => relative(cwd, f));
120
+ console.log(` · ${p.name}`);
121
+ for (const f of styleNames) console.log(` styles → bundle 섹션 + 파일 삭제: ${f}`);
122
+ for (const f of tsxNames) console.log(` .tsx import 제거: ${f}`);
123
+ }
124
+
125
+ if (!apply) {
126
+ console.log(`\n실제 적용은 \`sh-ui migrate bundled --apply\`.`);
127
+ return;
128
+ }
129
+
130
+ // 적용 시작 — 번들 텍스트를 먼저 빌드, 마지막에 한 번에 쓴다.
131
+ let bundleText = existsSync(bundleAbs)
132
+ ? await readFile(bundleAbs, "utf8")
133
+ : `/* sh-ui — 컴포넌트 CSS 번들 (cssStrategy: bundled). 마커 사이는 sh-ui 가 관리, 그 밖은 사용자 자유. */\n\n`;
134
+
135
+ for (const p of plan) {
136
+ let cssAccum = "";
137
+ for (const f of p.styleFiles) {
138
+ const text = await readFile(f, "utf8");
139
+ cssAccum += (cssAccum ? "\n\n" : "") + text.trim();
140
+ }
141
+ if (cssAccum) {
142
+ bundleText = upsertSection(bundleText, p.name, cssAccum);
143
+ }
144
+ for (const f of p.tsxFiles) {
145
+ const before = await readFile(f, "utf8");
146
+ const after = stripStylesImport(before);
147
+ if (before !== after) await writeFile(f, after, "utf8");
148
+ }
149
+ for (const f of p.styleFiles) {
150
+ await rm(f);
151
+ }
152
+ }
153
+
154
+ await mkdir(dirname(bundleAbs), { recursive: true });
155
+ await writeFile(bundleAbs, bundleText, "utf8");
156
+
157
+ // config 갱신
158
+ const nextConfig = {
159
+ ...config,
160
+ cssStrategy: "bundled",
161
+ paths: { ...config.paths, cssBundle: bundleRel },
162
+ };
163
+ await writeFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n", "utf8");
164
+
165
+ console.log(
166
+ `\n✓ 마이그레이션 완료.\n` +
167
+ ` bundle: ${bundleRel}\n` +
168
+ ` config: cssStrategy='bundled', paths.cssBundle 추가\n\n` +
169
+ `다음 단계: globals.css (또는 entry CSS) 에서 한 번 import 하세요:\n` +
170
+ ` @import './${bundleRel.replace(/^.*\//, "")}';`,
171
+ );
172
+ }
@@ -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
+ }