sh-ui-cli 0.68.1 → 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 +52 -0
- package/data/changelog/versions.json +28 -0
- package/package.json +1 -1
- package/src/theme-extract.mjs +198 -0
- package/src/tokens-cmd.mjs +174 -0
- package/src/tokens-diff.mjs +404 -0
package/bin/sh-ui.mjs
CHANGED
|
@@ -16,6 +16,13 @@ const usage = `사용법:
|
|
|
16
16
|
특수값: tokens → 설정 기반 토큰 파일 생성
|
|
17
17
|
sh-ui list 현재 설치된 컴포넌트 목록 표시
|
|
18
18
|
sh-ui doctor 프로젝트 정합성 점검 (config / tokens / 설치된 컴포넌트)
|
|
19
|
+
sh-ui tokens diff tokens.css 와 buildTokens 결과 비교 (added/changed/removed)
|
|
20
|
+
sh-ui tokens upgrade [--apply|--replace]
|
|
21
|
+
--apply: 추가만 incremental (사용자 편집 보존)
|
|
22
|
+
--replace: 통째 덮어쓰기 (add tokens --force 와 동일)
|
|
23
|
+
sh-ui theme extract [--out <path>]
|
|
24
|
+
현재 tokens.css 의 색·radius 를 base64 로 추출
|
|
25
|
+
stdout 출력, --out 으로 파일 저장
|
|
19
26
|
sh-ui remove <component...> 설치된 컴포넌트 파일 삭제
|
|
20
27
|
sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
|
|
21
28
|
(apps/<old>/, packages/ui/ui-apps/ui-<old>/
|
|
@@ -105,6 +112,51 @@ try {
|
|
|
105
112
|
await doctor({ cwd: process.cwd() });
|
|
106
113
|
break;
|
|
107
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
|
+
}
|
|
129
|
+
case "tokens": {
|
|
130
|
+
// sh-ui tokens diff
|
|
131
|
+
// sh-ui tokens upgrade --apply | --replace
|
|
132
|
+
const sub = rest[0];
|
|
133
|
+
const flags = rest.slice(1);
|
|
134
|
+
const { runTokensDiff, runTokensUpgrade } = await import("../src/tokens-cmd.mjs");
|
|
135
|
+
if (sub === "diff") {
|
|
136
|
+
await runTokensDiff({ cwd: process.cwd() });
|
|
137
|
+
} else if (sub === "upgrade") {
|
|
138
|
+
const apply = flags.includes("--apply");
|
|
139
|
+
const replace = flags.includes("--replace");
|
|
140
|
+
if (apply && replace) {
|
|
141
|
+
console.error("에러: --apply 와 --replace 은 함께 쓸 수 없습니다.\n");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (!apply && !replace) {
|
|
145
|
+
console.error(
|
|
146
|
+
"에러: `sh-ui tokens upgrade` 는 --apply 또는 --replace 가 필요합니다.\n" +
|
|
147
|
+
" --apply 추가된 변수만 적용 (사용자 편집 보존)\n" +
|
|
148
|
+
" --replace buildTokens 결과로 통째 덮어쓰기\n" +
|
|
149
|
+
"미리보기는 `sh-ui tokens diff`.",
|
|
150
|
+
);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
await runTokensUpgrade({ cwd: process.cwd(), mode: apply ? "apply" : "replace" });
|
|
154
|
+
} else {
|
|
155
|
+
console.error(`에러: 알 수 없는 tokens 서브명령 '${sub ?? ""}'. 'diff' 또는 'upgrade'.\n`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
108
160
|
case "mcp": {
|
|
109
161
|
// `sh-ui mcp init ...` → 설정 파일에 엔트리 추가
|
|
110
162
|
// `sh-ui mcp` → MCP 서버 시작
|
|
@@ -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.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
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.69.0",
|
|
21
|
+
"date": "2026-05-09",
|
|
22
|
+
"title": "sh-ui tokens diff / upgrade — incremental 토큰 마이그레이션 (Phase B)",
|
|
23
|
+
"type": "minor",
|
|
24
|
+
"highlights": [
|
|
25
|
+
"**`sh-ui tokens diff` 신설** — 현재 tokens.css 와 buildTokens 결과를 비교해 added(신규) / changed(값 다름) / removed(사용자 전용) 를 selector 블록 단위로 표시. sh-ui 버전 업그레이드 후 어떤 토큰이 새로 들어왔는지 즉시 파악.",
|
|
26
|
+
"**`sh-ui tokens upgrade --apply`** — added 변수만 매칭 selector 블록 끝에 incremental 삽입. 사용자가 손댄 색 (changed) 과 추가한 custom 변수 (removed) 는 그대로 보존. 매칭 selector 가 없으면 파일 끝에 새 블록 append (`/* sh-ui upgrade — added */` 주석 표시).",
|
|
27
|
+
"**`sh-ui tokens upgrade --replace`** — buildTokens 결과로 tokens.css 통째 덮어쓰기. `add tokens --force` 와 동일한 동작이지만 의도가 명시적 (사용자가 모든 편집을 의도적으로 버릴 때만).",
|
|
28
|
+
"**CSS 블록 파서 자체 구현** — 의존성 없는 1-pass 스캐너 (`packages/cli/src/tokens-diff.mjs`). `:root`, `.dark`, `@media (...) { :root:not(.light):not(.dark) { ... } }` 같은 nested 까지 평탄화 (key 형식: `@media (...) > :root:not(...)`). 같은 selector 가 여러 번 나오면 vars 자동 병합 (CSS cascade 룰).",
|
|
29
|
+
"**제약** — buildable theme.base (neutral/zinc/slate) 에서만 동작. custom base64 / rich preset (rose/emerald/violet) 은 buildTokens 가 primitives 미정의로 throw — 친절 메시지로 일찍 종료. Flutter platform 은 미지원 (Dart 토큰 생성 형식이 달라 별도 도구 필요)."
|
|
30
|
+
],
|
|
31
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.69.0"
|
|
32
|
+
},
|
|
5
33
|
{
|
|
6
34
|
"version": "0.68.1",
|
|
7
35
|
"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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// `sh-ui tokens diff` / `sh-ui tokens upgrade` 명령 핸들러.
|
|
2
|
+
//
|
|
3
|
+
// 두 명령 다 사용자 tokens.css 와 buildTokens(config) 결과를 비교한다.
|
|
4
|
+
// - diff — 미리보기만 (added / removed / changed 표).
|
|
5
|
+
// - upgrade --apply — added 만 incremental 적용 (사용자 손댄 값 보존).
|
|
6
|
+
// - upgrade --replace — buildTokens 결과로 통째 덮어쓰기 (`add tokens --force` 동일).
|
|
7
|
+
//
|
|
8
|
+
// custom theme (theme.base === 'custom' 또는 buildable 아닌 preset) 은
|
|
9
|
+
// buildTokens 가 throw 하므로 안내 메시지로 일찍 종료.
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { resolve, relative } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { getTokensRoot } from "./paths.mjs";
|
|
16
|
+
import { THEME_BASES } from "./constants.js";
|
|
17
|
+
import { parseBlocks, diffBlocks, applyAdditions } from "./tokens-diff.mjs";
|
|
18
|
+
|
|
19
|
+
async function loadTokensBuilder() {
|
|
20
|
+
const url = pathToFileURL(resolve(getTokensRoot(), "build.mjs")).href;
|
|
21
|
+
return import(url);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadConfig(cwd) {
|
|
25
|
+
const configPath = resolve(cwd, "sh-ui.config.json");
|
|
26
|
+
if (!existsSync(configPath)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"sh-ui.config.json 을 찾을 수 없습니다. 먼저 `sh-ui init` 또는 `sh-ui create` 실행.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return JSON.parse(await readFile(configPath, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* config 로 expected tokens.css 를 생성. custom / non-buildable preset 은 throw.
|
|
36
|
+
*/
|
|
37
|
+
async function buildExpected(config) {
|
|
38
|
+
if (config.theme?.base === "custom") {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"custom theme 은 buildTokens 로 재생성 불가 (base64 가 단일 진실). " +
|
|
41
|
+
"tokens diff/upgrade 는 buildable preset (neutral/zinc/slate) 에서만 동작합니다.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const base = config.theme?.base;
|
|
45
|
+
if (base && !THEME_BASES.includes(base)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`'${base}' preset 은 buildTokens 로 재생성 불가 (primitives 미정의 — buildable: ${THEME_BASES.join("/")}). ` +
|
|
48
|
+
"diff/upgrade 는 base 가 neutral/zinc/slate 일 때만 사용 가능합니다.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (config.platform !== "react") {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`tokens diff/upgrade 는 React 만 지원합니다 (현재: ${config.platform}). ` +
|
|
54
|
+
"Flutter 는 향후 추가 예정.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const { buildTokens } = await loadTokensBuilder();
|
|
58
|
+
return buildTokens(config);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loadCurrent(config, cwd) {
|
|
62
|
+
const tokensRel = config.paths?.tokens;
|
|
63
|
+
if (!tokensRel) throw new Error("paths.tokens 가 sh-ui.config.json 에 없습니다.");
|
|
64
|
+
const tokensPath = resolve(cwd, tokensRel);
|
|
65
|
+
if (!existsSync(tokensPath)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`tokens.css 가 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return { text: await readFile(tokensPath, "utf8"), path: tokensPath, rel: tokensRel };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderDiffReport({ added, removed, changed, unchangedCount }, rel) {
|
|
74
|
+
console.log(`\nsh-ui tokens diff — ${rel}\n`);
|
|
75
|
+
|
|
76
|
+
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
77
|
+
console.log(`✓ 변경 없음 — 사용자 tokens.css 가 buildTokens 결과와 동일 (${unchangedCount} 변수).`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (added.length > 0) {
|
|
82
|
+
console.log(`+ 추가 (${added.length}) — buildTokens 가 새로 제공:`);
|
|
83
|
+
for (const a of added) {
|
|
84
|
+
console.log(` [${a.selector}] ${a.name}: ${a.value};`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (changed.length > 0) {
|
|
89
|
+
console.log(`\n~ 변경 (${changed.length}) — 양쪽 정의값이 다름 (사용자가 손댔거나 sh-ui 가 갱신):`);
|
|
90
|
+
for (const c of changed) {
|
|
91
|
+
console.log(` [${c.selector}] ${c.name}`);
|
|
92
|
+
console.log(` current : ${c.current}`);
|
|
93
|
+
console.log(` expected : ${c.expected}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (removed.length > 0) {
|
|
98
|
+
console.log(`\n- 제거 (${removed.length}) — 사용자 tokens.css 에만 존재 (custom 추가 또는 deprecated):`);
|
|
99
|
+
for (const r of removed) {
|
|
100
|
+
console.log(` [${r.selector}] ${r.name}: ${r.value};`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(
|
|
105
|
+
`\n동일 ${unchangedCount} 변수.\n\n` +
|
|
106
|
+
`적용 옵션:\n` +
|
|
107
|
+
` sh-ui tokens upgrade --apply 추가만 incremental 적용 (변경/제거 보존)\n` +
|
|
108
|
+
` sh-ui tokens upgrade --replace buildTokens 결과로 통째 덮어쓰기 (모든 사용자 편집 손실)`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function runTokensDiff({ cwd }) {
|
|
113
|
+
const config = await loadConfig(cwd);
|
|
114
|
+
const expectedText = await buildExpected(config);
|
|
115
|
+
const { text: currentText, rel } = await loadCurrent(config, cwd);
|
|
116
|
+
const diff = diffBlocks(parseBlocks(currentText), parseBlocks(expectedText));
|
|
117
|
+
renderDiffReport(diff, rel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runTokensUpgrade({ cwd, mode }) {
|
|
121
|
+
const config = await loadConfig(cwd);
|
|
122
|
+
const expectedText = await buildExpected(config);
|
|
123
|
+
const current = await loadCurrent(config, cwd);
|
|
124
|
+
const diff = diffBlocks(parseBlocks(current.text), parseBlocks(expectedText));
|
|
125
|
+
|
|
126
|
+
console.log(`\nsh-ui tokens upgrade — ${current.rel} (${mode})\n`);
|
|
127
|
+
|
|
128
|
+
if (mode === "replace") {
|
|
129
|
+
if (current.text === expectedText) {
|
|
130
|
+
console.log(`✓ 변경 없음 — buildTokens 결과와 이미 동일.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await writeFile(current.path, expectedText, "utf8");
|
|
134
|
+
console.log(
|
|
135
|
+
`✓ ${current.rel} 을 buildTokens 결과로 덮어썼습니다.\n` +
|
|
136
|
+
` 추가 ${diff.added.length} / 변경 ${diff.changed.length} / 제거 ${diff.removed.length}.`,
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// mode === "apply" — added 만 incremental.
|
|
142
|
+
if (diff.added.length === 0) {
|
|
143
|
+
if (diff.changed.length > 0 || diff.removed.length > 0) {
|
|
144
|
+
console.log(
|
|
145
|
+
`✓ 추가할 변수 없음. 변경 ${diff.changed.length}, 제거 ${diff.removed.length} 는 사용자 의도로 보존.`,
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
console.log(`✓ 변경 없음.`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const next = applyAdditions(current.text, diff.added);
|
|
153
|
+
await writeFile(current.path, next, "utf8");
|
|
154
|
+
console.log(
|
|
155
|
+
`✓ ${diff.added.length} 개 변수 추가 적용:\n` +
|
|
156
|
+
diff.added
|
|
157
|
+
.slice(0, 12)
|
|
158
|
+
.map((a) => ` + [${a.selector}] ${a.name}`)
|
|
159
|
+
.join("\n") +
|
|
160
|
+
(diff.added.length > 12 ? `\n +${diff.added.length - 12} more` : ""),
|
|
161
|
+
);
|
|
162
|
+
if (diff.changed.length > 0) {
|
|
163
|
+
console.log(
|
|
164
|
+
`\nℹ 값 변경 ${diff.changed.length} 개는 보존됨. ` +
|
|
165
|
+
`sh-ui 의 새 권장값을 보려면 \`sh-ui tokens diff\`. ` +
|
|
166
|
+
`통째 덮어쓰기는 \`sh-ui tokens upgrade --replace\`.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (diff.removed.length > 0) {
|
|
170
|
+
console.log(
|
|
171
|
+
`ℹ 사용자 추가 변수 ${diff.removed.length} 개는 그대로 유지.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// tokens.css 비교/병합 로직.
|
|
2
|
+
//
|
|
3
|
+
// 설계:
|
|
4
|
+
// - 사용자 tokens.css 를 selector 별 블록으로 파싱 (`:root`, `.dark`,
|
|
5
|
+
// `@media (...)` 안의 nested selector 까지).
|
|
6
|
+
// - 같은 작업을 buildTokens 결과에도 적용해 "expected" 블록 트리 생성.
|
|
7
|
+
// - 두 트리를 selector 매칭으로 비교:
|
|
8
|
+
// added — expected 에만 존재하는 변수 (사용자에게 새로 제공된 토큰)
|
|
9
|
+
// removed — current 에만 존재하는 변수 (사용자 커스텀 또는 deprecated)
|
|
10
|
+
// changed — 양쪽에 존재하지만 값이 다름 (사용자가 손댔거나 sh-ui 가 갱신)
|
|
11
|
+
// unchanged— 양쪽 동일
|
|
12
|
+
//
|
|
13
|
+
// 적용(apply) 정책:
|
|
14
|
+
// - added 만 자동 적용 — 사용자가 손댄 값은 건드리지 않음.
|
|
15
|
+
// - 같은 selector 블록을 current 에서 찾아 닫는 `}` 바로 전에 라인 삽입.
|
|
16
|
+
// - 매칭 selector 블록이 없으면 파일 끝에 신규 블록을 append.
|
|
17
|
+
// - 결과는 새 텍스트 — 호출부가 파일에 쓰는지 결정.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* tokens.css 텍스트를 셀렉터 블록 트리로 파싱.
|
|
21
|
+
*
|
|
22
|
+
* 단순 1-pass 스캐너 — `selector { ... }` 형태의 블록을 추출하고, 중첩이면
|
|
23
|
+
* inner block 도 별도 항목으로 평탄화한다 (parent selector 와 결합한 키로).
|
|
24
|
+
*
|
|
25
|
+
* 예:
|
|
26
|
+
* :root { --x: 1; }
|
|
27
|
+
* @media (prefers-color-scheme: dark) { :root:not(.light):not(.dark) { --x: 2; } }
|
|
28
|
+
* .dark { --x: 3; }
|
|
29
|
+
*
|
|
30
|
+
* → [
|
|
31
|
+
* { key: ":root", vars: { "--x": "1" } },
|
|
32
|
+
* { key: "@media (prefers-color-scheme: dark) > :root:not(.light):not(.dark)",
|
|
33
|
+
* vars: { "--x": "2" } },
|
|
34
|
+
* { key: ".dark", vars: { "--x": "3" } }
|
|
35
|
+
* ]
|
|
36
|
+
*/
|
|
37
|
+
export function parseBlocks(css) {
|
|
38
|
+
const ctx = { css, i: 0, blocks: [], stack: [] };
|
|
39
|
+
parseTopLevel(ctx);
|
|
40
|
+
return ctx.blocks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** top-level (또는 @rule 내부) 의 selector 들을 발견할 때마다 본문 파싱. */
|
|
44
|
+
function parseTopLevel(ctx) {
|
|
45
|
+
const { css } = ctx;
|
|
46
|
+
const n = css.length;
|
|
47
|
+
while (ctx.i < n) {
|
|
48
|
+
const c = css[ctx.i];
|
|
49
|
+
if (c === "}") return; // 부모 호출에서 닫힘 처리
|
|
50
|
+
if (c === "/" && css[ctx.i + 1] === "*") {
|
|
51
|
+
const end = css.indexOf("*/", ctx.i + 2);
|
|
52
|
+
if (end < 0) {
|
|
53
|
+
ctx.i = n;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
ctx.i = end + 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (/\s/.test(c)) {
|
|
60
|
+
ctx.i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// selector 수집 — '{' 또는 ';' 또는 '}' 까지
|
|
64
|
+
const selStart = ctx.i;
|
|
65
|
+
while (ctx.i < n) {
|
|
66
|
+
const ch = css[ctx.i];
|
|
67
|
+
if (ch === "{" || ch === "}" || ch === ";") break;
|
|
68
|
+
if (ch === "/" && css[ctx.i + 1] === "*") {
|
|
69
|
+
const end = css.indexOf("*/", ctx.i + 2);
|
|
70
|
+
if (end < 0) {
|
|
71
|
+
ctx.i = n;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
ctx.i = end + 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
ctx.i++;
|
|
78
|
+
}
|
|
79
|
+
const head = css.slice(selStart, ctx.i).trim();
|
|
80
|
+
if (!head) {
|
|
81
|
+
ctx.i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (css[ctx.i] === "{") {
|
|
85
|
+
ctx.i++; // skip '{'
|
|
86
|
+
ctx.stack.push(head);
|
|
87
|
+
parseBlockBody(ctx);
|
|
88
|
+
ctx.stack.pop();
|
|
89
|
+
// body 끝나면 ctx.i 는 '}' 다음.
|
|
90
|
+
} else {
|
|
91
|
+
// ';' — top-level @import 등. 무시.
|
|
92
|
+
ctx.i++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `{` 직후 위치부터 본문을 스캔. `--name: value;` 는 vars 로 수집하고,
|
|
99
|
+
* 다른 selector 가 나오면 parseTopLevel 재귀 — 평탄화된 key 로 blocks 에 push.
|
|
100
|
+
* 닫는 `}` 만나면 종료, ctx.i 는 `}` 다음을 가리킨다.
|
|
101
|
+
*/
|
|
102
|
+
function parseBlockBody(ctx) {
|
|
103
|
+
const { css } = ctx;
|
|
104
|
+
const n = css.length;
|
|
105
|
+
const vars = {};
|
|
106
|
+
|
|
107
|
+
while (ctx.i < n) {
|
|
108
|
+
const c = css[ctx.i];
|
|
109
|
+
if (c === "}") {
|
|
110
|
+
ctx.i++;
|
|
111
|
+
// 자기 자신의 vars 가 있으면 blocks 에 push (selector chain 그대로 join).
|
|
112
|
+
if (Object.keys(vars).length > 0) {
|
|
113
|
+
ctx.blocks.push({ key: ctx.stack.join(" > "), vars });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (c === "/" && css[ctx.i + 1] === "*") {
|
|
118
|
+
const end = css.indexOf("*/", ctx.i + 2);
|
|
119
|
+
if (end < 0) {
|
|
120
|
+
ctx.i = n;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
ctx.i = end + 2;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (/\s/.test(c)) {
|
|
127
|
+
ctx.i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// 변수 선언 — `--name: value;`
|
|
131
|
+
if (c === "-" && css[ctx.i + 1] === "-") {
|
|
132
|
+
const colonIdx = css.indexOf(":", ctx.i);
|
|
133
|
+
if (colonIdx < 0) {
|
|
134
|
+
ctx.i = n;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const semiIdx = findStatementEnd(css, colonIdx + 1);
|
|
138
|
+
if (semiIdx < 0) {
|
|
139
|
+
ctx.i = n;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const name = css.slice(ctx.i, colonIdx).trim();
|
|
143
|
+
const value = css.slice(colonIdx + 1, semiIdx).trim();
|
|
144
|
+
vars[name] = value;
|
|
145
|
+
ctx.i = semiIdx + 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// 일반 선언 또는 nested selector — `{` 또는 `;` 를 만날 때까지 스캔.
|
|
149
|
+
let j = ctx.i;
|
|
150
|
+
while (j < n) {
|
|
151
|
+
const ch = css[j];
|
|
152
|
+
if (ch === "{" || ch === "}" || ch === ";") break;
|
|
153
|
+
if (ch === "/" && css[j + 1] === "*") {
|
|
154
|
+
const end = css.indexOf("*/", j + 2);
|
|
155
|
+
if (end < 0) {
|
|
156
|
+
j = n;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
j = end + 2;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
j++;
|
|
163
|
+
}
|
|
164
|
+
if (css[j] === "{") {
|
|
165
|
+
// nested selector — 평탄화 위해 stack 에 push, parseBlockBody 재귀.
|
|
166
|
+
const inner = css.slice(ctx.i, j).trim();
|
|
167
|
+
ctx.i = j + 1;
|
|
168
|
+
ctx.stack.push(inner);
|
|
169
|
+
parseBlockBody(ctx);
|
|
170
|
+
ctx.stack.pop();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// 일반 property — 무시 (var 아닌 일반 css 선언)
|
|
174
|
+
ctx.i = j + 1;
|
|
175
|
+
}
|
|
176
|
+
// EOF — 마무리
|
|
177
|
+
if (Object.keys(vars).length > 0) {
|
|
178
|
+
ctx.blocks.push({ key: ctx.stack.join(" > "), vars });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `;` 또는 블록 끝 `}` 위치 — 변수 선언의 종결점.
|
|
184
|
+
* 괄호 깊이를 추적해 `var(--a, var(--b))` 처럼 안에 ',' 가 있어도 무사.
|
|
185
|
+
*/
|
|
186
|
+
function findStatementEnd(css, start) {
|
|
187
|
+
let i = start;
|
|
188
|
+
let depth = 0;
|
|
189
|
+
const n = css.length;
|
|
190
|
+
while (i < n) {
|
|
191
|
+
const c = css[i];
|
|
192
|
+
if (c === "(") depth++;
|
|
193
|
+
else if (c === ")") depth--;
|
|
194
|
+
else if (depth === 0 && (c === ";" || c === "}")) return i;
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
return -1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 두 블록 트리 비교. selector 키로 매칭 후 변수별 added/removed/changed 분류.
|
|
202
|
+
*
|
|
203
|
+
* @returns {{
|
|
204
|
+
* added: Array<{ selector: string, name: string, value: string }>,
|
|
205
|
+
* removed: Array<{ selector: string, name: string, value: string }>,
|
|
206
|
+
* changed: Array<{ selector: string, name: string, expected: string, current: string }>,
|
|
207
|
+
* unchangedCount: number
|
|
208
|
+
* }}
|
|
209
|
+
*/
|
|
210
|
+
export function diffBlocks(currentBlocks, expectedBlocks) {
|
|
211
|
+
const added = [];
|
|
212
|
+
const removed = [];
|
|
213
|
+
const changed = [];
|
|
214
|
+
let unchangedCount = 0;
|
|
215
|
+
|
|
216
|
+
// 같은 selector 가 파일에 여러 번 등장하면 (예: 색 :root 와 spacing :root 가 분리)
|
|
217
|
+
// vars 를 병합해야 한다 — 단순 Map 으로는 뒤 등장이 앞을 덮어써 오탐 발생.
|
|
218
|
+
const currentByKey = mergeBlocks(currentBlocks);
|
|
219
|
+
const expectedByKey = mergeBlocks(expectedBlocks);
|
|
220
|
+
|
|
221
|
+
// expected 기준 — added / changed / unchanged 분류
|
|
222
|
+
for (const [key, eb] of expectedByKey) {
|
|
223
|
+
const cb = currentByKey.get(key);
|
|
224
|
+
if (!cb) {
|
|
225
|
+
// 매칭 셀렉터 자체가 없으면 모든 expected var 가 added.
|
|
226
|
+
for (const [name, value] of Object.entries(eb.vars)) {
|
|
227
|
+
added.push({ selector: key, name, value });
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for (const [name, eValue] of Object.entries(eb.vars)) {
|
|
232
|
+
const cValue = cb.vars[name];
|
|
233
|
+
if (cValue === undefined) {
|
|
234
|
+
added.push({ selector: key, name, value: eValue });
|
|
235
|
+
} else if (cValue !== eValue) {
|
|
236
|
+
changed.push({ selector: key, name, expected: eValue, current: cValue });
|
|
237
|
+
} else {
|
|
238
|
+
unchangedCount++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// current 기준 — removed (expected 에 없는 사용자 변수)
|
|
244
|
+
for (const [key, cb] of currentByKey) {
|
|
245
|
+
const eb = expectedByKey.get(key);
|
|
246
|
+
if (!eb) {
|
|
247
|
+
// 매칭 셀렉터가 expected 에 없음 — 사용자 커스텀 블록. removed 에 안 넣음.
|
|
248
|
+
// (사용자가 의도적으로 추가했을 가능성 — 자동 제거 위험)
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
for (const [name, cValue] of Object.entries(cb.vars)) {
|
|
252
|
+
if (!(name in eb.vars)) {
|
|
253
|
+
removed.push({ selector: key, name, value: cValue });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { added, removed, changed, unchangedCount };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 같은 selector key 의 vars 를 병합. 뒤 등장이 앞 값을 덮어쓰는 CSS cascade 룰 따름. */
|
|
262
|
+
function mergeBlocks(blocks) {
|
|
263
|
+
const out = new Map();
|
|
264
|
+
for (const b of blocks) {
|
|
265
|
+
const prev = out.get(b.key);
|
|
266
|
+
if (!prev) {
|
|
267
|
+
out.set(b.key, { key: b.key, vars: { ...b.vars } });
|
|
268
|
+
} else {
|
|
269
|
+
Object.assign(prev.vars, b.vars);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* added 변수만 current text 에 incremental insert.
|
|
277
|
+
*
|
|
278
|
+
* 정책:
|
|
279
|
+
* - 같은 selector 블록을 current 에서 찾아 닫는 `}` 바로 전에 한 줄 추가.
|
|
280
|
+
* - 매칭 블록이 없으면 파일 끝에 새 블록을 한 번 append (selector 별로 묶음).
|
|
281
|
+
* - changed / removed 는 건드리지 않음 (사용자 의도 보존).
|
|
282
|
+
*
|
|
283
|
+
* @returns {string} 새로운 css 텍스트
|
|
284
|
+
*/
|
|
285
|
+
export function applyAdditions(currentText, added) {
|
|
286
|
+
if (added.length === 0) return currentText;
|
|
287
|
+
|
|
288
|
+
// selector 별로 묶기
|
|
289
|
+
const bySelector = new Map();
|
|
290
|
+
for (const a of added) {
|
|
291
|
+
if (!bySelector.has(a.selector)) bySelector.set(a.selector, []);
|
|
292
|
+
bySelector.get(a.selector).push(a);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let result = currentText;
|
|
296
|
+
const orphans = [];
|
|
297
|
+
|
|
298
|
+
for (const [selector, items] of bySelector) {
|
|
299
|
+
const inserted = tryInsertIntoExistingBlock(result, selector, items);
|
|
300
|
+
if (inserted !== null) {
|
|
301
|
+
result = inserted;
|
|
302
|
+
} else {
|
|
303
|
+
orphans.push({ selector, items });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (orphans.length > 0) {
|
|
308
|
+
result += "\n\n/* sh-ui upgrade — added */\n";
|
|
309
|
+
for (const { selector, items } of orphans) {
|
|
310
|
+
const segments = selector.split(" > ");
|
|
311
|
+
if (segments.length > 1) {
|
|
312
|
+
// nested — 첫 segment 가 outer (@media 등), 마지막이 inner selector.
|
|
313
|
+
const outer = segments[0];
|
|
314
|
+
const inner = segments[segments.length - 1];
|
|
315
|
+
result += `${outer} {\n ${inner} {\n`;
|
|
316
|
+
for (const it of items) result += ` ${it.name}: ${it.value};\n`;
|
|
317
|
+
result += " }\n}\n";
|
|
318
|
+
} else {
|
|
319
|
+
// 단순 selector 또는 @rule — 그대로 한 블록.
|
|
320
|
+
result += `${selector} {\n`;
|
|
321
|
+
for (const it of items) result += ` ${it.name}: ${it.value};\n`;
|
|
322
|
+
result += "}\n";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* current 안에서 같은 셀렉터 블록을 찾아 닫는 `}` 직전에 라인 삽입.
|
|
332
|
+
* 못 찾으면 null 반환 — 호출부가 orphan 처리.
|
|
333
|
+
*/
|
|
334
|
+
function tryInsertIntoExistingBlock(currentText, selector, items) {
|
|
335
|
+
// selector 가 nested 인 경우 — 단순화 위해 마지막 segment 만으로 매칭.
|
|
336
|
+
// 예: "@media (...) > :root:not(.light):not(.dark)" → ":root:not(.light):not(.dark)"
|
|
337
|
+
const segments = selector.split(" > ");
|
|
338
|
+
const target = segments[segments.length - 1];
|
|
339
|
+
|
|
340
|
+
// target { 의 첫 매칭. selector 정확 일치 (공백 정규화).
|
|
341
|
+
const targetNormalized = target.replace(/\s+/g, " ").trim();
|
|
342
|
+
const re = new RegExp(
|
|
343
|
+
`(${escapeRegex(targetNormalized).replace(/ /g, "\\s+")})\\s*\\{`,
|
|
344
|
+
"g",
|
|
345
|
+
);
|
|
346
|
+
let m;
|
|
347
|
+
while ((m = re.exec(currentText))) {
|
|
348
|
+
const blockStart = m.index + m[0].length;
|
|
349
|
+
// 이 블록의 닫는 `}` 찾기 — 깊이 추적
|
|
350
|
+
const closeIdx = findMatchingBrace(currentText, blockStart);
|
|
351
|
+
if (closeIdx < 0) continue;
|
|
352
|
+
// 닫기 직전 위치에 줄 삽입.
|
|
353
|
+
// 들여쓰기 — 마지막 의미 있는 줄의 들여쓰기 따르기.
|
|
354
|
+
const indent = detectIndent(currentText, closeIdx);
|
|
355
|
+
const insertion = items
|
|
356
|
+
.map((it) => `${indent}${it.name}: ${it.value};`)
|
|
357
|
+
.join("\n") + "\n";
|
|
358
|
+
return (
|
|
359
|
+
currentText.slice(0, closeIdx) + insertion + currentText.slice(closeIdx)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function findMatchingBrace(text, start) {
|
|
366
|
+
let depth = 1;
|
|
367
|
+
let i = start;
|
|
368
|
+
const n = text.length;
|
|
369
|
+
while (i < n) {
|
|
370
|
+
const c = text[i];
|
|
371
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
372
|
+
const end = text.indexOf("*/", i + 2);
|
|
373
|
+
if (end < 0) return -1;
|
|
374
|
+
i = end + 2;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (c === "{") depth++;
|
|
378
|
+
else if (c === "}") {
|
|
379
|
+
depth--;
|
|
380
|
+
if (depth === 0) return i;
|
|
381
|
+
}
|
|
382
|
+
i++;
|
|
383
|
+
}
|
|
384
|
+
return -1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function detectIndent(text, closeIdx) {
|
|
388
|
+
// 닫는 `}` 직전 줄의 들여쓰기 추출.
|
|
389
|
+
// 한 줄 위로 올라가 마지막 의미있는 줄 시작점 찾기.
|
|
390
|
+
let i = closeIdx - 1;
|
|
391
|
+
while (i >= 0 && text[i] !== "\n") i--;
|
|
392
|
+
// i 는 직전 줄의 \n. 다음 줄 시작에서 공백 카운트.
|
|
393
|
+
let j = i + 1;
|
|
394
|
+
let indent = "";
|
|
395
|
+
while (j < closeIdx && (text[j] === " " || text[j] === "\t")) {
|
|
396
|
+
indent += text[j];
|
|
397
|
+
j++;
|
|
398
|
+
}
|
|
399
|
+
return indent || " ";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function escapeRegex(s) {
|
|
403
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
404
|
+
}
|