sh-ui-cli 0.69.0 → 0.70.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 +17 -0
- package/data/changelog/versions.json +14 -0
- package/package.json +1 -1
- package/src/theme-extract.mjs +198 -0
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,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.70.0",
|
|
7
|
+
"date": "2026-05-09",
|
|
8
|
+
"title": "sh-ui theme extract — tokens.css → base64 라운드트립 (Phase C)",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`sh-ui theme extract` 신설** — 사용자가 손본 tokens.css 의 색·radius 를 sh-ui base64 테마 문자열로 추출. stdout 출력 (또는 `--out <path>` 로 파일 저장). 추출 정보는 stderr 로 분리되어 pipe-friendly.",
|
|
12
|
+
"**라운드트립 검증** — 추출 → `sh-ui create --theme <base64>` 또는 MCP `sh_ui_create_project` 의 theme 인자로 그대로 재사용. encodeTheme 이 즉시 decodeTheme 으로 round-trip 검증해 잘못된 입력은 실패 시점이 빠름.",
|
|
13
|
+
"**옵셔널 토큰 보수적 처리** — light/dark 양쪽에 모두 hex 가 정의된 옵셔널 키 (success/warning/info/danger-hover/ring) 만 emit. 한쪽만 있으면 디자인 어긋남 위험 — 둘 다 skip.",
|
|
14
|
+
"**hex-only 정책** — 모든 필수 색이 `#RRGGBB` 여야 함. `color-mix() / var() / rgba()` 등이 섞이면 친절 에러로 종료 + `sh-ui tokens upgrade --replace` 또는 직접 hex 화 권장. 파싱 실패가 정적 vs 동적 색 의도를 흐리지 않게.",
|
|
15
|
+
"**활용 패턴** — custom brand 색을 한 번 박은 뒤 다른 앱에도 같은 색으로 스캐폴드 / 디자인 시스템 문서에 토큰 스냅샷 보존 / 색 살짝 바꾼 후 새 base64 추출해 brand 변형판 만들기."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.70.0"
|
|
18
|
+
},
|
|
5
19
|
{
|
|
6
20
|
"version": "0.69.0",
|
|
7
21
|
"date": "2026-05-09",
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|