sh-ui-cli 0.68.1 → 0.69.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
@@ -16,6 +16,10 @@ 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 와 동일)
19
23
  sh-ui remove <component...> 설치된 컴포넌트 파일 삭제
20
24
  sh-ui rename-app <old> <new> monorepo 의 앱 이름 일괄 변경
21
25
  (apps/<old>/, packages/ui/ui-apps/ui-<old>/
@@ -105,6 +109,37 @@ try {
105
109
  await doctor({ cwd: process.cwd() });
106
110
  break;
107
111
  }
112
+ case "tokens": {
113
+ // sh-ui tokens diff
114
+ // sh-ui tokens upgrade --apply | --replace
115
+ const sub = rest[0];
116
+ const flags = rest.slice(1);
117
+ const { runTokensDiff, runTokensUpgrade } = await import("../src/tokens-cmd.mjs");
118
+ if (sub === "diff") {
119
+ await runTokensDiff({ cwd: process.cwd() });
120
+ } else if (sub === "upgrade") {
121
+ const apply = flags.includes("--apply");
122
+ const replace = flags.includes("--replace");
123
+ if (apply && replace) {
124
+ console.error("에러: --apply 와 --replace 은 함께 쓸 수 없습니다.\n");
125
+ process.exit(1);
126
+ }
127
+ if (!apply && !replace) {
128
+ console.error(
129
+ "에러: `sh-ui tokens upgrade` 는 --apply 또는 --replace 가 필요합니다.\n" +
130
+ " --apply 추가된 변수만 적용 (사용자 편집 보존)\n" +
131
+ " --replace buildTokens 결과로 통째 덮어쓰기\n" +
132
+ "미리보기는 `sh-ui tokens diff`.",
133
+ );
134
+ process.exit(1);
135
+ }
136
+ await runTokensUpgrade({ cwd: process.cwd(), mode: apply ? "apply" : "replace" });
137
+ } else {
138
+ console.error(`에러: 알 수 없는 tokens 서브명령 '${sub ?? ""}'. 'diff' 또는 'upgrade'.\n`);
139
+ process.exit(1);
140
+ }
141
+ break;
142
+ }
108
143
  case "mcp": {
109
144
  // `sh-ui mcp init ...` → 설정 파일에 엔트리 추가
110
145
  // `sh-ui mcp` → MCP 서버 시작
@@ -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.69.0",
7
+ "date": "2026-05-09",
8
+ "title": "sh-ui tokens diff / upgrade — incremental 토큰 마이그레이션 (Phase B)",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`sh-ui tokens diff` 신설** — 현재 tokens.css 와 buildTokens 결과를 비교해 added(신규) / changed(값 다름) / removed(사용자 전용) 를 selector 블록 단위로 표시. sh-ui 버전 업그레이드 후 어떤 토큰이 새로 들어왔는지 즉시 파악.",
12
+ "**`sh-ui tokens upgrade --apply`** — added 변수만 매칭 selector 블록 끝에 incremental 삽입. 사용자가 손댄 색 (changed) 과 추가한 custom 변수 (removed) 는 그대로 보존. 매칭 selector 가 없으면 파일 끝에 새 블록 append (`/* sh-ui upgrade — added */` 주석 표시).",
13
+ "**`sh-ui tokens upgrade --replace`** — buildTokens 결과로 tokens.css 통째 덮어쓰기. `add tokens --force` 와 동일한 동작이지만 의도가 명시적 (사용자가 모든 편집을 의도적으로 버릴 때만).",
14
+ "**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 룰).",
15
+ "**제약** — buildable theme.base (neutral/zinc/slate) 에서만 동작. custom base64 / rich preset (rose/emerald/violet) 은 buildTokens 가 primitives 미정의로 throw — 친절 메시지로 일찍 종료. Flutter platform 은 미지원 (Dart 토큰 생성 형식이 달라 별도 도구 필요)."
16
+ ],
17
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.69.0"
18
+ },
5
19
  {
6
20
  "version": "0.68.1",
7
21
  "date": "2026-05-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.68.1",
3
+ "version": "0.69.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }