@roottale/cms-media 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.
- package/README.md +40 -2
- package/dist/delivery.d.ts +51 -0
- package/dist/delivery.js +78 -0
- package/dist/delivery.js.map +1 -0
- package/dist/react.js +144 -0
- package/dist/react.js.map +1 -1
- package/package.json +6 -1
- package/dist/react.d.ts +0 -58
package/README.md
CHANGED
|
@@ -2,10 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
RootTale 미디어 패키지 — **R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트**. (ADR-0034 §3, `docs/image-standard.md`)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
엔트리:
|
|
6
6
|
|
|
7
7
|
- `@roottale/cms-media` — 서버(R2/S3 클라이언트, variant URL 생성, 파일명 정규화). Node.
|
|
8
|
-
- `@roottale/cms-media/react` —
|
|
8
|
+
- `@roottale/cms-media/react` — `RootTaleImage`·기존 `CfImage` 렌더 컴포넌트(`"use client"`). react는 optional peer.
|
|
9
|
+
- `@roottale/cms-media/delivery` — React·R2/S3 클라이언트 없는 서버/브라우저 공용 표시 주소 처리.
|
|
10
|
+
|
|
11
|
+
## RootTaleImage (`@roottale/cms-media/react`)
|
|
12
|
+
|
|
13
|
+
사이트 공용 기본 이미지 컴포넌트다. 추가 래퍼·CSS·로더를 만들지 않고 네이티브 `img`를 출력한다. 기존 `CfImage`와 default export는 호환성을 위해 유지한다.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { RootTaleImage } from "@roottale/cms-media/react";
|
|
17
|
+
|
|
18
|
+
<RootTaleImage src={coverUrl} alt="시설 사진" width={800} height={556}
|
|
19
|
+
sizes="(max-width: 880px) calc(100vw - 36px), 840px" priority />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- CF 공개 주소는 폭 후보·품질 75·`fit=scale-down`으로 브라우저가 직접 받는다. 해당 계정의 Flexible variants를 활성화해야 한다. 알려진 원본 폭과 최대 후보 폭까지 요청하고 자르기·확대는 하지 않는다.
|
|
23
|
+
- `src`는 문자열 또는 `{ src, width, height }` 정적 import 메타데이터다. `ref`는 실제 img를 가리킨다. `fill`·`sizes`·`style`·네이티브 이벤트·접근성 속성을 유지한다.
|
|
24
|
+
- 기본 lazy/async, `priority`는 eager/high, `preload`는 React 19의 반응형 미리 읽기를 사용한다. React 18.3에서는 eager/high로 처리한다.
|
|
25
|
+
- `accountHash`로 사이트 계정만 변환할 수 있다. SVG/GIF·서명 URL·미등록 R2/외부 주소는 그대로 표시한다. 콘텐츠의 신규 등록·CMS 쓰기는 이 컴포넌트의 책임이 아니다.
|
|
26
|
+
- `fallbackSrc`는 CF 오류 때 한 번 표시할 R2/로컬 원본이다. 없으면 `src`를 사용한다. 대체도 실패하면 기존 `onError`로 이어지며 새 src는 다시 시도한다. picture의 `data-image-fallback-src`/`data-original-image-src`도 복원한다.
|
|
27
|
+
- 커스텀 `srcSet`은 그대로 유지한다. 사이트가 미리 계산한 `imageAttributes`는 이를 우선하며 화면에 넘길 최적화 결과만 받는다.
|
|
28
|
+
|
|
29
|
+
등록된 R2·정적 파일은 사이트가 매핑을 소유한다. 원본 CMS 데이터·주소·바이트는 유지하고, 공용 헬퍼로 표시 속성만 계산한다. 전체 본문 매핑을 브라우저에 넣지 않도록 서버에서도 같은 헬퍼를 사용할 수 있다.
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { createImageDelivery } from "@roottale/cms-media/delivery";
|
|
33
|
+
import { RootTaleImage } from "@roottale/cms-media/react";
|
|
34
|
+
|
|
35
|
+
const images = createImageDelivery({
|
|
36
|
+
accountHash: "사이트 CF account hash",
|
|
37
|
+
registry: registeredAssets, // 정확한 원본 주소 → { delivery, original, width, height }
|
|
38
|
+
assetOrigins: ["https://site.example"],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
<RootTaleImage src={originalUrl} alt="시설 사진"
|
|
42
|
+
imageAttributes={images.imageAttributes(originalUrl, "800px")}
|
|
43
|
+
fallbackSrc={images.imageFallback(originalUrl)} />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
매핑은 다른 CF 계정으로 넘어가지 않으며, 등록되지 않은 원본 주소를 추측하지 않는다. `createImageDelivery`는 업로드·네트워크·환경 변수 접근이 없는 순수 URL 처리다. 공용 패키지에 고객 이미지 목록을 넣지 않는다. `next/image`와 `/_next/image`를 사용하지 않는다.
|
|
9
47
|
|
|
10
48
|
## CfImage (`@roottale/cms-media/react`)
|
|
11
49
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Browser/server URL helpers. No React, upload clients, environment access or network requests. */
|
|
2
|
+
interface StaticImageSource {
|
|
3
|
+
src: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
}
|
|
7
|
+
type ImageSource = string | StaticImageSource;
|
|
8
|
+
interface RegisteredImage {
|
|
9
|
+
delivery: string;
|
|
10
|
+
original?: string;
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
type ImageDeliveryRegistry = Readonly<Record<string, RegisteredImage>>;
|
|
15
|
+
interface ImageDeliveryConfig {
|
|
16
|
+
/** Only this CF account is transformed. Omit to accept any public CF Images account. */
|
|
17
|
+
accountHash?: string;
|
|
18
|
+
registry?: ImageDeliveryRegistry;
|
|
19
|
+
/** Site origins whose public paths use the same local registry. */
|
|
20
|
+
assetOrigins?: readonly string[];
|
|
21
|
+
widths?: readonly number[];
|
|
22
|
+
quality?: number;
|
|
23
|
+
}
|
|
24
|
+
interface ImageAttributes {
|
|
25
|
+
src: string;
|
|
26
|
+
srcSet?: string;
|
|
27
|
+
sizes?: string;
|
|
28
|
+
}
|
|
29
|
+
declare const IMAGE_WIDTHS: readonly number[];
|
|
30
|
+
declare function imageSource(source: ImageSource): string;
|
|
31
|
+
/** Signed URLs, credentials and host lookalikes are never rewritten. Custom image IDs may contain '/'. */
|
|
32
|
+
declare function cfImageBase(src: string, accountHash?: string): string | null;
|
|
33
|
+
/** A site's asset mapping stays outside the component and is never written back to the CMS. */
|
|
34
|
+
declare function createImageDelivery(config?: ImageDeliveryConfig): {
|
|
35
|
+
imageDelivery: (source: ImageSource) => {
|
|
36
|
+
base: string;
|
|
37
|
+
delivery: string;
|
|
38
|
+
original?: string;
|
|
39
|
+
width?: number;
|
|
40
|
+
height?: number;
|
|
41
|
+
} | {
|
|
42
|
+
base: string;
|
|
43
|
+
width: undefined;
|
|
44
|
+
original: undefined;
|
|
45
|
+
} | null;
|
|
46
|
+
imageFallback: (source: ImageSource) => string;
|
|
47
|
+
imageUrl: (source: ImageSource, requestedWidth: number) => string;
|
|
48
|
+
imageAttributes: (source: ImageSource, sizes?: string) => ImageAttributes;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { IMAGE_WIDTHS, type ImageAttributes, type ImageDeliveryConfig, type ImageDeliveryRegistry, type ImageSource, type RegisteredImage, type StaticImageSource, cfImageBase, createImageDelivery, imageSource };
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/delivery.ts
|
|
2
|
+
var IMAGE_WIDTHS = [32, 48, 64, 96, 128, 256, 320, 384, 640, 768, 960, 1200, 1600, 1920, 2560];
|
|
3
|
+
function imageSource(source) {
|
|
4
|
+
return typeof source === "string" ? source : source.src;
|
|
5
|
+
}
|
|
6
|
+
function cfImageBase(src, accountHash) {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(src);
|
|
9
|
+
if (url.protocol !== "https:" || url.hostname !== "imagedelivery.net" || url.port || url.username || url.password || url.search || url.hash) return null;
|
|
10
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
11
|
+
if (segments.length < 3 || accountHash && segments[0] !== accountHash) return null;
|
|
12
|
+
return `${url.origin}/${segments.slice(0, -1).join("/")}`;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function createImageDelivery(config = {}) {
|
|
18
|
+
const { accountHash, registry = {}, assetOrigins = [], quality = 75 } = config;
|
|
19
|
+
const widths = [...new Set(config.widths ?? IMAGE_WIDTHS)].sort((a, b) => a - b);
|
|
20
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100 || !widths.length || widths.some((width) => !Number.isSafeInteger(width) || width < 1)) {
|
|
21
|
+
throw new RangeError("Image quality must be 1\u2013100 and widths must be positive integers.");
|
|
22
|
+
}
|
|
23
|
+
const largestWidth = widths[widths.length - 1];
|
|
24
|
+
const urlFor = (base, width) => `${base}/width=${width},quality=${quality},fit=scale-down`;
|
|
25
|
+
function imageDelivery(source) {
|
|
26
|
+
const src = imageSource(source);
|
|
27
|
+
if (!src || /[\s\\]/.test(src) || src.startsWith("//") || /\.(svg|gif)(?:[?#]|$)/i.test(src) || src.startsWith("/_next/image")) return null;
|
|
28
|
+
let assetPath;
|
|
29
|
+
if (!src.startsWith("/")) {
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(src);
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
|
|
33
|
+
if (assetOrigins.includes(url.origin)) assetPath = url.pathname;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const base = cfImageBase(src, accountHash);
|
|
39
|
+
const registered = registry[src] ?? (base ? registry[base] : void 0) ?? (assetPath ? registry[assetPath] : void 0);
|
|
40
|
+
if (registered) {
|
|
41
|
+
const deliveryBase = cfImageBase(registered.delivery, accountHash);
|
|
42
|
+
return deliveryBase ? { ...registered, base: deliveryBase } : null;
|
|
43
|
+
}
|
|
44
|
+
return base ? { base, width: void 0, original: void 0 } : null;
|
|
45
|
+
}
|
|
46
|
+
function imageFallback(source) {
|
|
47
|
+
const original = imageSource(source);
|
|
48
|
+
return original.startsWith("/") ? original : imageDelivery(source)?.original ?? original;
|
|
49
|
+
}
|
|
50
|
+
function imageUrl(source, requestedWidth) {
|
|
51
|
+
const delivery = imageDelivery(source);
|
|
52
|
+
if (!delivery) return imageSource(source);
|
|
53
|
+
const width = widths.find((size) => size >= requestedWidth) ?? largestWidth;
|
|
54
|
+
const sourceWidth = delivery.width ?? (typeof source === "string" ? void 0 : source.width);
|
|
55
|
+
return urlFor(delivery.base, Math.min(width, sourceWidth || width));
|
|
56
|
+
}
|
|
57
|
+
function imageAttributes(source, sizes = "100vw") {
|
|
58
|
+
const delivery = imageDelivery(source);
|
|
59
|
+
if (!delivery) return { src: imageSource(source) };
|
|
60
|
+
const sourceWidth = delivery.width ?? (typeof source === "string" ? void 0 : source.width);
|
|
61
|
+
const limit = sourceWidth && sourceWidth > 0 ? Math.min(sourceWidth, largestWidth) : largestWidth;
|
|
62
|
+
const candidates = widths.filter((width) => width < limit);
|
|
63
|
+
candidates.push(limit);
|
|
64
|
+
return {
|
|
65
|
+
src: urlFor(delivery.base, Math.min(widths.find((width) => width >= 1600) ?? largestWidth, limit)),
|
|
66
|
+
srcSet: candidates.map((width) => `${urlFor(delivery.base, width)} ${width}w`).join(", "),
|
|
67
|
+
sizes
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { imageDelivery, imageFallback, imageUrl, imageAttributes };
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
IMAGE_WIDTHS,
|
|
74
|
+
cfImageBase,
|
|
75
|
+
createImageDelivery,
|
|
76
|
+
imageSource
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=delivery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/delivery.ts"],"sourcesContent":["/** Browser/server URL helpers. No React, upload clients, environment access or network requests. */\nexport interface StaticImageSource {\n src: string;\n width?: number;\n height?: number;\n}\n\nexport type ImageSource = string | StaticImageSource;\n\nexport interface RegisteredImage {\n delivery: string;\n original?: string;\n width?: number;\n height?: number;\n}\n\nexport type ImageDeliveryRegistry = Readonly<Record<string, RegisteredImage>>;\n\nexport interface ImageDeliveryConfig {\n /** Only this CF account is transformed. Omit to accept any public CF Images account. */\n accountHash?: string;\n registry?: ImageDeliveryRegistry;\n /** Site origins whose public paths use the same local registry. */\n assetOrigins?: readonly string[];\n widths?: readonly number[];\n quality?: number;\n}\n\nexport interface ImageAttributes {\n src: string;\n srcSet?: string;\n sizes?: string;\n}\n\nexport const IMAGE_WIDTHS: readonly number[] = [32, 48, 64, 96, 128, 256, 320, 384, 640, 768, 960, 1200, 1600, 1920, 2560];\n\nexport function imageSource(source: ImageSource): string {\n return typeof source === \"string\" ? source : source.src;\n}\n\n/** Signed URLs, credentials and host lookalikes are never rewritten. Custom image IDs may contain '/'. */\nexport function cfImageBase(src: string, accountHash?: string): string | null {\n try {\n const url = new URL(src);\n if (url.protocol !== \"https:\" || url.hostname !== \"imagedelivery.net\" || url.port || url.username || url.password || url.search || url.hash) return null;\n const segments = url.pathname.split(\"/\").filter(Boolean);\n if (segments.length < 3 || (accountHash && segments[0] !== accountHash)) return null;\n return `${url.origin}/${segments.slice(0, -1).join(\"/\")}`;\n } catch { return null; }\n}\n\n/** A site's asset mapping stays outside the component and is never written back to the CMS. */\nexport function createImageDelivery(config: ImageDeliveryConfig = {}) {\n const { accountHash, registry = {}, assetOrigins = [], quality = 75 } = config;\n const widths = [...new Set(config.widths ?? IMAGE_WIDTHS)].sort((a, b) => a - b);\n if (!Number.isInteger(quality) || quality < 1 || quality > 100 || !widths.length || widths.some(width => !Number.isSafeInteger(width) || width < 1)) {\n throw new RangeError(\"Image quality must be 1–100 and widths must be positive integers.\");\n }\n const largestWidth = widths[widths.length - 1]!;\n const urlFor = (base: string, width: number) => `${base}/width=${width},quality=${quality},fit=scale-down`;\n\n function imageDelivery(source: ImageSource) {\n const src = imageSource(source);\n if (!src || /[\\s\\\\]/.test(src) || src.startsWith(\"//\") || /\\.(svg|gif)(?:[?#]|$)/i.test(src) || src.startsWith(\"/_next/image\")) return null;\n let assetPath: string | undefined;\n if (!src.startsWith(\"/\")) {\n try {\n const url = new URL(src);\n if (![\"http:\", \"https:\"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;\n if (assetOrigins.includes(url.origin)) assetPath = url.pathname;\n } catch { return null; }\n }\n const base = cfImageBase(src, accountHash);\n const registered = registry[src] ?? (base ? registry[base] : undefined) ?? (assetPath ? registry[assetPath] : undefined);\n if (registered) {\n const deliveryBase = cfImageBase(registered.delivery, accountHash);\n return deliveryBase ? { ...registered, base: deliveryBase } : null;\n }\n return base ? { base, width: undefined, original: undefined } : null;\n }\n\n function imageFallback(source: ImageSource): string {\n const original = imageSource(source);\n return original.startsWith(\"/\") ? original : imageDelivery(source)?.original ?? original;\n }\n\n function imageUrl(source: ImageSource, requestedWidth: number): string {\n const delivery = imageDelivery(source);\n if (!delivery) return imageSource(source);\n const width = widths.find(size => size >= requestedWidth) ?? largestWidth;\n const sourceWidth = delivery.width ?? (typeof source === \"string\" ? undefined : source.width);\n return urlFor(delivery.base, Math.min(width, sourceWidth || width));\n }\n\n function imageAttributes(source: ImageSource, sizes = \"100vw\"): ImageAttributes {\n const delivery = imageDelivery(source);\n if (!delivery) return { src: imageSource(source) };\n const sourceWidth = delivery.width ?? (typeof source === \"string\" ? undefined : source.width);\n const limit = sourceWidth && sourceWidth > 0 ? Math.min(sourceWidth, largestWidth) : largestWidth;\n const candidates = widths.filter(width => width < limit);\n candidates.push(limit);\n return {\n src: urlFor(delivery.base, Math.min(widths.find(width => width >= 1600) ?? largestWidth, limit)),\n srcSet: candidates.map(width => `${urlFor(delivery.base, width)} ${width}w`).join(\", \"),\n sizes,\n };\n }\n\n return { imageDelivery, imageFallback, imageUrl, imageAttributes };\n}\n"],"mappings":"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","names":[]}
|
package/dist/react.js
CHANGED
|
@@ -213,8 +213,152 @@ function CfImage({
|
|
|
213
213
|
] });
|
|
214
214
|
}
|
|
215
215
|
var CfImage_default = CfImage;
|
|
216
|
+
|
|
217
|
+
// src/react/RootTaleImage.tsx
|
|
218
|
+
import { forwardRef, useState as useState2 } from "react";
|
|
219
|
+
import * as ReactDOM from "react-dom";
|
|
220
|
+
|
|
221
|
+
// src/delivery.ts
|
|
222
|
+
var IMAGE_WIDTHS = [32, 48, 64, 96, 128, 256, 320, 384, 640, 768, 960, 1200, 1600, 1920, 2560];
|
|
223
|
+
function imageSource(source) {
|
|
224
|
+
return typeof source === "string" ? source : source.src;
|
|
225
|
+
}
|
|
226
|
+
function cfImageBase(src, accountHash) {
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(src);
|
|
229
|
+
if (url.protocol !== "https:" || url.hostname !== "imagedelivery.net" || url.port || url.username || url.password || url.search || url.hash) return null;
|
|
230
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
231
|
+
if (segments.length < 3 || accountHash && segments[0] !== accountHash) return null;
|
|
232
|
+
return `${url.origin}/${segments.slice(0, -1).join("/")}`;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function createImageDelivery(config = {}) {
|
|
238
|
+
const { accountHash, registry = {}, assetOrigins = [], quality = 75 } = config;
|
|
239
|
+
const widths = [...new Set(config.widths ?? IMAGE_WIDTHS)].sort((a, b) => a - b);
|
|
240
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100 || !widths.length || widths.some((width) => !Number.isSafeInteger(width) || width < 1)) {
|
|
241
|
+
throw new RangeError("Image quality must be 1\u2013100 and widths must be positive integers.");
|
|
242
|
+
}
|
|
243
|
+
const largestWidth = widths[widths.length - 1];
|
|
244
|
+
const urlFor = (base, width) => `${base}/width=${width},quality=${quality},fit=scale-down`;
|
|
245
|
+
function imageDelivery(source) {
|
|
246
|
+
const src = imageSource(source);
|
|
247
|
+
if (!src || /[\s\\]/.test(src) || src.startsWith("//") || /\.(svg|gif)(?:[?#]|$)/i.test(src) || src.startsWith("/_next/image")) return null;
|
|
248
|
+
let assetPath;
|
|
249
|
+
if (!src.startsWith("/")) {
|
|
250
|
+
try {
|
|
251
|
+
const url = new URL(src);
|
|
252
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
|
|
253
|
+
if (assetOrigins.includes(url.origin)) assetPath = url.pathname;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const base = cfImageBase(src, accountHash);
|
|
259
|
+
const registered = registry[src] ?? (base ? registry[base] : void 0) ?? (assetPath ? registry[assetPath] : void 0);
|
|
260
|
+
if (registered) {
|
|
261
|
+
const deliveryBase = cfImageBase(registered.delivery, accountHash);
|
|
262
|
+
return deliveryBase ? { ...registered, base: deliveryBase } : null;
|
|
263
|
+
}
|
|
264
|
+
return base ? { base, width: void 0, original: void 0 } : null;
|
|
265
|
+
}
|
|
266
|
+
function imageFallback(source) {
|
|
267
|
+
const original = imageSource(source);
|
|
268
|
+
return original.startsWith("/") ? original : imageDelivery(source)?.original ?? original;
|
|
269
|
+
}
|
|
270
|
+
function imageUrl(source, requestedWidth) {
|
|
271
|
+
const delivery = imageDelivery(source);
|
|
272
|
+
if (!delivery) return imageSource(source);
|
|
273
|
+
const width = widths.find((size) => size >= requestedWidth) ?? largestWidth;
|
|
274
|
+
const sourceWidth = delivery.width ?? (typeof source === "string" ? void 0 : source.width);
|
|
275
|
+
return urlFor(delivery.base, Math.min(width, sourceWidth || width));
|
|
276
|
+
}
|
|
277
|
+
function imageAttributes(source, sizes = "100vw") {
|
|
278
|
+
const delivery = imageDelivery(source);
|
|
279
|
+
if (!delivery) return { src: imageSource(source) };
|
|
280
|
+
const sourceWidth = delivery.width ?? (typeof source === "string" ? void 0 : source.width);
|
|
281
|
+
const limit = sourceWidth && sourceWidth > 0 ? Math.min(sourceWidth, largestWidth) : largestWidth;
|
|
282
|
+
const candidates = widths.filter((width) => width < limit);
|
|
283
|
+
candidates.push(limit);
|
|
284
|
+
return {
|
|
285
|
+
src: urlFor(delivery.base, Math.min(widths.find((width) => width >= 1600) ?? largestWidth, limit)),
|
|
286
|
+
srcSet: candidates.map((width) => `${urlFor(delivery.base, width)} ${width}w`).join(", "),
|
|
287
|
+
sizes
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { imageDelivery, imageFallback, imageUrl, imageAttributes };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/react/RootTaleImage.tsx
|
|
294
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
295
|
+
function restorePictureSources(image) {
|
|
296
|
+
for (const source of image.closest("picture")?.querySelectorAll("source[srcset]") ?? []) {
|
|
297
|
+
const fallback = source.dataset.imageFallbackSrc ?? source.dataset.originalImageSrc;
|
|
298
|
+
if (fallback) source.srcset = fallback;
|
|
299
|
+
else source.removeAttribute("srcset");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
var RootTaleImage = forwardRef(function RootTaleImage2({
|
|
303
|
+
src,
|
|
304
|
+
alt,
|
|
305
|
+
fill,
|
|
306
|
+
priority,
|
|
307
|
+
preload: preload2,
|
|
308
|
+
imageAttributes,
|
|
309
|
+
fallbackSrc,
|
|
310
|
+
accountHash,
|
|
311
|
+
sizes = "100vw",
|
|
312
|
+
srcSet,
|
|
313
|
+
width,
|
|
314
|
+
height,
|
|
315
|
+
loading,
|
|
316
|
+
fetchPriority,
|
|
317
|
+
decoding = "async",
|
|
318
|
+
style,
|
|
319
|
+
onError,
|
|
320
|
+
...props
|
|
321
|
+
}, ref) {
|
|
322
|
+
const original = imageSource(src);
|
|
323
|
+
const delivery = createImageDelivery({ accountHash });
|
|
324
|
+
const optimized = imageAttributes ?? (srcSet ? { src: original, srcSet, sizes } : delivery.imageAttributes(src, sizes));
|
|
325
|
+
const fallback = fallbackSrc ?? original;
|
|
326
|
+
const attempt = `${original}
|
|
327
|
+
${optimized.src}
|
|
328
|
+
${fallback}`;
|
|
329
|
+
const [failure, setFailure] = useState2({ attempt, failed: false });
|
|
330
|
+
const failed = failure.attempt === attempt && failure.failed;
|
|
331
|
+
if (failure.attempt !== attempt) setFailure({ attempt, failed: false });
|
|
332
|
+
const attributes = failed ? { src: fallback } : optimized;
|
|
333
|
+
const eager = priority || preload2;
|
|
334
|
+
if (preload2 && typeof ReactDOM.preload === "function") {
|
|
335
|
+
ReactDOM.preload(attributes.src, { as: "image", imageSrcSet: attributes.srcSet, imageSizes: attributes.sizes, fetchPriority: "high" });
|
|
336
|
+
}
|
|
337
|
+
return /* @__PURE__ */ jsx2(
|
|
338
|
+
"img",
|
|
339
|
+
{
|
|
340
|
+
...props,
|
|
341
|
+
...attributes,
|
|
342
|
+
ref,
|
|
343
|
+
alt,
|
|
344
|
+
width: fill ? void 0 : width ?? (typeof src === "string" ? void 0 : src.width),
|
|
345
|
+
height: fill ? void 0 : height ?? (typeof src === "string" ? void 0 : src.height),
|
|
346
|
+
loading: loading ?? (eager ? "eager" : "lazy"),
|
|
347
|
+
fetchPriority: fetchPriority ?? (eager ? "high" : void 0),
|
|
348
|
+
decoding,
|
|
349
|
+
style: fill ? { position: "absolute", inset: 0, width: "100%", height: "100%", ...style } : style,
|
|
350
|
+
onError: (event) => {
|
|
351
|
+
if (!failed && (attributes.srcSet || attributes.src !== fallback)) {
|
|
352
|
+
restorePictureSources(event.currentTarget);
|
|
353
|
+
setFailure({ attempt, failed: true });
|
|
354
|
+
} else onError?.(event);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
});
|
|
216
359
|
export {
|
|
217
360
|
CfImage,
|
|
361
|
+
RootTaleImage,
|
|
218
362
|
cfImageSrcSet,
|
|
219
363
|
cfImageVariant,
|
|
220
364
|
CfImage_default as default,
|
package/dist/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react/CfImage.tsx","../src/variants.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ImgHTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport { cfImageVariant, cfImageSrcSet, isCfImageUrl } from \"../variants.js\";\n\n/**\n * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)\n *\n * 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.\n * 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)\n * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)\n *\n * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.\n * - placeholder(로더): 'blur' | 'skeleton' | 'image' | 'none'. 기본 'blur'.\n * · 'blur'(CF): skeleton shimmer 를 즉시 깔고(=마운트 순간 로딩 인지) 그 위로 CF thumbnail\n * 흐림을 썸네일이 받아지는 순간 페이드인 → 원본 받아지면 둘 다 페이드아웃. blur 썸네일도\n * 네트워크라 받기 전 빈 화면이 생기던 갭을 skeleton 이 메운다. (CF 아니면 skeleton 단독)\n * · 'skeleton': shimmer 단독.\n * · 'image': loaderImage(브랜드 스피너/로고 등)를 가운데 표시.\n * · loader: 임의 ReactNode 를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.\n * - 원본 onLoad 시 opacity 0→1 페이드인.\n *\n * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.\n */\n\nexport type CfImagePlaceholder = \"blur\" | \"skeleton\" | \"image\" | \"none\";\n\nexport interface CfImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"placeholder\"> {\n src: string;\n alt: string;\n /** next/image의 fill 처럼 부모(position:relative)를 채운다. */\n fill?: boolean;\n /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */\n priority?: boolean;\n /** fill일 때 object-fit (기본 cover). */\n objectFit?: CSSProperties[\"objectFit\"];\n /** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */\n placeholder?: CfImagePlaceholder;\n /** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */\n loaderImage?: string;\n /** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */\n loader?: ReactNode;\n /** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */\n loaderBackground?: string;\n /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */\n wrapperClassName?: string;\n wrapperStyle?: CSSProperties;\n}\n\nconst SHIMMER_STYLE_ID = \"roottale-cf-image-shimmer\";\nconst SHIMMER_KEYFRAMES = `@keyframes rtCfImgShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}`;\n\nfunction ensureShimmerKeyframes() {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(SHIMMER_STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = SHIMMER_STYLE_ID;\n el.textContent = SHIMMER_KEYFRAMES;\n document.head.appendChild(el);\n}\n\nexport function CfImage({\n src,\n alt,\n fill,\n priority,\n objectFit = \"cover\",\n placeholder = \"blur\",\n loaderImage,\n loader,\n loaderBackground = \"#eceff1\",\n sizes,\n width,\n height,\n loading,\n style,\n className,\n wrapperClassName,\n wrapperStyle,\n onLoad,\n onError,\n ...rest\n}: CfImageProps) {\n const [loaded, setLoaded] = useState(false);\n const [blurReady, setBlurReady] = useState(false);\n const imgRef = useRef<HTMLImageElement>(null);\n\n // 캐시 히트 시 onLoad가 안 올 수 있어 마운트 시 complete 체크.\n useEffect(() => {\n if (imgRef.current?.complete) setLoaded(true);\n }, []);\n\n const cf = isCfImageUrl(src);\n\n // 로더 모드 결정. 우선순위: 커스텀 loader 노드 > placeholder.\n const hasCustomLoader = loader != null;\n let mode: \"custom\" | \"image\" | \"blur\" | \"skeleton\" | \"none\";\n if (hasCustomLoader) mode = \"custom\";\n else if (placeholder === \"image\" && loaderImage) mode = \"image\";\n else if (placeholder === \"image\") mode = cf ? \"blur\" : \"skeleton\"; // 이미지 미지정 폴백\n else if (placeholder === \"blur\") mode = cf ? \"blur\" : \"skeleton\";\n else if (placeholder === \"skeleton\") mode = \"skeleton\";\n else mode = \"none\";\n\n // blur 모드는 skeleton 을 base 로 깐다(썸네일 받기 전 갭 메움). skeleton 단독도 포함.\n const usesSkeleton = mode === \"skeleton\" || mode === \"blur\";\n const thumbSrc = mode === \"blur\" ? cfImageVariant(src, \"thumbnail\") : null;\n\n useEffect(() => {\n if (usesSkeleton) ensureShimmerKeyframes();\n }, [usesSkeleton]);\n\n // blur 썸네일은 background-image 라 onLoad 가 없다 → 프리로드로 준비 시점 추적.\n // 준비되기 전엔 skeleton 만 보이고, 준비되면 blur 가 그 위로 페이드인.\n useEffect(() => {\n if (!thumbSrc || typeof Image === \"undefined\") return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- 새 썸네일을 프리로드하는 동안 \"준비됨\"을 내린다. 브라우저의 이미지 로딩 진행을 따라가는 상태라 렌더 중에는 알 수 없다.\n setBlurReady(false);\n let cancelled = false;\n const pre = new Image();\n pre.onload = () => {\n if (!cancelled) setBlurReady(true);\n };\n pre.src = thumbSrc;\n if (pre.complete) setBlurReady(true); // 캐시 히트\n return () => {\n cancelled = true;\n };\n }, [thumbSrc]);\n\n const baseSrc = cf ? cfImageVariant(src, \"md\") : src;\n const srcSet = cf ? cfImageSrcSet(src) : undefined;\n const showLoader = mode !== \"none\" && !loaded && !priority;\n\n const wrapper: CSSProperties = fill\n ? { position: \"absolute\", inset: 0, overflow: \"hidden\", ...wrapperStyle }\n : {\n position: \"relative\",\n display: \"inline-block\",\n overflow: \"hidden\",\n ...wrapperStyle,\n };\n\n const imgStyle: CSSProperties = {\n ...(fill\n ? { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit }\n : { display: \"block\", width: width ? \"100%\" : undefined, height: \"auto\", objectFit }),\n opacity: loaded || priority ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n ...style,\n };\n\n const overlayBase: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n transition: \"opacity 0.4s ease\",\n };\n\n // 레이어 합성. 아래→위 순서로 쌓는다.\n // · skeleton: 즉시 표시(로딩 인지). blur 모드면 그 위로 blur 가 덮는다.\n // · blur: 썸네일 준비(blurReady) 시 skeleton 위로 페이드인.\n // · image/custom: 단독.\n const layers: ReactNode[] = [];\n\n if (usesSkeleton) {\n // blur 모드에서 썸네일이 준비되면 skeleton 은 가려지므로 미리 페이드아웃.\n const skeletonVisible =\n showLoader && !(mode === \"blur\" && blurReady);\n layers.push(\n <span\n key=\"skeleton\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: skeletonVisible ? 1 : 0,\n background: `linear-gradient(100deg,${loaderBackground} 30%,#f5f7f8 50%,${loaderBackground} 70%)`,\n backgroundSize: \"200% 100%\",\n animation: \"rtCfImgShimmer 1.4s ease-in-out infinite\",\n }}\n />,\n );\n }\n\n if (mode === \"blur\" && thumbSrc) {\n layers.push(\n <span\n key=\"blur\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: showLoader && blurReady ? 1 : 0,\n backgroundImage: `url(${thumbSrc})`,\n backgroundSize: \"cover\",\n backgroundPosition: \"center\",\n filter: \"blur(16px)\",\n transform: \"scale(1.1)\",\n }}\n />,\n );\n }\n\n if (mode === \"image\" || mode === \"custom\") {\n layers.push(\n <span\n key=\"loader\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: showLoader ? 1 : 0,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n background: loaderBackground,\n }}\n >\n {mode === \"image\" ? (\n <img\n src={loaderImage}\n alt=\"\"\n aria-hidden\n style={{ maxWidth: \"44%\", maxHeight: \"44%\", objectFit: \"contain\" }}\n />\n ) : (\n loader\n )}\n </span>,\n );\n }\n\n return (\n <span className={wrapperClassName} style={wrapper}>\n {layers}\n <img\n {...rest}\n ref={imgRef}\n src={baseSrc}\n srcSet={srcSet}\n sizes={sizes}\n alt={alt}\n width={fill ? undefined : width}\n height={fill ? undefined : height}\n loading={loading ?? (priority ? \"eager\" : \"lazy\")}\n fetchPriority={priority ? \"high\" : undefined}\n decoding=\"async\"\n className={className}\n style={imgStyle}\n onLoad={(e) => {\n setLoaded(true);\n onLoad?.(e);\n }}\n onError={onError}\n />\n </span>\n );\n}\n\nexport default CfImage;\n","// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/react/CfImage.tsx","../src/variants.ts","../src/react/RootTaleImage.tsx","../src/delivery.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ImgHTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport { cfImageVariant, cfImageSrcSet, isCfImageUrl } from \"../variants.js\";\n\n/**\n * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)\n *\n * 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.\n * 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)\n * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)\n *\n * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.\n * - placeholder(로더): 'blur' | 'skeleton' | 'image' | 'none'. 기본 'blur'.\n * · 'blur'(CF): skeleton shimmer 를 즉시 깔고(=마운트 순간 로딩 인지) 그 위로 CF thumbnail\n * 흐림을 썸네일이 받아지는 순간 페이드인 → 원본 받아지면 둘 다 페이드아웃. blur 썸네일도\n * 네트워크라 받기 전 빈 화면이 생기던 갭을 skeleton 이 메운다. (CF 아니면 skeleton 단독)\n * · 'skeleton': shimmer 단독.\n * · 'image': loaderImage(브랜드 스피너/로고 등)를 가운데 표시.\n * · loader: 임의 ReactNode 를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.\n * - 원본 onLoad 시 opacity 0→1 페이드인.\n *\n * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.\n */\n\nexport type CfImagePlaceholder = \"blur\" | \"skeleton\" | \"image\" | \"none\";\n\nexport interface CfImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"placeholder\"> {\n src: string;\n alt: string;\n /** next/image의 fill 처럼 부모(position:relative)를 채운다. */\n fill?: boolean;\n /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */\n priority?: boolean;\n /** fill일 때 object-fit (기본 cover). */\n objectFit?: CSSProperties[\"objectFit\"];\n /** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */\n placeholder?: CfImagePlaceholder;\n /** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */\n loaderImage?: string;\n /** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */\n loader?: ReactNode;\n /** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */\n loaderBackground?: string;\n /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */\n wrapperClassName?: string;\n wrapperStyle?: CSSProperties;\n}\n\nconst SHIMMER_STYLE_ID = \"roottale-cf-image-shimmer\";\nconst SHIMMER_KEYFRAMES = `@keyframes rtCfImgShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}`;\n\nfunction ensureShimmerKeyframes() {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(SHIMMER_STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = SHIMMER_STYLE_ID;\n el.textContent = SHIMMER_KEYFRAMES;\n document.head.appendChild(el);\n}\n\nexport function CfImage({\n src,\n alt,\n fill,\n priority,\n objectFit = \"cover\",\n placeholder = \"blur\",\n loaderImage,\n loader,\n loaderBackground = \"#eceff1\",\n sizes,\n width,\n height,\n loading,\n style,\n className,\n wrapperClassName,\n wrapperStyle,\n onLoad,\n onError,\n ...rest\n}: CfImageProps) {\n const [loaded, setLoaded] = useState(false);\n const [blurReady, setBlurReady] = useState(false);\n const imgRef = useRef<HTMLImageElement>(null);\n\n // 캐시 히트 시 onLoad가 안 올 수 있어 마운트 시 complete 체크.\n useEffect(() => {\n if (imgRef.current?.complete) setLoaded(true);\n }, []);\n\n const cf = isCfImageUrl(src);\n\n // 로더 모드 결정. 우선순위: 커스텀 loader 노드 > placeholder.\n const hasCustomLoader = loader != null;\n let mode: \"custom\" | \"image\" | \"blur\" | \"skeleton\" | \"none\";\n if (hasCustomLoader) mode = \"custom\";\n else if (placeholder === \"image\" && loaderImage) mode = \"image\";\n else if (placeholder === \"image\") mode = cf ? \"blur\" : \"skeleton\"; // 이미지 미지정 폴백\n else if (placeholder === \"blur\") mode = cf ? \"blur\" : \"skeleton\";\n else if (placeholder === \"skeleton\") mode = \"skeleton\";\n else mode = \"none\";\n\n // blur 모드는 skeleton 을 base 로 깐다(썸네일 받기 전 갭 메움). skeleton 단독도 포함.\n const usesSkeleton = mode === \"skeleton\" || mode === \"blur\";\n const thumbSrc = mode === \"blur\" ? cfImageVariant(src, \"thumbnail\") : null;\n\n useEffect(() => {\n if (usesSkeleton) ensureShimmerKeyframes();\n }, [usesSkeleton]);\n\n // blur 썸네일은 background-image 라 onLoad 가 없다 → 프리로드로 준비 시점 추적.\n // 준비되기 전엔 skeleton 만 보이고, 준비되면 blur 가 그 위로 페이드인.\n useEffect(() => {\n if (!thumbSrc || typeof Image === \"undefined\") return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- 새 썸네일을 프리로드하는 동안 \"준비됨\"을 내린다. 브라우저의 이미지 로딩 진행을 따라가는 상태라 렌더 중에는 알 수 없다.\n setBlurReady(false);\n let cancelled = false;\n const pre = new Image();\n pre.onload = () => {\n if (!cancelled) setBlurReady(true);\n };\n pre.src = thumbSrc;\n if (pre.complete) setBlurReady(true); // 캐시 히트\n return () => {\n cancelled = true;\n };\n }, [thumbSrc]);\n\n const baseSrc = cf ? cfImageVariant(src, \"md\") : src;\n const srcSet = cf ? cfImageSrcSet(src) : undefined;\n const showLoader = mode !== \"none\" && !loaded && !priority;\n\n const wrapper: CSSProperties = fill\n ? { position: \"absolute\", inset: 0, overflow: \"hidden\", ...wrapperStyle }\n : {\n position: \"relative\",\n display: \"inline-block\",\n overflow: \"hidden\",\n ...wrapperStyle,\n };\n\n const imgStyle: CSSProperties = {\n ...(fill\n ? { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit }\n : { display: \"block\", width: width ? \"100%\" : undefined, height: \"auto\", objectFit }),\n opacity: loaded || priority ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n ...style,\n };\n\n const overlayBase: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n transition: \"opacity 0.4s ease\",\n };\n\n // 레이어 합성. 아래→위 순서로 쌓는다.\n // · skeleton: 즉시 표시(로딩 인지). blur 모드면 그 위로 blur 가 덮는다.\n // · blur: 썸네일 준비(blurReady) 시 skeleton 위로 페이드인.\n // · image/custom: 단독.\n const layers: ReactNode[] = [];\n\n if (usesSkeleton) {\n // blur 모드에서 썸네일이 준비되면 skeleton 은 가려지므로 미리 페이드아웃.\n const skeletonVisible =\n showLoader && !(mode === \"blur\" && blurReady);\n layers.push(\n <span\n key=\"skeleton\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: skeletonVisible ? 1 : 0,\n background: `linear-gradient(100deg,${loaderBackground} 30%,#f5f7f8 50%,${loaderBackground} 70%)`,\n backgroundSize: \"200% 100%\",\n animation: \"rtCfImgShimmer 1.4s ease-in-out infinite\",\n }}\n />,\n );\n }\n\n if (mode === \"blur\" && thumbSrc) {\n layers.push(\n <span\n key=\"blur\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: showLoader && blurReady ? 1 : 0,\n backgroundImage: `url(${thumbSrc})`,\n backgroundSize: \"cover\",\n backgroundPosition: \"center\",\n filter: \"blur(16px)\",\n transform: \"scale(1.1)\",\n }}\n />,\n );\n }\n\n if (mode === \"image\" || mode === \"custom\") {\n layers.push(\n <span\n key=\"loader\"\n aria-hidden\n style={{\n ...overlayBase,\n opacity: showLoader ? 1 : 0,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n background: loaderBackground,\n }}\n >\n {mode === \"image\" ? (\n <img\n src={loaderImage}\n alt=\"\"\n aria-hidden\n style={{ maxWidth: \"44%\", maxHeight: \"44%\", objectFit: \"contain\" }}\n />\n ) : (\n loader\n )}\n </span>,\n );\n }\n\n return (\n <span className={wrapperClassName} style={wrapper}>\n {layers}\n <img\n {...rest}\n ref={imgRef}\n src={baseSrc}\n srcSet={srcSet}\n sizes={sizes}\n alt={alt}\n width={fill ? undefined : width}\n height={fill ? undefined : height}\n loading={loading ?? (priority ? \"eager\" : \"lazy\")}\n fetchPriority={priority ? \"high\" : undefined}\n decoding=\"async\"\n className={className}\n style={imgStyle}\n onLoad={(e) => {\n setLoaded(true);\n onLoad?.(e);\n }}\n onError={onError}\n />\n </span>\n );\n}\n\nexport default CfImage;\n","// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n","\"use client\";\n\nimport { forwardRef, useState, type ImgHTMLAttributes } from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport { createImageDelivery, imageSource, type ImageAttributes, type ImageSource } from \"../delivery.js\";\n\nexport interface RootTaleImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"alt\"> {\n src: ImageSource;\n alt: string;\n fill?: boolean;\n priority?: boolean;\n preload?: boolean;\n /** A site adapter can resolve local/R2 assets without putting its registry in this package. */\n imageAttributes?: ImageAttributes;\n fallbackSrc?: string;\n /** Scope direct CF URL transformations to the site's account. */\n accountHash?: string;\n}\n\nfunction restorePictureSources(image: HTMLImageElement) {\n for (const source of image.closest(\"picture\")?.querySelectorAll<HTMLSourceElement>(\"source[srcset]\") ?? []) {\n const fallback = source.dataset.imageFallbackSrc ?? source.dataset.originalImageSrc;\n if (fallback) source.srcset = fallback;\n else source.removeAttribute(\"srcset\");\n }\n}\n\n/** RootTale's native image boundary. No wrapper, injected CSS, Next.js or network/upload clients. */\nexport const RootTaleImage = forwardRef<HTMLImageElement, RootTaleImageProps>(function RootTaleImage({\n src, alt, fill, priority, preload, imageAttributes, fallbackSrc, accountHash,\n sizes = \"100vw\", srcSet, width, height, loading, fetchPriority, decoding = \"async\", style, onError, ...props\n}, ref) {\n const original = imageSource(src);\n const delivery = createImageDelivery({ accountHash });\n const optimized = imageAttributes ?? (srcSet ? { src: original, srcSet, sizes } : delivery.imageAttributes(src, sizes));\n const fallback = fallbackSrc ?? original;\n // Track the attempted display URL: changing a CMS asset or mapping starts a fresh attempt.\n const attempt = `${original}\\n${optimized.src}\\n${fallback}`;\n const [failure, setFailure] = useState({ attempt, failed: false });\n const failed = failure.attempt === attempt && failure.failed;\n // React's guarded render update resets state when the source identity changes.\n if (failure.attempt !== attempt) setFailure({ attempt, failed: false });\n const attributes: ImageAttributes = failed ? { src: fallback } : optimized;\n const eager = priority || preload;\n // React 18.3 remains supported; it has no react-dom.preload API.\n if (preload && typeof ReactDOM.preload === \"function\") {\n ReactDOM.preload(attributes.src, { as: \"image\", imageSrcSet: attributes.srcSet, imageSizes: attributes.sizes, fetchPriority: \"high\" });\n }\n return <img {...props} {...attributes} ref={ref} alt={alt}\n width={fill ? undefined : width ?? (typeof src === \"string\" ? undefined : src.width)}\n height={fill ? undefined : height ?? (typeof src === \"string\" ? undefined : src.height)}\n loading={loading ?? (eager ? \"eager\" : \"lazy\")}\n fetchPriority={fetchPriority ?? (eager ? \"high\" : undefined)} decoding={decoding}\n style={fill ? { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", ...style } : style}\n onError={event => {\n if (!failed && (attributes.srcSet || attributes.src !== fallback)) {\n restorePictureSources(event.currentTarget);\n setFailure({ attempt, failed: true });\n } else onError?.(event);\n }} />;\n});\n","/** Browser/server URL helpers. No React, upload clients, environment access or network requests. */\nexport interface StaticImageSource {\n src: string;\n width?: number;\n height?: number;\n}\n\nexport type ImageSource = string | StaticImageSource;\n\nexport interface RegisteredImage {\n delivery: string;\n original?: string;\n width?: number;\n height?: number;\n}\n\nexport type ImageDeliveryRegistry = Readonly<Record<string, RegisteredImage>>;\n\nexport interface ImageDeliveryConfig {\n /** Only this CF account is transformed. Omit to accept any public CF Images account. */\n accountHash?: string;\n registry?: ImageDeliveryRegistry;\n /** Site origins whose public paths use the same local registry. */\n assetOrigins?: readonly string[];\n widths?: readonly number[];\n quality?: number;\n}\n\nexport interface ImageAttributes {\n src: string;\n srcSet?: string;\n sizes?: string;\n}\n\nexport const IMAGE_WIDTHS: readonly number[] = [32, 48, 64, 96, 128, 256, 320, 384, 640, 768, 960, 1200, 1600, 1920, 2560];\n\nexport function imageSource(source: ImageSource): string {\n return typeof source === \"string\" ? source : source.src;\n}\n\n/** Signed URLs, credentials and host lookalikes are never rewritten. Custom image IDs may contain '/'. */\nexport function cfImageBase(src: string, accountHash?: string): string | null {\n try {\n const url = new URL(src);\n if (url.protocol !== \"https:\" || url.hostname !== \"imagedelivery.net\" || url.port || url.username || url.password || url.search || url.hash) return null;\n const segments = url.pathname.split(\"/\").filter(Boolean);\n if (segments.length < 3 || (accountHash && segments[0] !== accountHash)) return null;\n return `${url.origin}/${segments.slice(0, -1).join(\"/\")}`;\n } catch { return null; }\n}\n\n/** A site's asset mapping stays outside the component and is never written back to the CMS. */\nexport function createImageDelivery(config: ImageDeliveryConfig = {}) {\n const { accountHash, registry = {}, assetOrigins = [], quality = 75 } = config;\n const widths = [...new Set(config.widths ?? IMAGE_WIDTHS)].sort((a, b) => a - b);\n if (!Number.isInteger(quality) || quality < 1 || quality > 100 || !widths.length || widths.some(width => !Number.isSafeInteger(width) || width < 1)) {\n throw new RangeError(\"Image quality must be 1–100 and widths must be positive integers.\");\n }\n const largestWidth = widths[widths.length - 1]!;\n const urlFor = (base: string, width: number) => `${base}/width=${width},quality=${quality},fit=scale-down`;\n\n function imageDelivery(source: ImageSource) {\n const src = imageSource(source);\n if (!src || /[\\s\\\\]/.test(src) || src.startsWith(\"//\") || /\\.(svg|gif)(?:[?#]|$)/i.test(src) || src.startsWith(\"/_next/image\")) return null;\n let assetPath: string | undefined;\n if (!src.startsWith(\"/\")) {\n try {\n const url = new URL(src);\n if (![\"http:\", \"https:\"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;\n if (assetOrigins.includes(url.origin)) assetPath = url.pathname;\n } catch { return null; }\n }\n const base = cfImageBase(src, accountHash);\n const registered = registry[src] ?? (base ? registry[base] : undefined) ?? (assetPath ? registry[assetPath] : undefined);\n if (registered) {\n const deliveryBase = cfImageBase(registered.delivery, accountHash);\n return deliveryBase ? { ...registered, base: deliveryBase } : null;\n }\n return base ? { base, width: undefined, original: undefined } : null;\n }\n\n function imageFallback(source: ImageSource): string {\n const original = imageSource(source);\n return original.startsWith(\"/\") ? original : imageDelivery(source)?.original ?? original;\n }\n\n function imageUrl(source: ImageSource, requestedWidth: number): string {\n const delivery = imageDelivery(source);\n if (!delivery) return imageSource(source);\n const width = widths.find(size => size >= requestedWidth) ?? largestWidth;\n const sourceWidth = delivery.width ?? (typeof source === \"string\" ? undefined : source.width);\n return urlFor(delivery.base, Math.min(width, sourceWidth || width));\n }\n\n function imageAttributes(source: ImageSource, sizes = \"100vw\"): ImageAttributes {\n const delivery = imageDelivery(source);\n if (!delivery) return { src: imageSource(source) };\n const sourceWidth = delivery.width ?? (typeof source === \"string\" ? undefined : source.width);\n const limit = sourceWidth && sourceWidth > 0 ? Math.min(sourceWidth, largestWidth) : largestWidth;\n const candidates = widths.filter(width => width < limit);\n candidates.push(limit);\n return {\n src: urlFor(delivery.base, Math.min(widths.find(width => width >= 1600) ?? largestWidth, limit)),\n srcSet: candidates.map(width => `${urlFor(delivery.base, width)} ${width}w`).join(\", \"),\n sizes,\n };\n }\n\n return { imageDelivery, imageFallback, imageUrl, imageAttributes };\n}\n"],"mappings":"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","names":["useState","jsx","RootTaleImage","preload","useState"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-media",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale 미디어 — R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트(@roottale/cms-media/react). ADR-0034 §3 / docs/image-standard.md.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./delivery": {
|
|
15
|
+
"types": "./dist/delivery.d.ts",
|
|
16
|
+
"import": "./dist/delivery.js",
|
|
17
|
+
"default": "./dist/delivery.js"
|
|
18
|
+
},
|
|
14
19
|
"./react": {
|
|
15
20
|
"types": "./dist/react.d.ts",
|
|
16
21
|
"import": "./dist/react.js",
|
package/dist/react.d.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import { ImgHTMLAttributes, CSSProperties, ReactNode } from 'react';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)
|
|
6
|
-
*
|
|
7
|
-
* 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.
|
|
8
|
-
* 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)
|
|
9
|
-
* srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)
|
|
10
|
-
*
|
|
11
|
-
* - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.
|
|
12
|
-
* - placeholder(로더): 'blur' | 'skeleton' | 'image' | 'none'. 기본 'blur'.
|
|
13
|
-
* · 'blur'(CF): skeleton shimmer 를 즉시 깔고(=마운트 순간 로딩 인지) 그 위로 CF thumbnail
|
|
14
|
-
* 흐림을 썸네일이 받아지는 순간 페이드인 → 원본 받아지면 둘 다 페이드아웃. blur 썸네일도
|
|
15
|
-
* 네트워크라 받기 전 빈 화면이 생기던 갭을 skeleton 이 메운다. (CF 아니면 skeleton 단독)
|
|
16
|
-
* · 'skeleton': shimmer 단독.
|
|
17
|
-
* · 'image': loaderImage(브랜드 스피너/로고 등)를 가운데 표시.
|
|
18
|
-
* · loader: 임의 ReactNode 를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.
|
|
19
|
-
* - 원본 onLoad 시 opacity 0→1 페이드인.
|
|
20
|
-
*
|
|
21
|
-
* next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.
|
|
22
|
-
*/
|
|
23
|
-
type CfImagePlaceholder = "blur" | "skeleton" | "image" | "none";
|
|
24
|
-
interface CfImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "placeholder"> {
|
|
25
|
-
src: string;
|
|
26
|
-
alt: string;
|
|
27
|
-
/** next/image의 fill 처럼 부모(position:relative)를 채운다. */
|
|
28
|
-
fill?: boolean;
|
|
29
|
-
/** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */
|
|
30
|
-
priority?: boolean;
|
|
31
|
-
/** fill일 때 object-fit (기본 cover). */
|
|
32
|
-
objectFit?: CSSProperties["objectFit"];
|
|
33
|
-
/** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */
|
|
34
|
-
placeholder?: CfImagePlaceholder;
|
|
35
|
-
/** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */
|
|
36
|
-
loaderImage?: string;
|
|
37
|
-
/** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */
|
|
38
|
-
loader?: ReactNode;
|
|
39
|
-
/** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */
|
|
40
|
-
loaderBackground?: string;
|
|
41
|
-
/** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */
|
|
42
|
-
wrapperClassName?: string;
|
|
43
|
-
wrapperStyle?: CSSProperties;
|
|
44
|
-
}
|
|
45
|
-
declare function CfImage({ src, alt, fill, priority, objectFit, placeholder, loaderImage, loader, loaderBackground, sizes, width, height, loading, style, className, wrapperClassName, wrapperStyle, onLoad, onError, ...rest }: CfImageProps): react_jsx_runtime.JSX.Element;
|
|
46
|
-
|
|
47
|
-
type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
|
|
48
|
-
/** imagedelivery delivery URL 여부. */
|
|
49
|
-
declare function isCfImageUrl(url: string): boolean;
|
|
50
|
-
/**
|
|
51
|
-
* delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.
|
|
52
|
-
* CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).
|
|
53
|
-
*/
|
|
54
|
-
declare function cfImageVariant(url: string, variant: ImageVariant | "public"): string;
|
|
55
|
-
/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */
|
|
56
|
-
declare function cfImageSrcSet(url: string, variants?: readonly ImageVariant[]): string | undefined;
|
|
57
|
-
|
|
58
|
-
export { CfImage, type CfImagePlaceholder, type CfImageProps, type ImageVariant, cfImageSrcSet, cfImageVariant, CfImage as default, isCfImageUrl };
|