sh-ui-cli 0.98.0 → 0.98.1

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,18 @@
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.98.1",
7
+ "date": "2026-05-19",
8
+ "title": "크로스컴포넌트 상대 import 를 aliases.components 로 재작성 — NodeNext 소비자 호환",
9
+ "type": "patch",
10
+ "highlights": [
11
+ "**크로스컴포넌트 import 가 이제 `aliases.components` 경유로 emit** — `sidebar`(→popover), `calendar`(→select), `date-picker`(→calendar), `markdown-editor`(→code-editor), `code-tabs`(→tabs/code-panel), `form-rhf`/`form-tanstack`/`form-yup`(→form) 등 형제 컴포넌트를 import 하던 모든 컴포넌트가, registry 의 `import … from \"../popover\"` 상대경로 대신 사용자 `sh-ui.config.json` 의 `aliases.components`(예: `@workspace/ui-core/components/popover`)로 재작성된다. `@SH_UI_UTILS@` → `aliases.utils` 치환과 동일한 add-time 변환.",
12
+ "**`moduleResolution: \"NodeNext\"`/`\"Node16\"` 소비자 깨짐 수정** — 상대경로/디렉터리 import 는 NodeNext 가 거부하고, 과거 일부 버전이 emit 한 `\"../popover/index.tsx\"` 의 명시적 `.tsx` 확장자는 TS5097 을 유발해 소비자가 `allowImportingTsExtensions`+`noEmit`(패키지 전체 declaration emit 비활성화) 같은 침습적 tsconfig 변경을 강요당했다. 이제 `\"Bundler\"`·`\"NodeNext\"` 양쪽에서 소비자 tsconfig 변경 없이 resolve 된다.",
13
+ "**add/remove 대칭 + registry 불변** — `remove` 의 unmodified 판정도 같은 재작성을 replay 해 형제-import 컴포넌트 삭제 시 '사용자 수정됨' false positive 가 나지 않는다. registry 소스·docs 듀얼카피·generated 산출물은 변경 없음(CLI 변환만). `aliases.components` 미설정 시 `@SH_UI_UTILS@` 와 동일한 톤의 친절 에러로 사전 안내. 회귀 단위 테스트 추가(전 스위트 407 통과, dual-copy drift 47 components 0)."
14
+ ],
15
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.98.1"
16
+ },
5
17
  {
6
18
  "version": "0.98.0",
7
19
  "date": "2026-05-16",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.98.0",
3
+ "version": "0.98.1",
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
@@ -16,6 +16,8 @@ import {
16
16
  stripStylesImport,
17
17
  isStyleFile,
18
18
  isTsxFile,
19
+ hasCrossComponentImport,
20
+ rewriteCrossComponentImports,
19
21
  } from "./css-bundle.mjs";
20
22
 
21
23
  /**
@@ -96,26 +98,49 @@ function resolveDest(template, config) {
96
98
  }
97
99
 
98
100
  /**
99
- * registry source 안의 placeholder 를 사용자 config 값으로 치환.
100
- * 현재 지원: `@SH_UI_UTILS@` → `aliases.utils` (예: `@/src/shared/lib/utils`).
101
+ * registry source 안의 placeholder / 상대 import 를 사용자 config 값으로 치환.
101
102
  *
102
- * registry 컴포넌트는 cn 유틸을 `import { cn } from "@SH_UI_UTILS@"` 로 import 한다 —
103
- * CLI add 시점에 사용자 프로젝트의 alias 치환해 TS module resolution 이 동작.
103
+ * 1) `@SH_UI_UTILS@` `aliases.utils` (예: `@/src/shared/lib/utils`).
104
+ * registry 컴포넌트는 cn 유틸을 `import { cn } from "@SH_UI_UTILS@"`
105
+ * import 한다 — add 시점에 사용자 프로젝트 alias 로 치환해 module resolution 동작.
106
+ * 2) 크로스컴포넌트 상대 import (`from "../popover"` /
107
+ * `from "../popover/index.tsx"` / `from "../form/types"`) →
108
+ * `aliases.components` 경유 (예: `@workspace/ui-core/components/popover`).
109
+ * 상대경로/명시적 `.tsx` 확장자가 그대로 emit 되면 NodeNext 소비자가 깨지므로
110
+ * (TS5097 → `allowImportingTsExtensions`+`noEmit` 강요), utils 와 동일하게
111
+ * add 시점에 alias 로 정규화한다. 변환 규칙은 css-bundle.mjs 의
112
+ * rewriteCrossComponentImports 참조 (remove.mjs 가 동일 함수로 대칭 replay).
104
113
  *
105
- * aliases.utils 가 미설정인데 placeholder 가 등장하면 친절 에러로 안내. 사용자가 매 컴포넌트
106
- * 추가 후 import 깨진 것을 발견하기 전에 시점에 잡는다.
114
+ * 해당 alias 가 미설정인데 placeholder/상대 import 가 등장하면 친절 에러로
115
+ * 안내 — 사용자가 매 컴포넌트 추가 후 import 깨진 것을 발견하기 전에 잡는다.
107
116
  */
108
117
  function substitutePlaceholders(content, config, srcRel) {
118
+ let out = content;
119
+
109
120
  const PLACEHOLDER = "@SH_UI_UTILS@";
110
- if (!content.includes(PLACEHOLDER)) return content;
111
- const alias = config.aliases?.utils;
112
- if (!alias) {
113
- throw new Error(
114
- `${srcRel} 가 cn 유틸을 import 합니다. sh-ui.config.json 에 aliases.utils 를 설정하세요.\n` +
115
- ` 예: "aliases": { "utils": "@/src/lib/utils" }`,
116
- );
121
+ if (out.includes(PLACEHOLDER)) {
122
+ const alias = config.aliases?.utils;
123
+ if (!alias) {
124
+ throw new Error(
125
+ `${srcRel} 가 cn 유틸을 import 합니다. sh-ui.config.json 에 aliases.utils 를 설정하세요.\n` +
126
+ ` 예: "aliases": { "utils": "@/src/lib/utils" }`,
127
+ );
128
+ }
129
+ out = out.replaceAll(PLACEHOLDER, alias);
117
130
  }
118
- return content.replaceAll(PLACEHOLDER, alias);
131
+
132
+ if (hasCrossComponentImport(out)) {
133
+ const componentsAlias = config.aliases?.components;
134
+ if (!componentsAlias) {
135
+ throw new Error(
136
+ `${srcRel} 가 다른 sh-ui 컴포넌트를 import 합니다. sh-ui.config.json 에 aliases.components 를 설정하세요.\n` +
137
+ ` 예: "aliases": { "components": "@/src/components" }`,
138
+ );
139
+ }
140
+ out = rewriteCrossComponentImports(out, componentsAlias);
141
+ }
142
+
143
+ return out;
119
144
  }
120
145
 
121
146
  async function ensureDir(filePath) {
@@ -88,6 +88,66 @@ export function stripStylesImport(tsxText) {
88
88
  );
89
89
  }
90
90
 
91
+ /**
92
+ * 크로스컴포넌트 상대 import 매칭 정규식 생성 (호출마다 새 RegExp — lastIndex 상태 회피).
93
+ *
94
+ * 매칭: `... from "../<comp>..."` / `... from "../<comp>/index.tsx"` 형태.
95
+ * - group1: `from` 키워드 + 공백 (re-export `export {…} from` 도 포함)
96
+ * - group2: 따옴표
97
+ * - 리터럴 `../` 다음 negative-lookahead `(?!\.)` — `../../` / `../.x` 는 제외
98
+ * (sh-ui 컴포넌트는 항상 형제 디렉터리라 정확히 한 단계 `../`)
99
+ * - group3: 컴포넌트 경로 (따옴표/개행 없음, subpath 포함 가능: `form/types`)
100
+ * 같은 디렉터리(`./styles.css` 등)와 side-effect import(`import "..."`)는 매칭 안 됨.
101
+ */
102
+ function crossComponentImportRe() {
103
+ return /(\bfrom\s+)(["'])\.\.\/(?!\.)([^"'\n]+)\2/g;
104
+ }
105
+
106
+ /**
107
+ * 컴포넌트 소스에 다른 sh-ui 컴포넌트를 가리키는 상대 import 가 있는지.
108
+ * add 시 aliases.components 미설정을 사전 차단하는 게이트로 사용.
109
+ */
110
+ export function hasCrossComponentImport(content) {
111
+ return crossComponentImportRe().test(content);
112
+ }
113
+
114
+ /**
115
+ * registry 컴포넌트의 크로스컴포넌트 상대 import 를 사용자 config 의
116
+ * `aliases.components` 경유 모듈 스펙으로 재작성.
117
+ *
118
+ * 왜: registry 소스는 가독성을 위해 형제 컴포넌트를 `import … from "../popover"`
119
+ * 처럼 상대경로로 적는다. 이게 그대로 소비자 프로젝트에 emit 되면
120
+ * `moduleResolution: "NodeNext"`/`"Node16"` 환경에서 깨진다 — NodeNext 는
121
+ * 확장자 없는/디렉터리 상대 import 를 허용하지 않고, 과거 일부 버전이 emit 한
122
+ * `"../popover/index.tsx"` 는 명시적 `.tsx` 확장자라 TS5097 로 막힌다. 소비자는
123
+ * `allowImportingTsExtensions`+`noEmit` 같은 침습적 tsconfig 변경을 강요당한다.
124
+ * `@SH_UI_UTILS@` 가 `aliases.utils` 로 치환되는 것과 동일하게, 크로스컴포넌트
125
+ * import 도 add 시점에 `aliases.components` 로 재작성하면 Bundler/NodeNext
126
+ * 양쪽에서 소비자 tsconfig 변경 없이 resolve 된다.
127
+ *
128
+ * 변환 규칙: `../<rest>` → `<componentsAlias>/<rest>` (선행 `../` 제거),
129
+ * 그리고 말미의 `/index`(+`.tsx|.ts|.jsx|.js`)는 제거 — `../popover`,
130
+ * `../popover/index.tsx`, `../form/types` 가 모두 동일하게
131
+ * `<alias>/popover`, `<alias>/form/types` 로 정규화된다.
132
+ *
133
+ * `componentsAlias` 가 falsy 면 그대로 반환 (remove.mjs 의 best-effort 재생 replay
134
+ * 처럼 alias 미설정 컨텍스트에서 throw 하지 않기 위함 — 검증/차단은 add.mjs 책임).
135
+ *
136
+ * @param {string} content 컴포넌트 소스
137
+ * @param {string} componentsAlias 예: `@workspace/ui-core/components`
138
+ * @returns {string} 재작성된 소스
139
+ */
140
+ export function rewriteCrossComponentImports(content, componentsAlias) {
141
+ if (!componentsAlias) return content;
142
+ return content.replace(
143
+ crossComponentImportRe(),
144
+ (_m, fromKw, quote, spec) => {
145
+ const rest = spec.replace(/\/index(\.[tj]sx?)?$/, "");
146
+ return `${fromKw}${quote}${componentsAlias}/${rest}${quote}`;
147
+ },
148
+ );
149
+ }
150
+
91
151
  /**
92
152
  * registry 의 file 엔트리가 CSS 변종인지 (bundled 모드에서 별도 처리 대상).
93
153
  */
package/src/remove.mjs CHANGED
@@ -2,7 +2,13 @@ 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";
5
+ import {
6
+ removeSection,
7
+ isStyleFile,
8
+ stripStylesImport,
9
+ isTsxFile,
10
+ rewriteCrossComponentImports,
11
+ } from "./css-bundle.mjs";
6
12
 
7
13
  /** registry file 엔트리가 현재 cssFramework 와 호환되는지 (add.mjs 와 동일 규칙). */
8
14
  function frameworkMatches(entry, cssFramework) {
@@ -105,6 +111,9 @@ export async function remove({ cwd, names, force = false, dryRun = false }) {
105
111
  if (config.aliases?.utils) {
106
112
  out = out.replaceAll("@SH_UI_UTILS@", config.aliases.utils);
107
113
  }
114
+ if (config.aliases?.components) {
115
+ out = rewriteCrossComponentImports(out, config.aliases.components);
116
+ }
108
117
  if (bundled && isTsxFile(file)) out = stripStylesImport(out);
109
118
  return out;
110
119
  };