@iyulab/components 1.7.1 → 1.8.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/CHANGELOG.md +39 -0
- package/dist/components/UOverlayElement.js +2 -1
- package/dist/components/icon/UIcon.js +5 -7
- package/dist/components/tree/UTree.js +2 -1
- package/dist/utilities/Dialog.js +34 -27
- package/dist/utilities/Theme.js +1 -1
- package/dist/utilities/icons.d.ts +38 -4
- package/dist/utilities/icons.js +83 -51
- package/package.json +3 -1
- package/skills/iyulab-components/references/utilities/icons.md +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.0] - 2026-07-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`u-icon` 아이콘 리졸브가 재렌더·재마운트마다 다시 fetch되던 스톰 수정** — `IconRegistry.resolve()`가 리졸버 결과를 캐시하지 않아, SSE 스트리밍처럼 같은 아이콘이 반복 재마운트되는 UI에서 단일 아이콘에 수백 회 fetch가 발생했다(미존재 아이콘은 404 스톰으로 콘솔 오염 + dev 서버 부하 — SMI.AIMS 실측, ISSUE-components-20260722-iconregistry-resolver-no-cache). 이제 레지스트리가 (lib, name) 단위 캐싱과 동시 요청 in-flight dedupe를 소유해, 커스텀 리졸버를 포함한 모든 라이브러리에서 아이콘당 세션 1회만 리졸브된다. `u-icon`의 `src` 경로·무-lib 기본(baseUrl) 경로도 신설 `IconRegistry.resolveUrl(url)`을 경유해 동일하게 캐시된다.
|
|
7
|
+
- 내장 CDN 리졸버(tabler/heroicons/lucide/bootstrap)가 네트워크 오류를 `undefined`로 삼키던 것을 throw 전파로 교정 — 일시 장애가 세션 내 not-found로 오인·고착되지 않고 다음 조회에서 재시도된다.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **`IconResolver` 계약 명확화 (동작 변경)** — 리졸버의 `undefined` 반환은 이제 **not-found 확정**을 뜻하며 네거티브 캐시되어 세션 내 재호출되지 않는다(탈출구: `IconCache.clear()`). **일시 오류(네트워크 장애 등)에는 `undefined` 대신 `throw`를 사용할 것** — throw는 캐시되지 않아 다음 조회 시 재시도된다. 일시 실패에 `undefined`를 반환하던 커스텀 리졸버는 throw로 전환해야 재시도 동작을 유지한다.
|
|
11
|
+
- `IconRegistry.unregister(lib)`가 해당 라이브러리의 캐시 항목을 함께 비운다 — 내장 CDN 라이브러리를 로컬 리졸버로 교체(`unregister` → `register`, 폐쇄망 대응)할 때 이전 리졸버의 stale 결과가 남지 않는다.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `IconRegistry.resolveUrl(url)` — URL 직접 리졸브(캐시 + dedupe). 예약 네임스페이스 `url`로 `IconCache`에 저장되며 소비앱의 prewarm 용도로도 사용 가능.
|
|
15
|
+
- `IconCache.clear(lib)` — 특정 라이브러리 항목만 클리어(전체 클리어는 기존대로 인자 없이).
|
|
16
|
+
- `IconCache.set()`이 `undefined`(네거티브 항목) 저장을 허용.
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
- `docs/icons.md`·skills 레퍼런스에 리졸버 계약(성공/not-found/일시 오류)·캐싱 동작·내장 라이브러리 오버라이드 절차(`unregister`→`register`) 문서화. CDN 버전 표기 드리프트 교정(lucide 0.577.0, bootstrap 1.13.1).
|
|
20
|
+
|
|
21
|
+
## [1.7.2] - 2026-07-19
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **`Dialog.show()`가 영구 대기(hang)에 빠지던 결함 수정** — 프로미스 executor 가 `async` 였던 탓에 `await dialog.updateComplete` 가 reject 되면 예외가 삼켜지고 `hide` 리스너가 등록조차 되지 않아, `await Dialog.show(...)` 호출자가 **영원히 매달렸다**. 이제 리스너를 `await` **이전에** 등록하고(대기 중 발생한 `hide` 를 놓치던 경합도 함께 해소), 업데이트 실패 시 `console.error` 후 문서화된 "닫힘 = null" 규약대로 `null` 로 종료하며 고아 엘리먼트를 DOM 에서 제거한다. 회귀 테스트 3건 추가(정상 resolve / 닫힘 null / updateComplete reject).
|
|
25
|
+
- `UOverlayElement`: 삼항 연산자를 문(statement)으로 사용하던 `open ? setup() : cleanup()` 을 `if/else` 로 교정.
|
|
26
|
+
- `URating`/`USelect`/`UTree`: `switch` case 블록 안의 `const` 선언이 블록 스코프를 벗어나 다른 case 로 누출될 수 있던 형태를 중괄호 블록으로 격리(`no-case-declarations`).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **이 패키지의 eslint 가 실제로 동작하기 시작했다.** `eslint.config.js` 의 두 결함 — (1) `files: ["src/**/*"]` 가 ESLint 9 에서 universal 패턴으로 취급돼 `.ts` 를 린팅 대상으로 opt-in 하지 못함, (2) 배열 프리셋(`tseslint.configs.recommended`)을 객체 스프레드해 프리셋이 통째로 무력화됨 — 을 수정했다. `build` 스크립트에 `eslint &&` 게이트가 있었으나 매칭 파일이 0개라 **항상 통과**하고 있었다. 위 결함들은 모두 이 복구로 처음 드러난 것이다.
|
|
30
|
+
- `npm run lint` / `npm run lint:fix` 스크립트 추가(flex-table·u-widgets 와 통일).
|
|
31
|
+
- 내부 타입 정밀화: `Dialog`/`Theme`/`UTooltip`/`UInput`/`UTextarea` 의 `any` 캐스팅을 실제 타입(`CloseOnPolicy[]`, `UInput`, `InputType`, `VirtualElement`, `unknown[]`)으로 교체. 공개 API 시그니처 변경 없음.
|
|
32
|
+
|
|
33
|
+
## [1.7.1] - 2026-07-19
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- `UInput.type` 을 host 요소로 **reflect** 하도록 수정 — 미반영 시 `u-input[type="number"]::part(input)` 같은 속성 셀렉터가 HTML 속성으로 준 경우에만 매칭되고, React/Lit 의 property 바인딩(`.type=`, `el.type=`)에서는 host 에 속성이 나타나지 않아 무효였다. 형제 컴포넌트 `URadio.type` 은 이미 reflect 하고 있어 리포 내 비일관이기도 했다.
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
- `docs/theming.md` 에 `::part()` 커스터마이즈 섹션 신설 — 텍스트 정렬 레시피(`text-align` + `font-variant-numeric: tabular-nums`), 숫자 입력 우측정렬을 기본값으로 두지 않는 근거, 비반영 속성용 클래스 셀렉터 대안, 숫자 포맷팅 책임 범위.
|
|
40
|
+
- `docs/architecture.md` CSS Parts 절에서 `theming.md` 로 상호 링크. `UInput` 의 `@csspart input` JSDoc 보강.
|
|
41
|
+
|
|
3
42
|
## [1.7.0] - 2026-07-17
|
|
4
43
|
|
|
5
44
|
### Fixed
|
|
@@ -48,7 +48,8 @@ var UOverlayElement = class extends UElement {
|
|
|
48
48
|
}
|
|
49
49
|
updated(changedProperties) {
|
|
50
50
|
super.updated(changedProperties);
|
|
51
|
-
if (changedProperties.has("open")) this.open
|
|
51
|
+
if (changedProperties.has("open")) if (this.open) this.setup();
|
|
52
|
+
else this.cleanup();
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* 오버레이를 표시합니다. u-show 이벤트가 취소되면 열리지 않습니다.
|
|
@@ -13,8 +13,9 @@ var UIcon = class UIcon extends UElement {
|
|
|
13
13
|
this.styles = [super.styles, styles];
|
|
14
14
|
}
|
|
15
15
|
render() {
|
|
16
|
-
if (this.src) return until(
|
|
17
|
-
|
|
16
|
+
if (this.src) return until(IconRegistry.resolveUrl(this.src).then((html) => {
|
|
17
|
+
const sanitized = this.sanitize(html);
|
|
18
|
+
return sanitized ? unsafeHTML(sanitized) : nothing;
|
|
18
19
|
}), nothing);
|
|
19
20
|
if (this.name) return until(this.resolve(this.name).then((html) => {
|
|
20
21
|
return html ? unsafeHTML(html) : nothing;
|
|
@@ -24,12 +25,9 @@ var UIcon = class UIcon extends UElement {
|
|
|
24
25
|
async resolve(name) {
|
|
25
26
|
let html = void 0;
|
|
26
27
|
if (this.lib) html = await IconRegistry.resolve(this.lib, name);
|
|
27
|
-
else
|
|
28
|
+
else {
|
|
28
29
|
const url = `${getDefaultBaseUrl().replace(/\/$/, "")}/${name}.svg`;
|
|
29
|
-
|
|
30
|
-
if (response.ok) html = await response.text();
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error(error);
|
|
30
|
+
html = await IconRegistry.resolveUrl(url);
|
|
33
31
|
}
|
|
34
32
|
return this.sanitize(html);
|
|
35
33
|
}
|
|
@@ -119,11 +119,12 @@ var UTree = class UTree extends UElement {
|
|
|
119
119
|
items[items.length - 1]?.focus();
|
|
120
120
|
break;
|
|
121
121
|
case "Enter":
|
|
122
|
-
case " ":
|
|
122
|
+
case " ": {
|
|
123
123
|
e.preventDefault();
|
|
124
124
|
const header = focused.shadowRoot?.querySelector(".header");
|
|
125
125
|
if (header) header.click();
|
|
126
126
|
break;
|
|
127
|
+
}
|
|
127
128
|
}
|
|
128
129
|
};
|
|
129
130
|
}
|
package/dist/utilities/Dialog.js
CHANGED
|
@@ -81,39 +81,46 @@ var Dialog = class {
|
|
|
81
81
|
* 커스텀 다이얼로그를 표시합니다.
|
|
82
82
|
* @returns 클릭된 액션의 value 또는 닫힌 경우 null
|
|
83
83
|
*/
|
|
84
|
-
static show(options) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
await dialog.updateComplete;
|
|
110
|
-
dialog.show();
|
|
84
|
+
static async show(options) {
|
|
85
|
+
let closeValue = null;
|
|
86
|
+
const dialog = this.createDialog(options);
|
|
87
|
+
const content = typeof options.content === "string" ? unsafeHTML(options.content) : options.content;
|
|
88
|
+
const actions = options.actions && options.actions.length > 0 ? options.actions : null;
|
|
89
|
+
render(html`
|
|
90
|
+
<div style="min-width: 320px; font-size: 14px; line-height: 1.6;">
|
|
91
|
+
${content}
|
|
92
|
+
${actions ? html`
|
|
93
|
+
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;">
|
|
94
|
+
${actions.map((action) => html`
|
|
95
|
+
<u-button
|
|
96
|
+
variant=${action.variant || "solid"}
|
|
97
|
+
@click=${() => {
|
|
98
|
+
closeValue = action.value;
|
|
99
|
+
dialog.hide();
|
|
100
|
+
}}
|
|
101
|
+
>${action.label}</u-button>
|
|
102
|
+
`)}
|
|
103
|
+
</div>
|
|
104
|
+
` : nothing}
|
|
105
|
+
</div>
|
|
106
|
+
`, dialog);
|
|
107
|
+
(options.target || document.body).appendChild(dialog);
|
|
108
|
+
const closed = new Promise((resolve) => {
|
|
111
109
|
dialog.addEventListener("hide", (e) => {
|
|
112
110
|
if (e.target !== dialog) return;
|
|
113
111
|
setTimeout(() => dialog.remove(), 300);
|
|
114
112
|
resolve(closeValue);
|
|
115
113
|
});
|
|
116
114
|
});
|
|
115
|
+
try {
|
|
116
|
+
await dialog.updateComplete;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error("[Dialog] 다이얼로그를 표시하지 못했습니다.", error);
|
|
119
|
+
dialog.remove();
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
dialog.show();
|
|
123
|
+
return closed;
|
|
117
124
|
}
|
|
118
125
|
/** 다이얼로그 엘리먼트를 생성하고 옵션을 적용합니다. */
|
|
119
126
|
static createDialog(options) {
|
package/dist/utilities/Theme.js
CHANGED
|
@@ -51,7 +51,7 @@ var Theme = class {
|
|
|
51
51
|
}
|
|
52
52
|
if (options?.useBuiltIn ?? true) {
|
|
53
53
|
this.log("Import enabled: loading styles via internal assets");
|
|
54
|
-
for (
|
|
54
|
+
for (const [name, module] of internalStyleBundle) {
|
|
55
55
|
const style = document.createElement("style");
|
|
56
56
|
style.setAttribute("data-name", name);
|
|
57
57
|
style.textContent = module;
|
|
@@ -10,11 +10,18 @@ declare function getDefaultBaseUrl(): string;
|
|
|
10
10
|
declare function setDefaultBaseUrl(url: string): void;
|
|
11
11
|
/**
|
|
12
12
|
* `name`으로 아이콘 리소스를 가져오는 함수입니다.
|
|
13
|
+
*
|
|
14
|
+
* 반환 계약 — 레지스트리가 결과를 (lib, name) 단위로 캐시하므로 리졸버는 순수 조회만 담당합니다:
|
|
15
|
+
* - `string` 반환: 성공 — 세션 동안 캐시되어 재호출되지 않습니다.
|
|
16
|
+
* - `undefined` 반환: **not-found 확정** — 네거티브 캐시되어 재호출되지 않습니다.
|
|
17
|
+
* (탈출구: `IconCache.clear()`)
|
|
18
|
+
* - `throw`: **일시 오류**(네트워크 장애 등) — 캐시되지 않아 다음 조회 시 재시도됩니다.
|
|
13
19
|
*/
|
|
14
20
|
type IconResolver = (name: string) => string | undefined | Promise<string | undefined>;
|
|
15
21
|
/**
|
|
16
22
|
* 아이콘 리소스 캐시입니다.
|
|
17
23
|
* lib + name 조합으로 SVG 리소스를 저장/조회합니다.
|
|
24
|
+
* `undefined` 값은 "not-found 확정"의 네거티브 캐시 항목을 의미합니다.
|
|
18
25
|
*/
|
|
19
26
|
declare class IconCache {
|
|
20
27
|
private static cache;
|
|
@@ -22,7 +29,7 @@ declare class IconCache {
|
|
|
22
29
|
private constructor();
|
|
23
30
|
private static makeKey;
|
|
24
31
|
/**
|
|
25
|
-
* 지정된 lib, name 조합의
|
|
32
|
+
* 지정된 lib, name 조합의 리소스(네거티브 항목 포함)가 존재하는지 확인합니다.
|
|
26
33
|
*/
|
|
27
34
|
static has(lib: string, name: string): boolean;
|
|
28
35
|
/**
|
|
@@ -31,12 +38,15 @@ declare class IconCache {
|
|
|
31
38
|
static get(lib: string, name: string): string | undefined;
|
|
32
39
|
/**
|
|
33
40
|
* 지정된 lib, name 조합의 리소스를 저장합니다.
|
|
41
|
+
* `undefined`를 저장하면 not-found 확정(네거티브 캐시)으로 기록됩니다.
|
|
34
42
|
*/
|
|
35
|
-
static set(lib: string, name: string, resource: string): void;
|
|
43
|
+
static set(lib: string, name: string, resource: string | undefined): void;
|
|
36
44
|
/**
|
|
37
|
-
*
|
|
45
|
+
* 캐시를 비웁니다.
|
|
46
|
+
*
|
|
47
|
+
* @param lib - 지정하면 해당 라이브러리의 항목만, 생략하면 전체를 비웁니다.
|
|
38
48
|
*/
|
|
39
|
-
static clear(): void;
|
|
49
|
+
static clear(lib?: string): void;
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* 아이콘 레지스트리입니다.
|
|
@@ -44,6 +54,8 @@ declare class IconCache {
|
|
|
44
54
|
*/
|
|
45
55
|
declare class IconRegistry {
|
|
46
56
|
private static libs;
|
|
57
|
+
/** 동일 (lib, name)의 동시 리졸브를 하나의 요청으로 합치는 in-flight 맵입니다. */
|
|
58
|
+
private static pending;
|
|
47
59
|
/** 클래스 생성 방지 */
|
|
48
60
|
private constructor();
|
|
49
61
|
/**
|
|
@@ -57,11 +69,33 @@ declare class IconRegistry {
|
|
|
57
69
|
static register(lib: string, resolver: IconResolver): void;
|
|
58
70
|
/**
|
|
59
71
|
* 지정된 아이콘 라이브러리를 등록 해제합니다.
|
|
72
|
+
* 해당 라이브러리의 캐시 항목도 함께 비웁니다 —
|
|
73
|
+
* `unregister` 후 다른 리졸버를 재등록할 때 이전 결과가 남지 않도록 하기 위함입니다.
|
|
60
74
|
*/
|
|
61
75
|
static unregister(lib: string): void;
|
|
62
76
|
/**
|
|
63
77
|
* 지정된 아이콘 라이브러리의 svg 아이콘 소스를 가져옵니다.
|
|
78
|
+
*
|
|
79
|
+
* 리졸브 결과는 (lib, name) 단위로 캐시됩니다 — 스트리밍 재렌더처럼 같은 아이콘이
|
|
80
|
+
* 반복 재마운트되는 시나리오에서도 리졸버는 이름당 한 번만 호출됩니다.
|
|
81
|
+
* 동시 요청은 in-flight Promise를 공유하고, `IconResolver` 계약에 따라
|
|
82
|
+
* `undefined`(not-found 확정)는 네거티브 캐시, `throw`(일시 오류)는 캐시하지 않습니다.
|
|
64
83
|
*/
|
|
65
84
|
static resolve(lib: string, name: string): Promise<string | undefined>;
|
|
85
|
+
/**
|
|
86
|
+
* URL에서 직접 svg 아이콘 소스를 가져옵니다.
|
|
87
|
+
* `u-icon`의 `src` 경로와 무-lib 기본(baseUrl) 경로가 사용하는 저수준 진입점입니다.
|
|
88
|
+
*
|
|
89
|
+
* 결과는 예약 네임스페이스 `url`로 `IconCache`에 캐시되어 같은 URL은 세션당
|
|
90
|
+
* 한 번만 fetch됩니다. HTTP 실패(404 등)는 not-found로 네거티브 캐시되고,
|
|
91
|
+
* 네트워크 오류는 캐시하지 않아 다음 조회 시 재시도됩니다.
|
|
92
|
+
*/
|
|
93
|
+
static resolveUrl(url: string): Promise<string | undefined>;
|
|
94
|
+
/**
|
|
95
|
+
* 캐시 기록 + in-flight dedupe + 실패 시멘틱을 공유하는 리졸브 코어입니다.
|
|
96
|
+
* catch를 task 내부에 두어, in-flight를 공유한 모든 호출자가 rejection 대신
|
|
97
|
+
* undefined를 받도록 한다 (렌더 경로로 rejection이 전파되면 안 됨).
|
|
98
|
+
*/
|
|
99
|
+
private static resolveCached;
|
|
66
100
|
}
|
|
67
101
|
export { getDefaultBaseUrl, setDefaultBaseUrl, IconCache, IconRegistry, };
|
package/dist/utilities/icons.js
CHANGED
|
@@ -63,6 +63,7 @@ var InternalIconBundle = new Map(Object.entries(/* #__PURE__ */ Object.assign({
|
|
|
63
63
|
/**
|
|
64
64
|
* 아이콘 리소스 캐시입니다.
|
|
65
65
|
* lib + name 조합으로 SVG 리소스를 저장/조회합니다.
|
|
66
|
+
* `undefined` 값은 "not-found 확정"의 네거티브 캐시 항목을 의미합니다.
|
|
66
67
|
*/
|
|
67
68
|
var IconCache = class {
|
|
68
69
|
static {
|
|
@@ -74,7 +75,7 @@ var IconCache = class {
|
|
|
74
75
|
return `${lib}:${name}`;
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
|
-
* 지정된 lib, name 조합의
|
|
78
|
+
* 지정된 lib, name 조합의 리소스(네거티브 항목 포함)가 존재하는지 확인합니다.
|
|
78
79
|
*/
|
|
79
80
|
static has(lib, name) {
|
|
80
81
|
return this.cache.has(this.makeKey(lib, name));
|
|
@@ -87,15 +88,23 @@ var IconCache = class {
|
|
|
87
88
|
}
|
|
88
89
|
/**
|
|
89
90
|
* 지정된 lib, name 조합의 리소스를 저장합니다.
|
|
91
|
+
* `undefined`를 저장하면 not-found 확정(네거티브 캐시)으로 기록됩니다.
|
|
90
92
|
*/
|
|
91
93
|
static set(lib, name, resource) {
|
|
92
94
|
this.cache.set(this.makeKey(lib, name), resource);
|
|
93
95
|
}
|
|
94
96
|
/**
|
|
95
|
-
*
|
|
97
|
+
* 캐시를 비웁니다.
|
|
98
|
+
*
|
|
99
|
+
* @param lib - 지정하면 해당 라이브러리의 항목만, 생략하면 전체를 비웁니다.
|
|
96
100
|
*/
|
|
97
|
-
static clear() {
|
|
98
|
-
|
|
101
|
+
static clear(lib) {
|
|
102
|
+
if (lib === void 0) {
|
|
103
|
+
this.cache.clear();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const prefix = `${lib}:`;
|
|
107
|
+
for (const key of this.cache.keys()) if (key.startsWith(prefix)) this.cache.delete(key);
|
|
99
108
|
}
|
|
100
109
|
};
|
|
101
110
|
/**
|
|
@@ -106,6 +115,9 @@ var IconRegistry = class {
|
|
|
106
115
|
static {
|
|
107
116
|
this.libs = /* @__PURE__ */ new Map();
|
|
108
117
|
}
|
|
118
|
+
static {
|
|
119
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
120
|
+
}
|
|
109
121
|
/** 클래스 생성 방지 */
|
|
110
122
|
constructor() {}
|
|
111
123
|
/**
|
|
@@ -123,75 +135,95 @@ var IconRegistry = class {
|
|
|
123
135
|
}
|
|
124
136
|
/**
|
|
125
137
|
* 지정된 아이콘 라이브러리를 등록 해제합니다.
|
|
138
|
+
* 해당 라이브러리의 캐시 항목도 함께 비웁니다 —
|
|
139
|
+
* `unregister` 후 다른 리졸버를 재등록할 때 이전 결과가 남지 않도록 하기 위함입니다.
|
|
126
140
|
*/
|
|
127
141
|
static unregister(lib) {
|
|
128
142
|
this.libs.delete(lib);
|
|
143
|
+
IconCache.clear(lib);
|
|
129
144
|
}
|
|
130
145
|
/**
|
|
131
146
|
* 지정된 아이콘 라이브러리의 svg 아이콘 소스를 가져옵니다.
|
|
147
|
+
*
|
|
148
|
+
* 리졸브 결과는 (lib, name) 단위로 캐시됩니다 — 스트리밍 재렌더처럼 같은 아이콘이
|
|
149
|
+
* 반복 재마운트되는 시나리오에서도 리졸버는 이름당 한 번만 호출됩니다.
|
|
150
|
+
* 동시 요청은 in-flight Promise를 공유하고, `IconResolver` 계약에 따라
|
|
151
|
+
* `undefined`(not-found 확정)는 네거티브 캐시, `throw`(일시 오류)는 캐시하지 않습니다.
|
|
132
152
|
*/
|
|
133
153
|
static async resolve(lib, name) {
|
|
154
|
+
if (IconCache.has(lib, name)) return IconCache.get(lib, name);
|
|
134
155
|
const resolver = this.libs.get(lib);
|
|
135
156
|
if (!resolver) return void 0;
|
|
136
|
-
return (
|
|
157
|
+
return this.resolveCached(lib, name, () => resolver(name));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* URL에서 직접 svg 아이콘 소스를 가져옵니다.
|
|
161
|
+
* `u-icon`의 `src` 경로와 무-lib 기본(baseUrl) 경로가 사용하는 저수준 진입점입니다.
|
|
162
|
+
*
|
|
163
|
+
* 결과는 예약 네임스페이스 `url`로 `IconCache`에 캐시되어 같은 URL은 세션당
|
|
164
|
+
* 한 번만 fetch됩니다. HTTP 실패(404 등)는 not-found로 네거티브 캐시되고,
|
|
165
|
+
* 네트워크 오류는 캐시하지 않아 다음 조회 시 재시도됩니다.
|
|
166
|
+
*/
|
|
167
|
+
static async resolveUrl(url) {
|
|
168
|
+
if (IconCache.has("url", url)) return IconCache.get("url", url);
|
|
169
|
+
return this.resolveCached("url", url, async () => {
|
|
170
|
+
const response = await fetch(url);
|
|
171
|
+
if (!response.ok) return void 0;
|
|
172
|
+
return await response.text();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 캐시 기록 + in-flight dedupe + 실패 시멘틱을 공유하는 리졸브 코어입니다.
|
|
177
|
+
* catch를 task 내부에 두어, in-flight를 공유한 모든 호출자가 rejection 대신
|
|
178
|
+
* undefined를 받도록 한다 (렌더 경로로 rejection이 전파되면 안 됨).
|
|
179
|
+
*/
|
|
180
|
+
static resolveCached(lib, name, fn) {
|
|
181
|
+
const key = `${lib}:${name}`;
|
|
182
|
+
const inflight = this.pending.get(key);
|
|
183
|
+
if (inflight) return inflight;
|
|
184
|
+
const task = (async () => {
|
|
185
|
+
try {
|
|
186
|
+
const svg = (await fn())?.trim();
|
|
187
|
+
IconCache.set(lib, name, svg);
|
|
188
|
+
return svg;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error(`[IconRegistry] '${key}' 아이콘 리졸브 실패 (재시도 가능):`, error);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
this.pending.set(key, task);
|
|
195
|
+
task.finally(() => this.pending.delete(key));
|
|
196
|
+
return task;
|
|
137
197
|
}
|
|
138
198
|
};
|
|
139
199
|
IconRegistry.register("internal", (name) => {
|
|
140
200
|
return InternalIconBundle.get(name);
|
|
141
201
|
});
|
|
142
202
|
IconRegistry.register("tabler", async (name) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!response.ok) return void 0;
|
|
149
|
-
const svg = await response.text();
|
|
150
|
-
IconCache.set("tabler", name, svg);
|
|
151
|
-
return svg;
|
|
152
|
-
} catch {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
203
|
+
const [iconName, style = "outline"] = name.split(":");
|
|
204
|
+
const url = `https://cdn.jsdelivr.net/npm/@tabler/icons@3.40.0/icons/${style}/${iconName}.svg`;
|
|
205
|
+
const response = await fetch(url);
|
|
206
|
+
if (!response.ok) return void 0;
|
|
207
|
+
return await response.text();
|
|
155
208
|
});
|
|
156
209
|
IconRegistry.register("heroicons", async (name) => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!response.ok) return void 0;
|
|
163
|
-
const svg = await response.text();
|
|
164
|
-
IconCache.set("heroicons", name, svg);
|
|
165
|
-
return svg;
|
|
166
|
-
} catch {
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
210
|
+
const [iconName, style = "outline", size = "24"] = name.split(":");
|
|
211
|
+
const url = `https://cdn.jsdelivr.net/npm/heroicons@2.2.0/${size}/${style}/${iconName}.svg`;
|
|
212
|
+
const response = await fetch(url);
|
|
213
|
+
if (!response.ok) return void 0;
|
|
214
|
+
return await response.text();
|
|
169
215
|
});
|
|
170
216
|
IconRegistry.register("lucide", async (name) => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!response.ok) return void 0;
|
|
176
|
-
const svg = await response.text();
|
|
177
|
-
IconCache.set("lucide", name, svg);
|
|
178
|
-
return svg;
|
|
179
|
-
} catch {
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
217
|
+
const url = `https://cdn.jsdelivr.net/npm/lucide-static@0.577.0/icons/${name}.svg`;
|
|
218
|
+
const response = await fetch(url);
|
|
219
|
+
if (!response.ok) return void 0;
|
|
220
|
+
return await response.text();
|
|
182
221
|
});
|
|
183
222
|
IconRegistry.register("bootstrap", async (name) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!response.ok) return void 0;
|
|
189
|
-
const svg = await response.text();
|
|
190
|
-
IconCache.set("bootstrap", name, svg);
|
|
191
|
-
return svg;
|
|
192
|
-
} catch (error) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
223
|
+
const url = `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/${name}.svg`;
|
|
224
|
+
const response = await fetch(url);
|
|
225
|
+
if (!response.ok) return void 0;
|
|
226
|
+
return await response.text();
|
|
195
227
|
});
|
|
196
228
|
//#endregion
|
|
197
229
|
export { IconCache, IconRegistry, getDefaultBaseUrl, setDefaultBaseUrl };
|
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.
|
|
4
|
+
"version": "1.8.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"components",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"start": "vite --force",
|
|
43
43
|
"test": "vitest run",
|
|
44
44
|
"test:browser": "vitest run --project=browser",
|
|
45
|
+
"lint": "eslint src/",
|
|
46
|
+
"lint:fix": "eslint src/ --fix",
|
|
45
47
|
"build:plugins": "tsc -p plugins/tsconfig.json",
|
|
46
48
|
"build": "eslint && vite build && npm run build:plugins"
|
|
47
49
|
},
|
|
@@ -24,14 +24,20 @@ IconRegistry.register('my-icons', async (name: string) => {
|
|
|
24
24
|
});
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Resolver contract — the registry owns caching, so a resolver is a pure lookup:
|
|
28
|
+
return `string` = success (cached) · return `undefined` = definitive not-found (negative-cached, not retried) · `throw` = transient error (not cached, retried on next lookup).
|
|
29
|
+
|
|
30
|
+
`register()` ignores an already-registered `lib` name. To override a built-in library (e.g. air-gapped local bundle), call `unregister(lib)` first — this also evicts that library's cache.
|
|
31
|
+
|
|
27
32
|
## API
|
|
28
33
|
|
|
29
34
|
| Method | Description |
|
|
30
35
|
|--------|-------------|
|
|
31
|
-
| `IconRegistry.register(lib, resolver)` | Register an icon library |
|
|
32
|
-
| `IconRegistry.unregister(lib)` | Remove a library |
|
|
36
|
+
| `IconRegistry.register(lib, resolver)` | Register an icon library (ignored if `lib` already registered) |
|
|
37
|
+
| `IconRegistry.unregister(lib)` | Remove a library + evict its cached entries |
|
|
33
38
|
| `IconRegistry.has(lib)` | Check if a library is registered |
|
|
34
|
-
| `IconRegistry.resolve(lib, name)` | `Promise<string \| undefined>` — resolve SVG source |
|
|
39
|
+
| `IconRegistry.resolve(lib, name)` | `Promise<string \| undefined>` — resolve SVG source (cached + in-flight dedupe) |
|
|
40
|
+
| `IconRegistry.resolveUrl(url)` | `Promise<string \| undefined>` — fetch SVG by URL, cached under the reserved `url` namespace (used by `u-icon` `src`/base-URL paths) |
|
|
35
41
|
|
|
36
42
|
## Built-in Libraries
|
|
37
43
|
|
|
@@ -40,14 +46,15 @@ IconRegistry.register('my-icons', async (name: string) => {
|
|
|
40
46
|
| `internal` | (bundled) | — |
|
|
41
47
|
| `tabler` | `name` / `name:filled` | 3.40.0 |
|
|
42
48
|
| `heroicons` | `name` / `name:solid` | 2.2.0 |
|
|
43
|
-
| `lucide` | `name` | 0.
|
|
44
|
-
| `bootstrap` | `name` / `name:filled` | 1.
|
|
49
|
+
| `lucide` | `name` | 0.577.0 |
|
|
50
|
+
| `bootstrap` | `name` / `name:filled` | 1.13.1 |
|
|
45
51
|
|
|
46
52
|
## Caching
|
|
47
53
|
|
|
48
|
-
Resolved SVGs are cached by `lib:name` key. The cache persists for the lifetime of the page.
|
|
54
|
+
Resolved SVGs are cached by `lib:name` key (owned by `IconRegistry.resolve()` — resolvers are called once per name per session, concurrent lookups share one in-flight request). Not-found results are negative-cached; transient errors (`throw`) are retried. The cache persists for the lifetime of the page.
|
|
49
55
|
|
|
50
56
|
```ts
|
|
51
57
|
// Internal cache is managed automatically; manual access is rarely needed.
|
|
52
|
-
// IconCache.clear();
|
|
58
|
+
// IconCache.clear(); // clears all cached entries
|
|
59
|
+
// IconCache.clear('tabler'); // clears one library's entries
|
|
53
60
|
```
|