sh-ui-cli 0.72.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +14 -0
- package/package.json +1 -1
- package/src/theme-extract.mjs +104 -8
- package/src/tokens-cmd.mjs +19 -9
- package/src/tokens-diff-dart.mjs +147 -0
|
@@ -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.73.0",
|
|
7
|
+
"date": "2026-05-10",
|
|
8
|
+
"title": "Flutter — theme extract + tokens diff/upgrade Dart 지원",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`sh-ui theme extract` Flutter 지원** — `sh_ui_tokens.dart` 의 `static const light/dark` 블록에서 `Color(0xFFRRGGBB)` 를 추출해 sh-ui base64 로 인코딩. alpha 채널은 무시 (#RRGGBB 만 인코딩), `defaultRadius` 픽셀값을 16 으로 나눠 rem 변환.",
|
|
12
|
+
"**`sh-ui tokens diff` Flutter 지원** — Dart 의 `static const` 블록을 `<ClassName>.<staticName>` 키로 평탄화 후 field-level 비교. nested 함수 호출 (`Color(0x...)`, `Cubic(0.4, 0, 0.2, 1)`, `Duration(milliseconds: 120)`, `<BoxShadow>[...]`) 를 깊이 카운터로 안전하게 split — 간단 정규식 파서가 깨지지 않음.",
|
|
13
|
+
"**`sh-ui tokens upgrade --replace` Flutter 지원** — buildTokensDart 결과로 통째 덮어쓰기.",
|
|
14
|
+
"**`--apply` 는 Flutter 미지원** — Dart 클래스에 필드 추가는 (1) class field 선언, (2) constructor 파라미터, (3) 모든 static const 인스턴스화 3 군데를 동시에 수정해야 해서 incremental 적용이 위험. 친절 에러로 안내 + `--replace` 권장.",
|
|
15
|
+
"**dispatch 통합** — tokens-cmd / theme-extract 가 platform 으로 분기. React (CSS) 와 Flutter (Dart) 가 같은 명령 표면 공유."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.73.0"
|
|
18
|
+
},
|
|
5
19
|
{
|
|
6
20
|
"version": "0.72.0",
|
|
7
21
|
"date": "2026-05-10",
|
package/package.json
CHANGED
package/src/theme-extract.mjs
CHANGED
|
@@ -17,6 +17,30 @@ import { resolve, relative } from "node:path";
|
|
|
17
17
|
import { parseBlocks } from "./tokens-diff.mjs";
|
|
18
18
|
import { encodeTheme } from "./create/theme/encode.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Dart 의 ShUiColorTokens 필드명 ↔ TOKEN_KEYS (hyphen) 매핑.
|
|
22
|
+
* inject.js 의 DART_FIELD_SOURCES 와 같은 매핑을 reverse 로 사용.
|
|
23
|
+
*/
|
|
24
|
+
const DART_FIELD_TO_KEY = {
|
|
25
|
+
background: "background",
|
|
26
|
+
backgroundSubtle: "background-subtle",
|
|
27
|
+
backgroundMuted: "background-muted",
|
|
28
|
+
backgroundInverse: "background-inverse",
|
|
29
|
+
foreground: "foreground",
|
|
30
|
+
foregroundMuted: "foreground-muted",
|
|
31
|
+
foregroundSubtle: "foreground-subtle",
|
|
32
|
+
foregroundInverse: "foreground-inverse",
|
|
33
|
+
border: "border",
|
|
34
|
+
borderStrong: "border-strong",
|
|
35
|
+
primary: "primary",
|
|
36
|
+
primaryForeground: "primary-foreground",
|
|
37
|
+
primaryHover: "primary-hover",
|
|
38
|
+
danger: "danger",
|
|
39
|
+
dangerForeground: "danger-foreground",
|
|
40
|
+
dangerHover: "danger-hover",
|
|
41
|
+
ring: "ring",
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
const TOKEN_KEYS_REQUIRED = [
|
|
21
45
|
"background", "background-subtle", "background-muted", "background-inverse",
|
|
22
46
|
"foreground", "foreground-muted", "foreground-subtle", "foreground-inverse",
|
|
@@ -150,6 +174,79 @@ export function extractThemeFromCss(cssText) {
|
|
|
150
174
|
};
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Dart sh_ui_tokens.dart 텍스트에서 light/dark + radius 추출 → base64.
|
|
179
|
+
*
|
|
180
|
+
* 파싱 전략:
|
|
181
|
+
* - `static const light = ShUiColorTokens(...)` 블록 찾기 (dark 도 동일)
|
|
182
|
+
* - 본문에서 `field: Color(0xAARRGGBB)` 추출, alpha 무시 (#RRGGBB 만 인코딩)
|
|
183
|
+
* - `static const tokens = ShUiRadiusTokens(...)` 에서 `defaultRadius: X.X,` 추출,
|
|
184
|
+
* X.X / 16 를 rem 으로 사용 (buildTokensDart 가 rem×16 으로 픽셀화한 값)
|
|
185
|
+
*/
|
|
186
|
+
export function extractThemeFromDart(dartText) {
|
|
187
|
+
const light = extractDartColorBlock(dartText, "light");
|
|
188
|
+
const dark = extractDartColorBlock(dartText, "dark");
|
|
189
|
+
|
|
190
|
+
const radiusMatch = /ShUiRadiusTokens\s*\([^)]*defaultRadius:\s*(\d+(?:\.\d+)?)/s.exec(
|
|
191
|
+
dartText,
|
|
192
|
+
);
|
|
193
|
+
if (!radiusMatch) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"theme extract 실패: ShUiRadiusTokens.defaultRadius 를 찾지 못했습니다.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const radiusPx = parseFloat(radiusMatch[1]);
|
|
199
|
+
const radius = Number((radiusPx / 16).toFixed(4));
|
|
200
|
+
|
|
201
|
+
const theme = { light, dark, radius };
|
|
202
|
+
const base64 = encodeTheme(theme);
|
|
203
|
+
return {
|
|
204
|
+
base64,
|
|
205
|
+
theme,
|
|
206
|
+
summary: {
|
|
207
|
+
requiredKeys: Object.keys(light).length,
|
|
208
|
+
optionalKeys: 0,
|
|
209
|
+
optionalEmitted: [],
|
|
210
|
+
radius,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `static const light = ShUiColorTokens(...)` 의 본문에서 field: hex 맵 빌드.
|
|
217
|
+
* Dart `Color(0xFFRRGGBB)` 또는 `Color(0xAARRGGBB)` (alpha 비-FF 는 의미 손실되지만
|
|
218
|
+
* sh-ui base64 는 #RRGGBB 만 지원해 alpha 잘라냄).
|
|
219
|
+
*/
|
|
220
|
+
function extractDartColorBlock(dartText, mode) {
|
|
221
|
+
const blockRe = new RegExp(
|
|
222
|
+
`static\\s+const\\s+${mode}\\s*=\\s*ShUiColorTokens\\s*\\(([\\s\\S]*?)\\);`,
|
|
223
|
+
"m",
|
|
224
|
+
);
|
|
225
|
+
const m = blockRe.exec(dartText);
|
|
226
|
+
if (!m) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`theme extract 실패: \`static const ${mode} = ShUiColorTokens(...)\` 블록을 찾지 못했습니다.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const body = m[1];
|
|
232
|
+
const fieldRe = /([a-zA-Z_]\w*):\s*Color\(0x([0-9a-fA-F]{8})\)/g;
|
|
233
|
+
const out = {};
|
|
234
|
+
let fm;
|
|
235
|
+
while ((fm = fieldRe.exec(body))) {
|
|
236
|
+
const field = fm[1];
|
|
237
|
+
const hexFull = fm[2].toUpperCase();
|
|
238
|
+
const key = DART_FIELD_TO_KEY[field];
|
|
239
|
+
if (!key) continue; // 알 수 없는 필드는 무시 (사용자가 ShUiColorTokens 확장한 경우)
|
|
240
|
+
out[key] = `#${hexFull.slice(2)}`;
|
|
241
|
+
}
|
|
242
|
+
if (Object.keys(out).length === 0) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`theme extract 실패: ${mode} 블록에서 Color 필드를 추출하지 못했습니다.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
153
250
|
async function loadConfig(cwd) {
|
|
154
251
|
const configPath = resolve(cwd, "sh-ui.config.json");
|
|
155
252
|
if (!existsSync(configPath)) {
|
|
@@ -162,22 +259,21 @@ async function loadConfig(cwd) {
|
|
|
162
259
|
|
|
163
260
|
export async function runThemeExtract({ cwd, output }) {
|
|
164
261
|
const config = await loadConfig(cwd);
|
|
165
|
-
if (config.platform !== "react") {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`theme extract 는 React 만 지원합니다 (현재: ${config.platform}). Flutter 는 별도 도구 필요.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
262
|
const tokensRel = config.paths?.tokens;
|
|
171
263
|
if (!tokensRel) throw new Error("paths.tokens 가 sh-ui.config.json 에 없습니다.");
|
|
172
264
|
const tokensPath = resolve(cwd, tokensRel);
|
|
173
265
|
if (!existsSync(tokensPath)) {
|
|
174
266
|
throw new Error(
|
|
175
|
-
|
|
267
|
+
`토큰 파일이 없습니다 (${tokensRel}). \`sh-ui add tokens\` 로 먼저 생성하세요.`,
|
|
176
268
|
);
|
|
177
269
|
}
|
|
178
270
|
|
|
179
|
-
const
|
|
180
|
-
const
|
|
271
|
+
const tokensText = await readFile(tokensPath, "utf8");
|
|
272
|
+
const result =
|
|
273
|
+
config.platform === "flutter"
|
|
274
|
+
? extractThemeFromDart(tokensText)
|
|
275
|
+
: extractThemeFromCss(tokensText);
|
|
276
|
+
const { base64, summary } = result;
|
|
181
277
|
|
|
182
278
|
if (output) {
|
|
183
279
|
await writeFile(resolve(cwd, output), base64 + "\n", "utf8");
|
package/src/tokens-cmd.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { pathToFileURL } from "node:url";
|
|
|
15
15
|
import { getTokensRoot } from "./paths.mjs";
|
|
16
16
|
import { THEME_BASES } from "./constants.js";
|
|
17
17
|
import { parseBlocks, diffBlocks, applyAdditions } from "./tokens-diff.mjs";
|
|
18
|
+
import { parseDartTokens, diffDartTokens } from "./tokens-diff-dart.mjs";
|
|
18
19
|
|
|
19
20
|
async function loadTokensBuilder() {
|
|
20
21
|
const url = pathToFileURL(resolve(getTokensRoot(), "build.mjs")).href;
|
|
@@ -48,12 +49,6 @@ async function buildExpected(config) {
|
|
|
48
49
|
"diff/upgrade 는 base 가 neutral/zinc/slate 일 때만 사용 가능합니다.",
|
|
49
50
|
);
|
|
50
51
|
}
|
|
51
|
-
if (config.platform !== "react") {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`tokens diff/upgrade 는 React 만 지원합니다 (현재: ${config.platform}). ` +
|
|
54
|
-
"Flutter 는 향후 추가 예정.",
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
52
|
const { buildTokens } = await loadTokensBuilder();
|
|
58
53
|
return buildTokens(config);
|
|
59
54
|
}
|
|
@@ -74,7 +69,7 @@ function renderDiffReport({ added, removed, changed, unchangedCount }, rel) {
|
|
|
74
69
|
console.log(`\nsh-ui tokens diff — ${rel}\n`);
|
|
75
70
|
|
|
76
71
|
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
77
|
-
console.log(`✓ 변경 없음 — 사용자
|
|
72
|
+
console.log(`✓ 변경 없음 — 사용자 토큰 파일이 buildTokens 결과와 동일 (${unchangedCount} 항목).`);
|
|
78
73
|
return;
|
|
79
74
|
}
|
|
80
75
|
|
|
@@ -113,7 +108,10 @@ export async function runTokensDiff({ cwd }) {
|
|
|
113
108
|
const config = await loadConfig(cwd);
|
|
114
109
|
const expectedText = await buildExpected(config);
|
|
115
110
|
const { text: currentText, rel } = await loadCurrent(config, cwd);
|
|
116
|
-
const diff =
|
|
111
|
+
const diff =
|
|
112
|
+
config.platform === "flutter"
|
|
113
|
+
? diffDartTokens(parseDartTokens(currentText), parseDartTokens(expectedText))
|
|
114
|
+
: diffBlocks(parseBlocks(currentText), parseBlocks(expectedText));
|
|
117
115
|
renderDiffReport(diff, rel);
|
|
118
116
|
}
|
|
119
117
|
|
|
@@ -121,7 +119,19 @@ export async function runTokensUpgrade({ cwd, mode }) {
|
|
|
121
119
|
const config = await loadConfig(cwd);
|
|
122
120
|
const expectedText = await buildExpected(config);
|
|
123
121
|
const current = await loadCurrent(config, cwd);
|
|
124
|
-
const
|
|
122
|
+
const isFlutter = config.platform === "flutter";
|
|
123
|
+
|
|
124
|
+
if (isFlutter && mode === "apply") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"Flutter 에선 `tokens upgrade --apply` 미지원 — Dart 클래스에 필드 추가는 " +
|
|
127
|
+
"선언/생성자/static const 3 군데를 동시에 수정해야 해서 incremental 적용이 위험합니다.\n" +
|
|
128
|
+
" 대신 `sh-ui tokens upgrade --replace` 로 통째 재생성하세요.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const diff = isFlutter
|
|
133
|
+
? diffDartTokens(parseDartTokens(current.text), parseDartTokens(expectedText))
|
|
134
|
+
: diffBlocks(parseBlocks(current.text), parseBlocks(expectedText));
|
|
125
135
|
|
|
126
136
|
console.log(`\nsh-ui tokens upgrade — ${current.rel} (${mode})\n`);
|
|
127
137
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Flutter sh_ui_tokens.dart 와 buildTokensDart 결과를 비교한다.
|
|
2
|
+
//
|
|
3
|
+
// Dart 의 토큰 파일 구조:
|
|
4
|
+
// class ShUiColorTokens { final Color background; ...
|
|
5
|
+
// static const light = ShUiColorTokens(
|
|
6
|
+
// background: Color(0xFFFFFFFF),
|
|
7
|
+
// ...
|
|
8
|
+
// );
|
|
9
|
+
// static const dark = ShUiColorTokens(...);
|
|
10
|
+
// }
|
|
11
|
+
// class ShUiSpacingTokens { ...
|
|
12
|
+
// static const tokens = ShUiSpacingTokens(s0: 0.0, s1: 4.0, ...);
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// 비교 단위는 `<ClassName>.<staticName>` (예: ShUiColorTokens.light) — 그 안의
|
|
16
|
+
// field: value 맵으로 평탄화 후 added / changed / removed 분류.
|
|
17
|
+
//
|
|
18
|
+
// Apply 정책:
|
|
19
|
+
// Dart 클래스에 새 필드 추가는 (1) class field 선언, (2) constructor 파라미터,
|
|
20
|
+
// (3) 모든 static const 인스턴스화 — 3 군데 동기 수정 필요. 위험성/구현 복잡도가
|
|
21
|
+
// CSS 마커 기반과 비교 불가. v1 Flutter 는 --replace 만 지원, --apply 는 안내 에러.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `static const NAME = ClassName(...)` 블록들을 추출.
|
|
25
|
+
* @returns { [`ClassName.staticName`]: { field: rawValue } }
|
|
26
|
+
*/
|
|
27
|
+
export function parseDartTokens(dartText) {
|
|
28
|
+
const out = {};
|
|
29
|
+
// 의도적으로 단순 — `static const NAME = ClassName(...)` 의 본문 (마지막 `;` 까지) 을 캡처.
|
|
30
|
+
// 본문엔 nested 함수 호출 (Color(0x...), Cubic(...), Duration(...)) 이 가능하므로
|
|
31
|
+
// 간단한 깊이 카운터로 닫는 `)` 매칭.
|
|
32
|
+
const re = /static\s+const\s+([a-zA-Z_]\w*)\s*=\s*(ShUi[A-Z]\w*)\s*\(/g;
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = re.exec(dartText))) {
|
|
35
|
+
const staticName = m[1];
|
|
36
|
+
const className = m[2];
|
|
37
|
+
const bodyStart = m.index + m[0].length;
|
|
38
|
+
const bodyEnd = findClosingParen(dartText, bodyStart);
|
|
39
|
+
if (bodyEnd < 0) continue;
|
|
40
|
+
const body = dartText.slice(bodyStart, bodyEnd);
|
|
41
|
+
const fields = parseDartFieldList(body);
|
|
42
|
+
if (Object.keys(fields).length === 0) continue;
|
|
43
|
+
out[`${className}.${staticName}`] = fields;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findClosingParen(text, start) {
|
|
49
|
+
let depth = 1;
|
|
50
|
+
let i = start;
|
|
51
|
+
const n = text.length;
|
|
52
|
+
while (i < n) {
|
|
53
|
+
const c = text[i];
|
|
54
|
+
if (c === "(") depth++;
|
|
55
|
+
else if (c === ")") {
|
|
56
|
+
depth--;
|
|
57
|
+
if (depth === 0) return i;
|
|
58
|
+
}
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `field: value, field: value, ...` 본문을 { field: value-string } 으로.
|
|
66
|
+
* 값은 trim 된 raw 문자열 — `Color(0xFF...)`, `0.5`, `Duration(milliseconds: 120)`,
|
|
67
|
+
* `Cubic(0.4, 0, 0.2, 1)`, `<BoxShadow>[...]` 등 그대로 보존.
|
|
68
|
+
*/
|
|
69
|
+
function parseDartFieldList(body) {
|
|
70
|
+
const out = {};
|
|
71
|
+
const items = splitByTopLevelComma(body);
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
const trimmed = item.trim();
|
|
74
|
+
if (!trimmed) continue;
|
|
75
|
+
const colonIdx = findTopLevelColon(trimmed);
|
|
76
|
+
if (colonIdx < 0) continue;
|
|
77
|
+
const field = trimmed.slice(0, colonIdx).trim();
|
|
78
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
79
|
+
if (field && value) out[field] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 깊이 0 콤마로 split. (), [], {} 안의 콤마는 보호. */
|
|
85
|
+
function splitByTopLevelComma(text) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let start = 0;
|
|
89
|
+
for (let i = 0; i < text.length; i++) {
|
|
90
|
+
const c = text[i];
|
|
91
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
92
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
93
|
+
else if (c === "," && depth === 0) {
|
|
94
|
+
parts.push(text.slice(start, i));
|
|
95
|
+
start = i + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
parts.push(text.slice(start));
|
|
99
|
+
return parts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 깊이 0 의 첫 ':' — `field: Color(0x...)` 형식 분리용. */
|
|
103
|
+
function findTopLevelColon(text) {
|
|
104
|
+
let depth = 0;
|
|
105
|
+
for (let i = 0; i < text.length; i++) {
|
|
106
|
+
const c = text[i];
|
|
107
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
108
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
109
|
+
else if (c === ":" && depth === 0) return i;
|
|
110
|
+
}
|
|
111
|
+
return -1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 두 Dart 토큰 트리 비교. CSS 측 diffBlocks 와 같은 결과 모양.
|
|
116
|
+
*/
|
|
117
|
+
export function diffDartTokens(currentTokens, expectedTokens) {
|
|
118
|
+
const added = [];
|
|
119
|
+
const removed = [];
|
|
120
|
+
const changed = [];
|
|
121
|
+
let unchangedCount = 0;
|
|
122
|
+
|
|
123
|
+
const allKeys = new Set([
|
|
124
|
+
...Object.keys(currentTokens),
|
|
125
|
+
...Object.keys(expectedTokens),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
for (const blockKey of allKeys) {
|
|
129
|
+
const cur = currentTokens[blockKey] ?? {};
|
|
130
|
+
const exp = expectedTokens[blockKey] ?? {};
|
|
131
|
+
const fieldKeys = new Set([...Object.keys(cur), ...Object.keys(exp)]);
|
|
132
|
+
for (const field of fieldKeys) {
|
|
133
|
+
const cv = cur[field];
|
|
134
|
+
const ev = exp[field];
|
|
135
|
+
if (cv === undefined && ev !== undefined) {
|
|
136
|
+
added.push({ selector: blockKey, name: field, value: ev });
|
|
137
|
+
} else if (ev === undefined && cv !== undefined) {
|
|
138
|
+
removed.push({ selector: blockKey, name: field, value: cv });
|
|
139
|
+
} else if (cv !== ev) {
|
|
140
|
+
changed.push({ selector: blockKey, name: field, expected: ev, current: cv });
|
|
141
|
+
} else {
|
|
142
|
+
unchangedCount++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { added, removed, changed, unchangedCount };
|
|
147
|
+
}
|