sh-ui-cli 0.70.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.
@@ -2,6 +2,21 @@
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
+ },
5
20
  {
6
21
  "version": "0.70.0",
7
22
  "date": "2026-05-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.70.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)