sh-ui-cli 0.69.0 → 0.71.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
@@ -20,6 +20,9 @@ const usage = `사용법:
20
20
  sh-ui tokens upgrade [--apply|--replace]
21
21
  --apply: 추가만 incremental (사용자 편집 보존)
22
22
  --replace: 통째 덮어쓰기 (add tokens --force 와 동일)
23
+ sh-ui theme extract [--out <path>]
24
+ 현재 tokens.css 의 색·radius 를 base64 로 추출
25
+ stdout 출력, --out 으로 파일 저장
23
26
  sh-ui remove <component...> 설치된 컴포넌트 파일 삭제
24
27
  sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
25
28
  (apps/<old>/, packages/ui/ui-apps/ui-<old>/
@@ -109,6 +112,20 @@ try {
109
112
  await doctor({ cwd: process.cwd() });
110
113
  break;
111
114
  }
115
+ case "theme": {
116
+ const sub = rest[0];
117
+ const flags = rest.slice(1);
118
+ if (sub === "extract") {
119
+ const outIdx = flags.indexOf("--out");
120
+ const output = outIdx !== -1 ? flags[outIdx + 1] : null;
121
+ const { runThemeExtract } = await import("../src/theme-extract.mjs");
122
+ await runThemeExtract({ cwd: process.cwd(), output });
123
+ } else {
124
+ console.error(`에러: 알 수 없는 theme 서브명령 '${sub ?? ""}'. 'extract' 만 지원.\n`);
125
+ process.exit(1);
126
+ }
127
+ break;
128
+ }
112
129
  case "tokens": {
113
130
  // sh-ui tokens diff
114
131
  // sh-ui tokens upgrade --apply | --replace
@@ -2,6 +2,35 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.71.0",
7
+ "date": "2026-05-09",
8
+ "title": "cssStrategy: bundled — 단일 CSS 번들 모드 (Phase D)",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`cssStrategy: \"bundled\"` 옵션 신설** — `sh-ui.config.json` 에서 켜면 모든 컴포넌트 CSS 가 `paths.cssBundle` 에 지정된 단일 파일로 누적. 컴포넌트별 `styles.css` 파일이 컴포넌트 폴더에 떨어지지 않음. 기본값은 `per-component` (기존 동작) — 옵트인.",
12
+ "**마커 기반 섹션 — `/* sh-ui:component:NAME-start ... -end */`** — sh-ui 가 마커 사이만 관리. 사용자 custom CSS 를 마커 밖에 추가해도 add/remove 가 그대로 보존. 같은 컴포넌트 재 add 면 안 내용만 교체.",
13
+ "**add 동작** — bundled 모드에서 컴포넌트 .tsx 의 `import \"./styles.css\";` 라인을 자동 제거 (번들이 한 번 import 되므로 불필요). conflict resolver 우회 (마커 사이는 sh-ui 영역).",
14
+ "**remove 동작** — .tsx 삭제 + 번들에서 해당 섹션 제거. 사용자 수정 검사도 stripStylesImport 변환 적용해 false positive 회피. 같은 dest 의 변종 중복 처리 fix.",
15
+ "**doctor 통합** — bundled 모드일 때 `paths.cssBundle` 존재 검증. 미설정/누락 시 fail.",
16
+ "**제약** — `cssFramework: \"plain\"` 에서만 동작 (tailwind/css-modules/vanilla-extract 는 자체 스코프 메커니즘). 사용자가 번들 파일을 globals.css 같은 곳에서 한 번 import 해야 스타일 적용 — 자동화 안 함."
17
+ ],
18
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.71.0"
19
+ },
20
+ {
21
+ "version": "0.70.0",
22
+ "date": "2026-05-09",
23
+ "title": "sh-ui theme extract — tokens.css → base64 라운드트립 (Phase C)",
24
+ "type": "minor",
25
+ "highlights": [
26
+ "**`sh-ui theme extract` 신설** — 사용자가 손본 tokens.css 의 색·radius 를 sh-ui base64 테마 문자열로 추출. stdout 출력 (또는 `--out <path>` 로 파일 저장). 추출 정보는 stderr 로 분리되어 pipe-friendly.",
27
+ "**라운드트립 검증** — 추출 → `sh-ui create --theme <base64>` 또는 MCP `sh_ui_create_project` 의 theme 인자로 그대로 재사용. encodeTheme 이 즉시 decodeTheme 으로 round-trip 검증해 잘못된 입력은 실패 시점이 빠름.",
28
+ "**옵셔널 토큰 보수적 처리** — light/dark 양쪽에 모두 hex 가 정의된 옵셔널 키 (success/warning/info/danger-hover/ring) 만 emit. 한쪽만 있으면 디자인 어긋남 위험 — 둘 다 skip.",
29
+ "**hex-only 정책** — 모든 필수 색이 `#RRGGBB` 여야 함. `color-mix() / var() / rgba()` 등이 섞이면 친절 에러로 종료 + `sh-ui tokens upgrade --replace` 또는 직접 hex 화 권장. 파싱 실패가 정적 vs 동적 색 의도를 흐리지 않게.",
30
+ "**활용 패턴** — custom brand 색을 한 번 박은 뒤 다른 앱에도 같은 색으로 스캐폴드 / 디자인 시스템 문서에 토큰 스냅샷 보존 / 색 살짝 바꾼 후 새 base64 추출해 brand 변형판 만들기."
31
+ ],
32
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.70.0"
33
+ },
5
34
  {
6
35
  "version": "0.69.0",
7
36
  "date": "2026-05-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.69.0",
3
+ "version": "0.71.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/add.mjs CHANGED
@@ -11,6 +11,12 @@ import {
11
11
  findMissingTokens,
12
12
  loadDefinedVarsFromConfig,
13
13
  } from "./tokens-validate.mjs";
14
+ import {
15
+ upsertSection,
16
+ stripStylesImport,
17
+ isStyleFile,
18
+ isTsxFile,
19
+ } from "./css-bundle.mjs";
14
20
 
15
21
  /**
16
22
  * 기존 파일과 registry 파일 내용이 다를 때 keep/overwrite 결정.
@@ -224,6 +230,50 @@ async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
224
230
  }
225
231
  }
226
232
 
233
+ /**
234
+ * bundled 모드 — config.paths.cssBundle 에 컴포넌트 섹션 upsert.
235
+ * 파일이 없으면 헤더 주석과 함께 새로 만든다.
236
+ *
237
+ * 마커 사이만 sh-ui 가 관리하므로 conflict resolver 를 거치지 않는다 — 사용자가
238
+ * 손댄 다른 섹션 / 마커 밖 custom CSS 는 보존되고, 같은 이름 섹션만 교체.
239
+ */
240
+ async function writeBundleSection({ name, css, config, cwd, diffMode, summary }) {
241
+ const bundleRel = config.paths?.cssBundle;
242
+ if (!bundleRel) {
243
+ throw new Error(
244
+ "cssStrategy='bundled' 인데 paths.cssBundle 이 sh-ui.config.json 에 없습니다.\n" +
245
+ " 예: \"paths\": { ..., \"cssBundle\": \"src/shared/styles/sh-ui-components.css\" }",
246
+ );
247
+ }
248
+ const bundlePath = resolve(cwd, bundleRel);
249
+ const exists = existsSync(bundlePath);
250
+ const existingText = exists
251
+ ? await readFile(bundlePath, "utf8")
252
+ : `/* sh-ui — 컴포넌트 CSS 번들 (cssStrategy: bundled). 마커 사이는 sh-ui 가 관리, 그 밖은 사용자 자유. */\n\n`;
253
+ const nextText = upsertSection(existingText, name, css);
254
+
255
+ if (existingText === nextText) {
256
+ return; // 변경 없음
257
+ }
258
+
259
+ if (diffMode) {
260
+ const rel = relative(cwd, bundlePath);
261
+ const { text, addCount, delCount } = formatUnifiedDiff(existingText, nextText, {
262
+ useColor: canUseColor(),
263
+ });
264
+ summary.push(
265
+ exists
266
+ ? { kind: "modified", rel, addCount, delCount, diff: text }
267
+ : { kind: "new", rel },
268
+ );
269
+ return;
270
+ }
271
+
272
+ await ensureDir(bundlePath);
273
+ await writeFile(bundlePath, nextText, "utf8");
274
+ console.log(`✓ ${name} → bundle ${relative(cwd, bundlePath)}`);
275
+ }
276
+
227
277
  /**
228
278
  * registry 엔트리의 frameworks 필드와 현재 cssFramework 가 호환되는지.
229
279
  * 필드가 없으면 "모든 프레임워크에 적용" — 기본 케이스.
@@ -297,12 +347,29 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
297
347
  await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver, validationCtx);
298
348
  }
299
349
 
350
+ // bundled 모드 여부. plain 변종에서만 의미 있음 (tailwind/css-modules/vanilla-extract
351
+ // 는 자체 스코프가 있어 단일 파일 합산이 부적절).
352
+ const bundled = config.cssStrategy === "bundled" && cssFramework === "plain";
353
+ let bundleAccumulated = "";
354
+
300
355
  for (const file of entry.files) {
301
356
  if (!frameworkMatches(file, cssFramework)) continue;
302
357
  const src = resolve(registryRoot, file.src);
303
- const dest = resolve(cwd, resolveDest(file.dest, config));
304
358
  const raw = await readFile(src, "utf8");
305
- const content = substitutePlaceholders(raw, config, file.src);
359
+ let content = substitutePlaceholders(raw, config, file.src);
360
+
361
+ if (bundled && isStyleFile(file)) {
362
+ // 컴포넌트별 styles.css 파일은 쓰지 않고 누적만 — 마지막에 bundle 에 upsert.
363
+ bundleAccumulated += (bundleAccumulated ? "\n\n" : "") + content.trim();
364
+ continue;
365
+ }
366
+ if (bundled && isTsxFile(file)) {
367
+ // .tsx 의 `import "./styles.css";` 제거 — bundled 모드는 사용자가 globals.css 에서
368
+ // bundle 을 한 번 import 하는 책임을 진다.
369
+ content = stripStylesImport(content);
370
+ }
371
+
372
+ const dest = resolve(cwd, resolveDest(file.dest, config));
306
373
  const result = await writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver });
307
374
  if (!diffMode && result !== "unchanged") {
308
375
  const prefix = result === "kept" ? "↷" : "✓";
@@ -311,6 +378,10 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
311
378
  }
312
379
  }
313
380
 
381
+ if (bundled && bundleAccumulated) {
382
+ await writeBundleSection({ name, css: bundleAccumulated, config, cwd, diffMode, summary, conflictResolver });
383
+ }
384
+
314
385
  for (const dep of entry.dependencies ?? []) {
315
386
  // dep 은 string ("react-hook-form") 또는 object ({name, frameworks?: string[]}).
316
387
  // 후자는 cssFramework 에 따라 의존성을 분기 (예: cva 는 tailwind 변종에만 필요).
@@ -0,0 +1,105 @@
1
+ // `cssStrategy: "bundled"` 모드에서 컴포넌트 CSS 를 단일 파일에 마커 기반 섹션으로 누적.
2
+ //
3
+ // 섹션 포맷:
4
+ // /* sh-ui:component:button-start */
5
+ // .sh-ui-button { ... }
6
+ // /* sh-ui:component:button-end */
7
+ //
8
+ // add 시 섹션을 append/replace, remove 시 섹션 제거. 마커 사이의 내용만 sh-ui 가 관리하고
9
+ // 파일의 다른 부분 (사용자가 추가한 custom CSS) 은 그대로 둔다.
10
+
11
+ const SEC_START = (name) => `/* sh-ui:component:${name}-start */`;
12
+ const SEC_END = (name) => `/* sh-ui:component:${name}-end */`;
13
+
14
+ /**
15
+ * bundle 텍스트에서 컴포넌트 섹션을 append 하거나 replace.
16
+ * 동일 이름 섹션이 이미 있으면 그 안의 내용만 교체.
17
+ *
18
+ * @param {string} bundleText 현재 번들 파일 내용 (없으면 빈 문자열)
19
+ * @param {string} name 컴포넌트 이름 (예: "button")
20
+ * @param {string} css 컴포넌트의 CSS (마커 제외)
21
+ * @returns {string} 갱신된 번들 텍스트
22
+ */
23
+ export function upsertSection(bundleText, name, css) {
24
+ const start = SEC_START(name);
25
+ const end = SEC_END(name);
26
+ const startIdx = bundleText.indexOf(start);
27
+ const endIdx = bundleText.indexOf(end);
28
+ const trimmed = css.trim();
29
+ const block = `${start}\n${trimmed}\n${end}`;
30
+
31
+ if (startIdx >= 0 && endIdx > startIdx) {
32
+ return (
33
+ bundleText.slice(0, startIdx) +
34
+ block +
35
+ bundleText.slice(endIdx + end.length)
36
+ );
37
+ }
38
+
39
+ // append — 기존 내용이 있으면 빈 줄 한 개로 분리.
40
+ const sep = bundleText.length === 0
41
+ ? ""
42
+ : bundleText.endsWith("\n\n")
43
+ ? ""
44
+ : bundleText.endsWith("\n")
45
+ ? "\n"
46
+ : "\n\n";
47
+ return bundleText + sep + block + "\n";
48
+ }
49
+
50
+ /**
51
+ * bundle 텍스트에서 컴포넌트 섹션 제거. 없으면 그대로 반환.
52
+ */
53
+ export function removeSection(bundleText, name) {
54
+ const start = SEC_START(name);
55
+ const end = SEC_END(name);
56
+ const startIdx = bundleText.indexOf(start);
57
+ const endIdx = bundleText.indexOf(end);
58
+ if (startIdx < 0 || endIdx <= startIdx) return bundleText;
59
+ // 섹션 + 다음 줄바꿈 한 번까지 제거 (중복 빈 줄 방지).
60
+ let cutEnd = endIdx + end.length;
61
+ if (bundleText[cutEnd] === "\n") cutEnd++;
62
+ return bundleText.slice(0, startIdx) + bundleText.slice(cutEnd);
63
+ }
64
+
65
+ /**
66
+ * bundle 텍스트에서 등록된 컴포넌트 이름 목록 추출 (시작 마커 기준).
67
+ */
68
+ export function listSections(bundleText) {
69
+ const re = /\/\*\s*sh-ui:component:([a-z0-9-]+)-start\s*\*\//g;
70
+ const out = [];
71
+ let m;
72
+ while ((m = re.exec(bundleText))) out.push(m[1]);
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * 컴포넌트 .tsx 의 `import "./styles.css";` 라인을 제거.
78
+ * 번들 모드에서는 styles.css 가 없으므로 이 import 는 빌드 깨뜨림.
79
+ *
80
+ * 다양한 quote / 공백을 허용. 다른 import 는 보존.
81
+ */
82
+ export function stripStylesImport(tsxText) {
83
+ // import "./styles.css"; / import './styles.css' / import "./styles.module.css" 등
84
+ // 줄 끝 newline 은 있을 수도/없을 수도 있어 (?:\n|$) 로 처리.
85
+ return tsxText.replace(
86
+ /^[ \t]*import\s+['"]\.\/styles\.(?:module\.)?css['"];?\s*(?:\n|$)/gm,
87
+ "",
88
+ );
89
+ }
90
+
91
+ /**
92
+ * registry 의 file 엔트리가 CSS 변종인지 (bundled 모드에서 별도 처리 대상).
93
+ */
94
+ export function isStyleFile(file) {
95
+ const src = file?.src ?? "";
96
+ return /\.(css|module\.css|css\.ts)$/.test(src);
97
+ }
98
+
99
+ /**
100
+ * registry 의 file 엔트리가 .tsx 변종인지 (stripStylesImport 적용 대상).
101
+ */
102
+ export function isTsxFile(file) {
103
+ const src = file?.src ?? "";
104
+ return /\.tsx$/.test(src);
105
+ }
package/src/doctor.mjs CHANGED
@@ -256,6 +256,32 @@ function resolveDest(template, config) {
256
256
  });
257
257
  }
258
258
 
259
+ /**
260
+ * cssStrategy='bundled' 인 경우 paths.cssBundle 파일 존재 + 마커 sanity 검사.
261
+ * per-component 모드면 검사 자체를 스킵 (필드가 없어도 정상).
262
+ */
263
+ function checkCssBundle(config, cwd, report) {
264
+ if (config.cssStrategy !== "bundled") return;
265
+ const rel = config.paths?.cssBundle;
266
+ if (!rel) {
267
+ report.fail(
268
+ "paths.cssBundle",
269
+ "cssStrategy='bundled' 인데 paths.cssBundle 이 미설정. " +
270
+ "예: \"cssBundle\": \"src/shared/styles/sh-ui-components.css\"",
271
+ );
272
+ return;
273
+ }
274
+ const bundlePath = resolve(cwd, rel);
275
+ if (!existsSync(bundlePath)) {
276
+ report.warn(
277
+ `paths.cssBundle — ${rel}`,
278
+ "파일 없음 — 컴포넌트를 add 하면 자동 생성됩니다.",
279
+ );
280
+ return;
281
+ }
282
+ report.ok(`paths.cssBundle — ${rel}`, "(bundled 모드)");
283
+ }
284
+
259
285
  function effectiveFramework(entry, cssFramework) {
260
286
  if (cssFramework === "plain") return cssFramework;
261
287
  const hasVariant = (entry.files ?? []).some(
@@ -276,6 +302,7 @@ export async function doctor({ cwd }) {
276
302
 
277
303
  const tokensPath = checkTokensFile(config, cwd, report);
278
304
  await checkCssEntry(config, cwd, tokensPath, report);
305
+ checkCssBundle(config, cwd, report);
279
306
 
280
307
  let definedVars = null;
281
308
  if (tokensPath) {
package/src/remove.mjs CHANGED
@@ -1,7 +1,14 @@
1
- import { readFile, rm, rmdir, readdir } from "node:fs/promises";
1
+ import { readFile, rm, rmdir, readdir, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, resolve, relative } from "node:path";
4
4
  import { getRegistryRoot } from "./paths.mjs";
5
+ import { removeSection, isStyleFile, stripStylesImport, isTsxFile } from "./css-bundle.mjs";
6
+
7
+ /** registry file 엔트리가 현재 cssFramework 와 호환되는지 (add.mjs 와 동일 규칙). */
8
+ function frameworkMatches(entry, cssFramework) {
9
+ if (!entry.frameworks) return true;
10
+ return entry.frameworks.includes(cssFramework);
11
+ }
5
12
 
6
13
  function resolveDest(template, config) {
7
14
  return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (m, key) => {
@@ -27,13 +34,16 @@ async function loadRegistry(platform) {
27
34
  return JSON.parse(await readFile(registryPath, "utf8"));
28
35
  }
29
36
 
30
- /** 설치된 파일이 레지스트리 원본과 동일한지(= 사용자가 수정하지 않았는지). */
31
- async function isUnmodified(srcAbs, destAbs) {
37
+ /**
38
+ * 설치된 파일이 레지스트리 원본과 동일한지(= 사용자가 수정하지 않았는지).
39
+ * bundled 모드에서 .tsx 는 add 시 `import "./styles.css";` 가 제거된 상태로 저장됐으므로
40
+ * 비교 전에 같은 변환을 src 에 적용해 false positive 회피.
41
+ */
42
+ async function isUnmodified(srcAbs, destAbs, { transform } = {}) {
32
43
  try {
33
- const [src, dest] = await Promise.all([
34
- readFile(srcAbs, "utf8"),
35
- readFile(destAbs, "utf8"),
36
- ]);
44
+ let src = await readFile(srcAbs, "utf8");
45
+ if (transform) src = transform(src);
46
+ const dest = await readFile(destAbs, "utf8");
37
47
  return src === dest;
38
48
  } catch {
39
49
  return false;
@@ -71,13 +81,34 @@ export async function remove({ cwd, names, force = false, dryRun = false }) {
71
81
  }
72
82
 
73
83
  const registryRoot = getRegistryRoot(config.platform);
84
+ const bundled = config.cssStrategy === "bundled";
85
+ const cssFramework = config.cssFramework ?? "plain";
86
+ const seenDest = new Set();
74
87
  for (const file of entry.files) {
88
+ if (!frameworkMatches(file, cssFramework)) continue;
89
+ // bundled 모드에선 CSS 파일이 sh-ui-components.css 내 섹션이라 개별 파일이 없음.
90
+ // .tsx 만 일반 삭제 흐름을 타고, 섹션 제거는 별도 단계에서 처리.
91
+ if (bundled && isStyleFile(file)) continue;
75
92
  const srcAbs = resolve(registryRoot, file.src);
76
93
  const destAbs = resolve(cwd, resolveDest(file.dest, config));
94
+ // 같은 dest 가 여러 file 변종에서 나올 수 있음 (예: index.tsx 와 index.tailwind.tsx
95
+ // 가 동일 dest 로 매핑) — 한 번만 처리.
96
+ if (seenDest.has(destAbs)) continue;
97
+ seenDest.add(destAbs);
77
98
 
78
99
  if (!existsSync(destAbs)) continue;
79
100
 
80
- const unmodified = await isUnmodified(srcAbs, destAbs);
101
+ // dest add 시 substitutePlaceholders + (bundled.tsx ) stripStylesImport 가
102
+ // 적용된 채로 저장됐다. unmodified 비교는 같은 변환을 src 에 먹여 false positive 회피.
103
+ const transform = (text) => {
104
+ let out = text;
105
+ if (config.aliases?.utils) {
106
+ out = out.replaceAll("@SH_UI_UTILS@", config.aliases.utils);
107
+ }
108
+ if (bundled && isTsxFile(file)) out = stripStylesImport(out);
109
+ return out;
110
+ };
111
+ const unmodified = await isUnmodified(srcAbs, destAbs, { transform });
81
112
  if (!unmodified && !force) {
82
113
  modifiedBlocked.push({ name, dest: destAbs });
83
114
  continue;
@@ -127,6 +158,24 @@ export async function remove({ cwd, names, force = false, dryRun = false }) {
127
158
  touchedDirs.add(dirname(d.dest));
128
159
  }
129
160
 
161
+ // bundled 모드 — 각 컴포넌트의 CSS 섹션을 번들에서 제거.
162
+ if (config.cssStrategy === "bundled" && config.paths?.cssBundle) {
163
+ const bundlePath = resolve(cwd, config.paths.cssBundle);
164
+ if (existsSync(bundlePath)) {
165
+ let text = await readFile(bundlePath, "utf8");
166
+ let removedAny = false;
167
+ for (const name of names) {
168
+ const next = removeSection(text, name);
169
+ if (next !== text) {
170
+ text = next;
171
+ removedAny = true;
172
+ console.log(`✓ 번들 섹션 제거: ${name} → ${relative(cwd, bundlePath)}`);
173
+ }
174
+ }
175
+ if (removedAny) await writeFile(bundlePath, text, "utf8");
176
+ }
177
+ }
178
+
130
179
  // 컴포넌트 폴더가 비면 정리 (paths.components 상위까지)
131
180
  const componentsRoot = config.paths?.components
132
181
  ? resolve(cwd, config.paths.components)
@@ -0,0 +1,198 @@
1
+ // `sh-ui theme extract` — 사용자 tokens.css 의 색·radius 를 추출해 sh-ui base64
2
+ // 테마 문자열로 인코딩한다. 산출물을 `sh-ui create --theme <base64>` 에 그대로
3
+ // 넘기거나, 팀/디자인 시스템 문서에 보존 가능.
4
+ //
5
+ // 동작:
6
+ // 1) sh-ui.config.json 의 paths.tokens 를 읽음.
7
+ // 2) parseBlocks 로 :root (light) / .dark (dark) 블록 추출.
8
+ // 3) TOKEN_KEYS + 옵셔널 색을 #RRGGBB 형식으로 검증, --radius 값을 rem 으로 파싱.
9
+ // 4) encodeTheme() 으로 base64 생성 (round-trip 검증 포함).
10
+ //
11
+ // 제약: tokens.css 의 모든 색이 #RRGGBB 형식이어야 한다. color-mix() / var() /
12
+ // rgba() 가 섞여 있으면 안내 에러로 종료 (사용자에게 --replace 또는 직접 hex 화 권장).
13
+
14
+ import { readFile, writeFile } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { resolve, relative } from "node:path";
17
+ import { parseBlocks } from "./tokens-diff.mjs";
18
+ import { encodeTheme } from "./create/theme/encode.js";
19
+
20
+ const TOKEN_KEYS_REQUIRED = [
21
+ "background", "background-subtle", "background-muted", "background-inverse",
22
+ "foreground", "foreground-muted", "foreground-subtle", "foreground-inverse",
23
+ "border", "border-strong",
24
+ "primary", "primary-foreground", "primary-hover",
25
+ "danger", "danger-foreground",
26
+ ];
27
+
28
+ const TOKEN_KEYS_OPTIONAL = [
29
+ "success", "success-foreground",
30
+ "warning", "warning-foreground",
31
+ "info", "info-foreground",
32
+ "danger-hover",
33
+ "ring",
34
+ ];
35
+
36
+ const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
37
+ const REM_RE = /^(-?\d*\.?\d+)rem$/;
38
+
39
+ /**
40
+ * 같은 selector 키가 여러 번 등장하면 vars 를 cascade 순으로 병합 (뒤가 앞을 덮음).
41
+ * tokens-diff 의 mergeBlocks 와 같은 정책이지만 여기서는 평탄 vars map 만 필요해 단순 병합.
42
+ */
43
+ function mergeVarsForSelector(blocks, selector) {
44
+ const merged = {};
45
+ for (const b of blocks) {
46
+ if (b.key === selector) Object.assign(merged, b.vars);
47
+ }
48
+ return merged;
49
+ }
50
+
51
+ function pickHexOrThrow(vars, name, mode) {
52
+ const value = vars["--" + name];
53
+ if (value === undefined) {
54
+ throw new Error(
55
+ `theme extract 실패: ${mode}.${name} 가 tokens.css 에 없습니다.\n` +
56
+ ` → \`sh-ui tokens upgrade --replace\` 로 buildTokens 결과를 적용해 모든 토큰을 hex 로 채운 뒤 다시 시도하세요.`,
57
+ );
58
+ }
59
+ if (!HEX_RE.test(value)) {
60
+ throw new Error(
61
+ `theme extract 실패: ${mode}.${name} 가 hex (#RRGGBB) 가 아닙니다 (현재: ${value}).\n` +
62
+ ` → tokens.css 의 해당 변수를 #RRGGBB 형식으로 바꾸거나, ` +
63
+ `\`sh-ui tokens upgrade --replace\` 로 표준값으로 리셋한 후 색을 다시 편집하세요.\n` +
64
+ ` (color-mix() / var() / rgba() 등은 추출 대상이 아님 — 색 추출은 정적 hex 만)`,
65
+ );
66
+ }
67
+ return value.toUpperCase();
68
+ }
69
+
70
+ function pickHexIfPresent(vars, name) {
71
+ const value = vars["--" + name];
72
+ if (value === undefined) return null;
73
+ if (!HEX_RE.test(value)) return null;
74
+ return value.toUpperCase();
75
+ }
76
+
77
+ function pickRadiusOrThrow(rootVars) {
78
+ const value = rootVars["--radius"];
79
+ if (value === undefined) {
80
+ throw new Error("theme extract 실패: --radius 가 tokens.css 에 없습니다.");
81
+ }
82
+ const m = REM_RE.exec(value);
83
+ if (!m) {
84
+ throw new Error(
85
+ `theme extract 실패: --radius 가 rem 단위가 아닙니다 (현재: ${value}). 예: '0.5rem'.`,
86
+ );
87
+ }
88
+ const num = parseFloat(m[1]);
89
+ if (!Number.isFinite(num) || num < 0 || num > 1.5) {
90
+ throw new Error(
91
+ `theme extract 실패: --radius 값이 0~1.5 범위 밖입니다 (현재: ${num}).`,
92
+ );
93
+ }
94
+ return num;
95
+ }
96
+
97
+ /**
98
+ * tokens.css 텍스트에서 base64 theme 산출.
99
+ * @returns {{ base64: string, theme: object, summary: object }}
100
+ */
101
+ export function extractThemeFromCss(cssText) {
102
+ const blocks = parseBlocks(cssText);
103
+ const lightVars = mergeVarsForSelector(blocks, ":root");
104
+ const darkVars = mergeVarsForSelector(blocks, ".dark");
105
+
106
+ if (Object.keys(lightVars).length === 0) {
107
+ throw new Error("theme extract 실패: :root 블록이 tokens.css 에 없습니다.");
108
+ }
109
+ if (Object.keys(darkVars).length === 0) {
110
+ throw new Error(
111
+ "theme extract 실패: .dark 블록이 tokens.css 에 없습니다 (light-only 모드는 미지원).",
112
+ );
113
+ }
114
+
115
+ const light = {};
116
+ const dark = {};
117
+
118
+ for (const name of TOKEN_KEYS_REQUIRED) {
119
+ light[name] = pickHexOrThrow(lightVars, name, "light");
120
+ dark[name] = pickHexOrThrow(darkVars, name, "dark");
121
+ }
122
+
123
+ // 옵셔널 — 양쪽 모두 hex 로 정의돼야 emit. 한쪽만 있으면 둘 다 skip
124
+ // (디자인이 어긋날 위험 — 보수적으로).
125
+ const optionalEmitted = [];
126
+ for (const name of TOKEN_KEYS_OPTIONAL) {
127
+ const lv = pickHexIfPresent(lightVars, name);
128
+ const dv = pickHexIfPresent(darkVars, name);
129
+ if (lv && dv) {
130
+ light[name] = lv;
131
+ dark[name] = dv;
132
+ optionalEmitted.push(name);
133
+ }
134
+ }
135
+
136
+ const radius = pickRadiusOrThrow(lightVars);
137
+
138
+ const theme = { light, dark, radius };
139
+ const base64 = encodeTheme(theme);
140
+
141
+ return {
142
+ base64,
143
+ theme,
144
+ summary: {
145
+ requiredKeys: TOKEN_KEYS_REQUIRED.length,
146
+ optionalKeys: optionalEmitted.length,
147
+ optionalEmitted,
148
+ radius,
149
+ },
150
+ };
151
+ }
152
+
153
+ async function loadConfig(cwd) {
154
+ const configPath = resolve(cwd, "sh-ui.config.json");
155
+ if (!existsSync(configPath)) {
156
+ throw new Error(
157
+ "sh-ui.config.json 을 찾을 수 없습니다. 먼저 `sh-ui init` 또는 `sh-ui create`.",
158
+ );
159
+ }
160
+ return JSON.parse(await readFile(configPath, "utf8"));
161
+ }
162
+
163
+ export async function runThemeExtract({ cwd, output }) {
164
+ const config = await loadConfig(cwd);
165
+ if (config.platform !== "react") {
166
+ throw new Error(
167
+ `theme extract 는 React 만 지원합니다 (현재: ${config.platform}). Flutter 는 별도 도구 필요.`,
168
+ );
169
+ }
170
+ const tokensRel = config.paths?.tokens;
171
+ if (!tokensRel) throw new Error("paths.tokens 가 sh-ui.config.json 에 없습니다.");
172
+ const tokensPath = resolve(cwd, tokensRel);
173
+ if (!existsSync(tokensPath)) {
174
+ throw new Error(
175
+ `tokens.css 가 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
176
+ );
177
+ }
178
+
179
+ const cssText = await readFile(tokensPath, "utf8");
180
+ const { base64, summary } = extractThemeFromCss(cssText);
181
+
182
+ if (output) {
183
+ await writeFile(resolve(cwd, output), base64 + "\n", "utf8");
184
+ console.log(`✓ ${output} 에 base64 저장 (${base64.length} 바이트).`);
185
+ } else {
186
+ console.log(base64);
187
+ }
188
+
189
+ console.error(
190
+ `\n추출 정보 (stderr — base64 와 분리):\n` +
191
+ ` 필수 토큰: ${summary.requiredKeys}\n` +
192
+ ` 옵셔널 토큰: ${summary.optionalKeys} (${summary.optionalEmitted.join(", ") || "—"})\n` +
193
+ ` radius: ${summary.radius}rem\n\n` +
194
+ `사용:\n` +
195
+ ` sh-ui create my-app --theme '${base64.slice(0, 24)}…'\n` +
196
+ ` (또는 'sh_ui_create_project' MCP 툴의 theme 인자에 그대로 전달)`,
197
+ );
198
+ }