@iyulab/components 1.14.0 → 1.14.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.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.14.1] - 2026-08-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- ★**Vite 플러그인 진입점이 게시본에 실려 있지 않던 문제 수정** —
|
|
8
|
+
`@iyulab/components/plugins/vite-plugin-react-wrapper.js` 를 임포트하는 소비 패키지의
|
|
9
|
+
**빌드가 실패**했다(`ERR_MODULE_NOT_FOUND`).
|
|
10
|
+
|
|
11
|
+
플러그인을 타입 검사 전용(`noEmit`)으로 바꾸면서 `.js` 산출물이 사라졌는데
|
|
12
|
+
`exports`·`files` 선언은 그대로 남아 있었다. **이 패키지 안에서는 아무 증상이 없다** —
|
|
13
|
+
로컬 빌드는 상대 경로로 `.ts` 소스를 직접 읽기 때문이다. 깨지는 곳은 게시본을 설치한
|
|
14
|
+
다른 패키지이고, 그것도 버전 범위가 새 버전을 잡을 때까지 잠복한다.
|
|
15
|
+
|
|
16
|
+
산출물을 **`dist/plugins/`** 로 내보내고 `exports` 가 그곳을 가리키게 했다.
|
|
17
|
+
**임포트 경로는 종전과 같다** — 소비 패키지는 변경할 것이 없다.
|
|
18
|
+
(산출물을 소스 옆이 아니라 `dist/` 밑에 두는 것은 의도적이다. 옆에 두면 확장자 없는
|
|
19
|
+
임포트가 컴파일본을 소스보다 먼저 잡아 로컬만 낡은 산출물을 쓰게 된다.)
|
|
20
|
+
|
|
21
|
+
`exports` 대상이 실재하는지 검사하는 테스트를 추가했다 — 게시 계약은 게시하는 쪽에서
|
|
22
|
+
확인해야 한다. 소비하는 쪽은 너무 늦게 안다.
|
|
23
|
+
|
|
3
24
|
## [1.14.0] - 2026-08-01
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Rolldown의 preserveModules 모드에서 import.meta.glob의 query 옵션 사용 시
|
|
4
|
+
* 빈 export가 생성되는 문제를 해결하는 플러그인.
|
|
5
|
+
*
|
|
6
|
+
* - `?raw`: 파일 내용을 문자열로 반환
|
|
7
|
+
* - `?inline`: 파일 내용을 문자열로 반환
|
|
8
|
+
* - `?url`: 파일을 asset으로 emit하고 URL을 반환
|
|
9
|
+
*
|
|
10
|
+
* 가상 모듈(\0 prefix)로 변환하여 파일명에 `?`가 포함되는 것을 방지합니다.
|
|
11
|
+
*/
|
|
12
|
+
export default function globResolve(): Plugin;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve, dirname, basename } from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
const VIRTUAL_PREFIX = '\0glob-assets';
|
|
5
|
+
const SUPPORTED_QUERIES = ['?raw', '?inline', '?url'];
|
|
6
|
+
function shortHash(input) {
|
|
7
|
+
return createHash('md5').update(input).digest('hex').slice(0, 8);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Rolldown의 preserveModules 모드에서 import.meta.glob의 query 옵션 사용 시
|
|
11
|
+
* 빈 export가 생성되는 문제를 해결하는 플러그인.
|
|
12
|
+
*
|
|
13
|
+
* - `?raw`: 파일 내용을 문자열로 반환
|
|
14
|
+
* - `?inline`: 파일 내용을 문자열로 반환
|
|
15
|
+
* - `?url`: 파일을 asset으로 emit하고 URL을 반환
|
|
16
|
+
*
|
|
17
|
+
* 가상 모듈(\0 prefix)로 변환하여 파일명에 `?`가 포함되는 것을 방지합니다.
|
|
18
|
+
*/
|
|
19
|
+
export default function globResolve() {
|
|
20
|
+
const idToAbsPath = new Map();
|
|
21
|
+
return {
|
|
22
|
+
name: 'vite:glob-resolve',
|
|
23
|
+
enforce: 'pre',
|
|
24
|
+
resolveId(source, importer) {
|
|
25
|
+
for (const query of SUPPORTED_QUERIES) {
|
|
26
|
+
if (source.endsWith(query)) {
|
|
27
|
+
const rawPath = source.slice(0, -query.length);
|
|
28
|
+
const absPath = importer
|
|
29
|
+
? resolve(dirname(importer), rawPath)
|
|
30
|
+
: rawPath;
|
|
31
|
+
const name = basename(absPath);
|
|
32
|
+
const hash = shortHash(absPath);
|
|
33
|
+
const queryName = query.slice(1);
|
|
34
|
+
const virtualId = `${VIRTUAL_PREFIX}_${queryName}/${name}.${hash}`;
|
|
35
|
+
idToAbsPath.set(virtualId, absPath);
|
|
36
|
+
return virtualId;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
load(id) {
|
|
42
|
+
if (!id.startsWith(VIRTUAL_PREFIX))
|
|
43
|
+
return null;
|
|
44
|
+
const absPath = idToAbsPath.get(id);
|
|
45
|
+
if (!absPath)
|
|
46
|
+
return null;
|
|
47
|
+
// virtualId에서 queryName 추출: "\0glob-assets_{queryName}/..."
|
|
48
|
+
const queryName = id.slice(VIRTUAL_PREFIX.length + 1).split('/')[0];
|
|
49
|
+
if (queryName === 'raw' || queryName === 'inline') {
|
|
50
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
51
|
+
return `export default ${JSON.stringify(content)};`;
|
|
52
|
+
}
|
|
53
|
+
if (queryName === 'url') {
|
|
54
|
+
const source = readFileSync(absPath);
|
|
55
|
+
const fileName = basename(absPath);
|
|
56
|
+
const ref = this.emitFile({
|
|
57
|
+
type: 'asset',
|
|
58
|
+
name: fileName,
|
|
59
|
+
source,
|
|
60
|
+
});
|
|
61
|
+
return `export default import.meta.ROLLUP_FILE_URL_${ref};`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
interface ComponentEvent {
|
|
3
|
+
name: string;
|
|
4
|
+
reactName: string;
|
|
5
|
+
detailType: string;
|
|
6
|
+
/**
|
|
7
|
+
* detail 타입 모듈의 **절대 소스 경로** (없으면 '').
|
|
8
|
+
*
|
|
9
|
+
* 상대 경로가 아니라 절대 경로인 이유: 이벤트는 상속 체인의 어느 파일에서든 선언될 수
|
|
10
|
+
* 있고, 그 파일의 import 경로는 **선언한 파일 기준**이다. 예를 들어
|
|
11
|
+
* `src/components/UOverlayElement.ts` 는 `../events/ShowEvent.js`(= `src/events/`)를
|
|
12
|
+
* 가리키는데, 이것을 상속받는 `src/components/dialog/UDialog.ts` 기준으로 풀면
|
|
13
|
+
* `src/components/events/`(존재하지 않음)가 된다. 수집 시점에 선언 파일 기준으로
|
|
14
|
+
* 절대화해 이 어긋남을 원천 차단한다.
|
|
15
|
+
*/
|
|
16
|
+
detailSource: string;
|
|
17
|
+
}
|
|
18
|
+
interface PluginOptions {
|
|
19
|
+
/** 컴포넌트 소스 디렉토리 경로 (기본값: 'src/components') */
|
|
20
|
+
input?: string;
|
|
21
|
+
/** React 래퍼 출력 폴더 - 빌드 outDir 기준 상대 경로 (기본값: 'react') */
|
|
22
|
+
output?: string;
|
|
23
|
+
/** 래퍼 생성에서 제외할 glob 패턴 목록 (cwd: 프로젝트 루트) */
|
|
24
|
+
exclude?: string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Lit Element 컴포넌트를 React 래퍼로 자동 생성하는 Vite 플러그인
|
|
28
|
+
*/
|
|
29
|
+
export default function reactWrapperPlugin(options: PluginOptions): Plugin;
|
|
30
|
+
/**
|
|
31
|
+
* 이벤트가 **정적으로 수집되지 않는 형태**로 발화되는지 검사한다. 반환값이 비어 있지 않으면
|
|
32
|
+
* 빌드를 실패시킨다 — 누락된 이벤트는 React 소비자 쪽에서 에러도 경고도 없이 죽으므로
|
|
33
|
+
* 경고로 두면 아무도 알아채지 못한다.
|
|
34
|
+
*
|
|
35
|
+
* `content` 는 **leaf 파일 본문만**이다 — 베이스까지 훑으면 `UElement` 의 `fire`/`relay`
|
|
36
|
+
* 구현 자체가 걸려 전 컴포넌트가 위반이 된다. 베이스에서 발화하는 이벤트는 상속 수집
|
|
37
|
+
* ({@link collectComponentEvents})이 이미 채우므로 여기서 볼 필요가 없다.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findEventDeclarationViolations(content: string, className: string, tagName: string, eventCount: number): string[];
|
|
40
|
+
/**
|
|
41
|
+
* 컴포넌트가 노출하는 이벤트를 **상속 체인 전체**에서 수집한다.
|
|
42
|
+
*
|
|
43
|
+
* leaf 파일만 읽으면 베이스 클래스가 발화하는 이벤트를 놓친다 — 예를 들어 `UDialog`·
|
|
44
|
+
* `UDrawer` 는 `show`/`hide` 를 **`UOverlayElement` 가** 발화하므로, leaf 만 보면
|
|
45
|
+
* `events: {}` 가 생성되고 React 소비자는 그 이벤트를 **구독할 방법이 없다**(에러도 경고도
|
|
46
|
+
* 없이 조용히 실패한다).
|
|
47
|
+
*
|
|
48
|
+
* 수집 순서는 **베이스 → leaf**다. `@event` 는 이름만 주고(detail=unknown) 기존 항목을
|
|
49
|
+
* 덮지 않는 반면 `this.fire<T>('name')` 은 타입까지 주며 덮으므로, 이 순서에서
|
|
50
|
+
* ⑴베이스의 타입 정보가 leaf 의 `@event` 재선언에 지워지지 않고
|
|
51
|
+
* ⑵leaf 가 같은 이벤트를 더 구체적 타입으로 발화하면 그것이 이긴다.
|
|
52
|
+
*/
|
|
53
|
+
export declare function collectComponentEvents(filePath: string): ComponentEvent[];
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { resolve, join, relative, dirname, basename } from 'path';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
3
|
+
import { gzipSync } from 'zlib';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
/**
|
|
6
|
+
* Lit Element 컴포넌트를 React 래퍼로 자동 생성하는 Vite 플러그인
|
|
7
|
+
*/
|
|
8
|
+
export default function reactWrapperPlugin(options) {
|
|
9
|
+
let rootDir;
|
|
10
|
+
let buildOutDir;
|
|
11
|
+
let outDir;
|
|
12
|
+
return {
|
|
13
|
+
name: 'vite-plugin-react-wrapper',
|
|
14
|
+
configResolved(config) {
|
|
15
|
+
rootDir = config.root;
|
|
16
|
+
buildOutDir = resolve(rootDir, config.build.outDir);
|
|
17
|
+
outDir = resolve(buildOutDir, options.output || 'react');
|
|
18
|
+
},
|
|
19
|
+
closeBundle() {
|
|
20
|
+
const log = (msg) => console.log(`\x1b[36m[react-wrapper]\x1b[0m ${msg}`);
|
|
21
|
+
log('Generating React wrappers...');
|
|
22
|
+
// 컴포넌트 수집
|
|
23
|
+
const inputPattern = (options.input || 'src/components') + '/**/*.ts';
|
|
24
|
+
let files = globSync(inputPattern, { cwd: rootDir, absolute: true });
|
|
25
|
+
if (options.exclude?.length) {
|
|
26
|
+
const excluded = new Set(options.exclude.flatMap(p => globSync(p, { cwd: rootDir, absolute: true })));
|
|
27
|
+
files = files.filter((f) => !excluded.has(f));
|
|
28
|
+
}
|
|
29
|
+
// 이벤트 선언 누락은 **빌드를 실패**시킨다. 경고로 두면 사람 기억에 의존하게 되고,
|
|
30
|
+
// 누락된 이벤트는 React 소비자 쪽에서 에러 없이 조용히 죽는다 — 알아채기 가장 어려운
|
|
31
|
+
// 실패 형태다. 전 컴포넌트를 훑은 뒤 위반을 한 번에 보고한다.
|
|
32
|
+
const violations = [];
|
|
33
|
+
const components = files.flatMap((f) => parseComponent(f, violations));
|
|
34
|
+
if (violations.length > 0) {
|
|
35
|
+
throw new Error(`[react-wrapper] 이벤트 선언 누락 ${violations.length}건:\n` +
|
|
36
|
+
violations.map(v => ` - ${v}`).join('\n'));
|
|
37
|
+
}
|
|
38
|
+
if (components.length === 0) {
|
|
39
|
+
log('No components found.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// 출력 디렉토리 생성
|
|
43
|
+
mkdirSync(outDir, { recursive: true });
|
|
44
|
+
// 래퍼 생성
|
|
45
|
+
const generated = [];
|
|
46
|
+
for (const comp of components) {
|
|
47
|
+
generated.push(...writeWrapper(comp, outDir, buildOutDir));
|
|
48
|
+
}
|
|
49
|
+
generated.push(...writeIndex(components, outDir, buildOutDir));
|
|
50
|
+
// 결과 출력
|
|
51
|
+
generated.sort((a, b) => a.size - b.size);
|
|
52
|
+
for (const f of generated) {
|
|
53
|
+
const kb = (f.size / 1024).toFixed(2);
|
|
54
|
+
const gzKb = (f.gzipSize / 1024).toFixed(2);
|
|
55
|
+
const pad = ' '.repeat(Math.max(0, 50 - f.path.length));
|
|
56
|
+
console.log(`${f.path}${pad}${kb.padStart(6)} kB │ gzip: ${gzKb.padStart(6)} kB`);
|
|
57
|
+
}
|
|
58
|
+
log(`Generated ${components.length} React wrappers.`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// --- 컴포넌트 파싱 ---
|
|
63
|
+
function parseComponent(filePath, violations = []) {
|
|
64
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
65
|
+
// @customElement('tag-name') 데코레이터 확인
|
|
66
|
+
const tagMatch = content.match(/@customElement\s*\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
67
|
+
if (!tagMatch)
|
|
68
|
+
return [];
|
|
69
|
+
// export class ClassName 추출
|
|
70
|
+
const classMatch = content.match(/export\s+(?:abstract\s+)?class\s+(\w+)/);
|
|
71
|
+
if (!classMatch)
|
|
72
|
+
return [];
|
|
73
|
+
const events = collectComponentEvents(filePath);
|
|
74
|
+
violations.push(...findEventDeclarationViolations(content, classMatch[1], tagMatch[1], events.length));
|
|
75
|
+
return [{
|
|
76
|
+
className: classMatch[1],
|
|
77
|
+
tagName: tagMatch[1],
|
|
78
|
+
filePath,
|
|
79
|
+
events,
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 이벤트가 **정적으로 수집되지 않는 형태**로 발화되는지 검사한다. 반환값이 비어 있지 않으면
|
|
84
|
+
* 빌드를 실패시킨다 — 누락된 이벤트는 React 소비자 쪽에서 에러도 경고도 없이 죽으므로
|
|
85
|
+
* 경고로 두면 아무도 알아채지 못한다.
|
|
86
|
+
*
|
|
87
|
+
* `content` 는 **leaf 파일 본문만**이다 — 베이스까지 훑으면 `UElement` 의 `fire`/`relay`
|
|
88
|
+
* 구현 자체가 걸려 전 컴포넌트가 위반이 된다. 베이스에서 발화하는 이벤트는 상속 수집
|
|
89
|
+
* ({@link collectComponentEvents})이 이미 채우므로 여기서 볼 필요가 없다.
|
|
90
|
+
*/
|
|
91
|
+
export function findEventDeclarationViolations(content, className, tagName, eventCount) {
|
|
92
|
+
const violations = [];
|
|
93
|
+
// relay/dispatchEvent 는 이름을 정적으로 도출할 수 없다. @event 태그도 없으면
|
|
94
|
+
// events 맵이 빈 채로 생성되어 React 소비자가 구독할 방법이 사라진다.
|
|
95
|
+
if (eventCount === 0 && /this\.(relay|dispatchEvent)\s*\(/.test(content)) {
|
|
96
|
+
violations.push(`${className} (${tagName}): 이벤트를 발생시키지만(relay/dispatchEvent) @event 태그가 없어 ` +
|
|
97
|
+
`React 래퍼에 이벤트 prop이 노출되지 않습니다. 클래스 JSDoc에 '@event <name>'을 추가하세요.`);
|
|
98
|
+
}
|
|
99
|
+
// this.fire(변수) 처럼 이름이 리터럴이 아니면 역시 수집되지 않는다.
|
|
100
|
+
for (const m of content.matchAll(/this\.fire\s*(?:<[^>]+>)?\s*\(\s*([^'"\s)])/g)) {
|
|
101
|
+
violations.push(`${className} (${tagName}): this.fire(${m[1]}…) 의 이벤트명이 문자열 리터럴이 아니라 ` +
|
|
102
|
+
`정적으로 수집할 수 없습니다. 클래스 JSDoc에 '@event <name>'을 추가하세요.`);
|
|
103
|
+
}
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 컴포넌트가 노출하는 이벤트를 **상속 체인 전체**에서 수집한다.
|
|
108
|
+
*
|
|
109
|
+
* leaf 파일만 읽으면 베이스 클래스가 발화하는 이벤트를 놓친다 — 예를 들어 `UDialog`·
|
|
110
|
+
* `UDrawer` 는 `show`/`hide` 를 **`UOverlayElement` 가** 발화하므로, leaf 만 보면
|
|
111
|
+
* `events: {}` 가 생성되고 React 소비자는 그 이벤트를 **구독할 방법이 없다**(에러도 경고도
|
|
112
|
+
* 없이 조용히 실패한다).
|
|
113
|
+
*
|
|
114
|
+
* 수집 순서는 **베이스 → leaf**다. `@event` 는 이름만 주고(detail=unknown) 기존 항목을
|
|
115
|
+
* 덮지 않는 반면 `this.fire<T>('name')` 은 타입까지 주며 덮으므로, 이 순서에서
|
|
116
|
+
* ⑴베이스의 타입 정보가 leaf 의 `@event` 재선언에 지워지지 않고
|
|
117
|
+
* ⑵leaf 가 같은 이벤트를 더 구체적 타입으로 발화하면 그것이 이긴다.
|
|
118
|
+
*/
|
|
119
|
+
export function collectComponentEvents(filePath) {
|
|
120
|
+
const eventMap = new Map();
|
|
121
|
+
collectInto(eventMap, filePath, new Set());
|
|
122
|
+
return Array.from(eventMap, ([name, { detailType, detailSource }]) => ({
|
|
123
|
+
name,
|
|
124
|
+
reactName: toCamelEvent(name),
|
|
125
|
+
detailType,
|
|
126
|
+
detailSource,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
function collectInto(eventMap, filePath, visited) {
|
|
130
|
+
if (visited.has(filePath) || !existsSync(filePath))
|
|
131
|
+
return; // 순환 상속/누락 파일 방어
|
|
132
|
+
visited.add(filePath);
|
|
133
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
134
|
+
// import 구문에서 식별자 → 소스 경로 매핑 수집 (detail 타입과 베이스 클래스 양쪽에 쓴다)
|
|
135
|
+
// 예: import { ShowEventDetail } from '../events/ShowEvent.js';
|
|
136
|
+
const importMap = new Map();
|
|
137
|
+
for (const m of content.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
138
|
+
const importPath = m[2];
|
|
139
|
+
for (const name of m[1].split(',')) {
|
|
140
|
+
const typeName = name.replace(/type\s+/, '').trim();
|
|
141
|
+
if (typeName)
|
|
142
|
+
importMap.set(typeName, importPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 베이스 클래스를 먼저 수집한다 (제네릭 베이스 `extends UFormControlElement<T>` 포함)
|
|
146
|
+
const extendsMatch = content.match(/export\s+(?:abstract\s+)?class\s+\w+(?:<[^>]*>)?\s+extends\s+(\w+)/);
|
|
147
|
+
if (extendsMatch) {
|
|
148
|
+
const basePath = resolveLocalModule(filePath, importMap.get(extendsMatch[1]));
|
|
149
|
+
if (basePath)
|
|
150
|
+
collectInto(eventMap, basePath, visited);
|
|
151
|
+
}
|
|
152
|
+
// JSDoc @event 패턴: @event eventName — 이름만 제공하므로 기존 항목을 덮지 않는다
|
|
153
|
+
for (const m of content.matchAll(/@event\s+([\w-]+)/g)) {
|
|
154
|
+
if (!eventMap.has(m[1]))
|
|
155
|
+
eventMap.set(m[1], { detailType: 'unknown', detailSource: '' });
|
|
156
|
+
}
|
|
157
|
+
// this.fire<DetailType>('eventName') 패턴 - 제네릭 타입 추출
|
|
158
|
+
for (const m of content.matchAll(/this\.fire\s*(?:<([^>]+)>)?\s*\(\s*['"]([\w-]+)['"]/g)) {
|
|
159
|
+
const detailType = m[1] || 'unknown';
|
|
160
|
+
const detailSource = resolveLocalModule(filePath, importMap.get(detailType)) || '';
|
|
161
|
+
eventMap.set(m[2], { detailType, detailSource });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** 같은 패키지 소스 안의 상대 import 를 절대 `.ts` 경로로 해석한다 (외부 모듈이면 null) */
|
|
165
|
+
function resolveLocalModule(fromFile, importPath) {
|
|
166
|
+
if (!importPath || !importPath.startsWith('.'))
|
|
167
|
+
return null;
|
|
168
|
+
const abs = resolve(dirname(fromFile), importPath.replace(/\.js$/, '.ts'));
|
|
169
|
+
return existsSync(abs) ? abs : null;
|
|
170
|
+
}
|
|
171
|
+
/** kebab-case 이벤트명을 onCamelCase로 변환 (예: shift-start → onShiftStart) */
|
|
172
|
+
function toCamelEvent(name) {
|
|
173
|
+
return 'on' + name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
|
|
174
|
+
}
|
|
175
|
+
function writeFile(filePath, content, buildOutDir) {
|
|
176
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
177
|
+
return {
|
|
178
|
+
path: relative(buildOutDir, filePath).replace(/\\/g, '/'),
|
|
179
|
+
size: Buffer.byteLength(content, 'utf-8'),
|
|
180
|
+
gzipSize: gzipSync(content).length,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function computeImportPath(from, to) {
|
|
184
|
+
let rel = relative(dirname(from), to).replace(/\\/g, '/').replace(/\.ts$/, '');
|
|
185
|
+
if (!rel.startsWith('.'))
|
|
186
|
+
rel = './' + rel;
|
|
187
|
+
return rel;
|
|
188
|
+
}
|
|
189
|
+
function writeWrapper(comp, outDir, buildOutDir) {
|
|
190
|
+
const { className, tagName, events } = comp;
|
|
191
|
+
const jsPath = join(outDir, `${className}.js`);
|
|
192
|
+
const dtsPath = join(outDir, `${className}.d.ts`);
|
|
193
|
+
// 소스 파일의 빌드 출력 경로 추정 (src/ → 빌드 outDir 매핑)
|
|
194
|
+
const srcIndex = comp.filePath.replace(/\\/g, '/').indexOf('/src/');
|
|
195
|
+
const relFromSrc = srcIndex >= 0
|
|
196
|
+
? comp.filePath.substring(srcIndex + 5).replace(/\\/g, '/').replace(/\.ts$/, '')
|
|
197
|
+
: basename(comp.filePath, '.ts');
|
|
198
|
+
const builtModulePath = resolve(buildOutDir, relFromSrc + '.js');
|
|
199
|
+
const importPath = computeImportPath(jsPath, builtModulePath);
|
|
200
|
+
// events 객체 생성
|
|
201
|
+
const eventsObj = events.length > 0
|
|
202
|
+
? `{\n${events.map(e => ` ${e.reactName}: '${e.name}',`).join('\n')}\n }`
|
|
203
|
+
: '{}';
|
|
204
|
+
// .js
|
|
205
|
+
const js = `import React from 'react';
|
|
206
|
+
import { createComponent } from '@lit/react';
|
|
207
|
+
import { ${className} as ${className}Element } from '${importPath}';
|
|
208
|
+
|
|
209
|
+
export const ${className} = createComponent({
|
|
210
|
+
react: React,
|
|
211
|
+
tagName: '${tagName}',
|
|
212
|
+
elementClass: ${className}Element,
|
|
213
|
+
events: ${eventsObj},
|
|
214
|
+
});
|
|
215
|
+
`;
|
|
216
|
+
// .d.ts - import 구문 생성
|
|
217
|
+
const dtsImportPath = importPath.replace(/\.js$/, '');
|
|
218
|
+
const dtsImports = [
|
|
219
|
+
`import React from 'react';`,
|
|
220
|
+
`import { ${className} as ${className}Element } from '${dtsImportPath}';`,
|
|
221
|
+
];
|
|
222
|
+
// 이벤트 detail 타입별 import - 소스 원본 경로 기준으로 빌드 경로 계산.
|
|
223
|
+
// detailSource 는 **선언한 파일 기준으로 이미 절대화**돼 있다(상속받은 이벤트의 경로가
|
|
224
|
+
// leaf 기준으로 잘못 풀리는 것을 막기 위함 — ComponentEvent.detailSource 주석 참조).
|
|
225
|
+
const detailImportMap = new Map(); // 빌드경로 → [타입명]
|
|
226
|
+
for (const e of events) {
|
|
227
|
+
if (e.detailType === 'unknown' || !e.detailSource)
|
|
228
|
+
continue;
|
|
229
|
+
const absEventSrc = e.detailSource.replace(/\\/g, '/');
|
|
230
|
+
const eventSrcIndex = absEventSrc.indexOf('/src/');
|
|
231
|
+
const relFromSrc = eventSrcIndex >= 0
|
|
232
|
+
? absEventSrc.substring(eventSrcIndex + 5).replace(/\.ts$/, '')
|
|
233
|
+
: '';
|
|
234
|
+
if (!relFromSrc)
|
|
235
|
+
continue;
|
|
236
|
+
const absEventBuilt = resolve(buildOutDir, relFromSrc);
|
|
237
|
+
const eventImportPath = computeImportPath(dtsPath, absEventBuilt + '.js').replace(/\.js$/, '');
|
|
238
|
+
const types = detailImportMap.get(eventImportPath) || [];
|
|
239
|
+
if (!types.includes(e.detailType))
|
|
240
|
+
types.push(e.detailType);
|
|
241
|
+
detailImportMap.set(eventImportPath, types);
|
|
242
|
+
}
|
|
243
|
+
for (const [path, types] of detailImportMap) {
|
|
244
|
+
dtsImports.push(`import { type ${types.join(', type ')} } from '${path}';`);
|
|
245
|
+
}
|
|
246
|
+
const eventTypes = events.length > 0
|
|
247
|
+
? events.map(e => {
|
|
248
|
+
const type = e.detailType !== 'unknown' ? `CustomEvent<${e.detailType}>` : 'CustomEvent';
|
|
249
|
+
return ` ${e.reactName}?: (event: ${type}) => void;`;
|
|
250
|
+
}).join('\n') + '\n'
|
|
251
|
+
: '';
|
|
252
|
+
// 래퍼 이벤트 prop(onChange 등)은 React.HTMLAttributes 의 동명 핸들러와 교집합되면
|
|
253
|
+
// 어떤 시그니처도 대입 불가가 된다. HTMLAttributes 쪽에서 해당 키를 제거해 CustomEvent
|
|
254
|
+
// 시그니처만 남긴다. 이벤트가 없으면 Omit<…, never> 로 HTMLAttributes 원형 유지.
|
|
255
|
+
const eventKeyUnion = events.length > 0
|
|
256
|
+
? events.map(e => `'${e.reactName}'`).join(' | ')
|
|
257
|
+
: 'never';
|
|
258
|
+
// Partial<Element> 은 DOM 프로퍼티(children: HTMLCollection 등)를 포함해 React 의 JSX
|
|
259
|
+
// children/이벤트/className 등과 충돌한다. keyof React.HTMLAttributes 를 제거해 컴포넌트
|
|
260
|
+
// 고유 prop만 남기고, React 친화 타입(children: ReactNode 포함)은 HTMLAttributes 가 제공한다.
|
|
261
|
+
//
|
|
262
|
+
// `React.RefAttributes` 를 반드시 교집합에 넣는다 — `ForwardRefExoticComponent<P>` 는 P 에
|
|
263
|
+
// ref 를 자동으로 더해 주지 않는다. @lit/react 의 `ReactWebComponent` 자신이
|
|
264
|
+
// `PropsWithoutRef<…> & React.RefAttributes<I>` 로 선언돼 있으므로(런타임은 ref 를 전달한다),
|
|
265
|
+
// 이것을 빠뜨리면 **런타임은 되는데 타입만 거부하는** 괴리가 생긴다.
|
|
266
|
+
const dts = `${dtsImports.join('\n')}
|
|
267
|
+
|
|
268
|
+
export declare const ${className}: React.ForwardRefExoticComponent<
|
|
269
|
+
Omit<Partial<${className}Element>, keyof React.HTMLAttributes<${className}Element>>
|
|
270
|
+
& Omit<React.HTMLAttributes<${className}Element>, ${eventKeyUnion}>
|
|
271
|
+
& React.RefAttributes<${className}Element>
|
|
272
|
+
& {
|
|
273
|
+
${eventTypes} }
|
|
274
|
+
>;
|
|
275
|
+
|
|
276
|
+
export type ${className}Props = React.ComponentProps<typeof ${className}>;
|
|
277
|
+
`;
|
|
278
|
+
return [
|
|
279
|
+
writeFile(jsPath, js, buildOutDir),
|
|
280
|
+
writeFile(dtsPath, dts, buildOutDir),
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
function writeIndex(components, outDir, buildOutDir) {
|
|
284
|
+
const jsExports = components
|
|
285
|
+
.map(c => `export { ${c.className} } from './${c.className}.js';`)
|
|
286
|
+
.join('\n');
|
|
287
|
+
const dtsExports = components
|
|
288
|
+
.map(c => `export { ${c.className}, ${c.className}Props } from './${c.className}';`)
|
|
289
|
+
.join('\n');
|
|
290
|
+
return [
|
|
291
|
+
writeFile(join(outDir, 'index.js'), jsExports + '\n', buildOutDir),
|
|
292
|
+
writeFile(join(outDir, 'index.d.ts'), dtsExports + '\n', buildOutDir),
|
|
293
|
+
];
|
|
294
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iyulab/components",
|
|
3
3
|
"description": "web-components library based on lit-element made by iyulab",
|
|
4
|
-
"version": "1.14.
|
|
4
|
+
"version": "1.14.1",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"components",
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
20
|
"skills",
|
|
21
|
-
"plugins/**/*.js",
|
|
22
|
-
"plugins/**/*.d.ts",
|
|
23
21
|
"package.json",
|
|
24
22
|
"README.md",
|
|
25
23
|
"CHANGELOG.md",
|
|
@@ -36,7 +34,7 @@
|
|
|
36
34
|
"./react": "./dist/react/index.js",
|
|
37
35
|
"./react/*": "./dist/react/*",
|
|
38
36
|
"./styles/*": "./dist/styles/*",
|
|
39
|
-
"./plugins/*": "./plugins/*"
|
|
37
|
+
"./plugins/*": "./dist/plugins/*"
|
|
40
38
|
},
|
|
41
39
|
"scripts": {
|
|
42
40
|
"start": "vite --force",
|
|
@@ -44,12 +42,13 @@
|
|
|
44
42
|
"test:browser": "vitest run --project=browser",
|
|
45
43
|
"lint": "eslint src/",
|
|
46
44
|
"lint:fix": "eslint src/ --fix",
|
|
47
|
-
"build": "eslint && npm run typecheck:plugins && vite build && npm run test:react-types",
|
|
45
|
+
"build": "eslint && npm run typecheck:plugins && vite build && npm run build:plugins && npm run test:react-types",
|
|
48
46
|
"test:react-types": "tsc -p tsconfig.react-smoke.json",
|
|
49
47
|
"typecheck:plugins": "tsc -p plugins/tsconfig.json",
|
|
50
48
|
"docs:cssprops": "node scripts/cssprops-doc.mjs --write",
|
|
51
49
|
"docs:react-events": "node scripts/react-events-doc.mjs --write",
|
|
52
|
-
"docs:tokens": "node scripts/design-tokens-doc.mjs --write"
|
|
50
|
+
"docs:tokens": "node scripts/design-tokens-doc.mjs --write",
|
|
51
|
+
"build:plugins": "tsc -p plugins/tsconfig.build.json"
|
|
53
52
|
},
|
|
54
53
|
"dependencies": {
|
|
55
54
|
"@floating-ui/dom": "^1.8.0",
|