@roottale/cms-media 0.39.0 → 0.53.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/dist/index.d.ts +86 -42
- package/dist/index.js +105 -35
- package/dist/index.js.map +1 -1
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
+
type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
|
|
2
|
+
declare const VARIANT_NAMES: readonly ImageVariant[];
|
|
3
|
+
interface VariantSizes {
|
|
4
|
+
thumbnail: {
|
|
5
|
+
width: 96;
|
|
6
|
+
height: 96;
|
|
7
|
+
};
|
|
8
|
+
sm: {
|
|
9
|
+
width: 320;
|
|
10
|
+
height: 320;
|
|
11
|
+
};
|
|
12
|
+
md: {
|
|
13
|
+
width: 768;
|
|
14
|
+
height: 768;
|
|
15
|
+
};
|
|
16
|
+
lg: {
|
|
17
|
+
width: 1600;
|
|
18
|
+
height: 1600;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
declare const VARIANT_SIZES: VariantSizes;
|
|
22
|
+
interface CfImagesConfig {
|
|
23
|
+
accountHash: string;
|
|
24
|
+
}
|
|
25
|
+
/** CF Images delivery URL 생성. */
|
|
26
|
+
declare function variantUrl(config: CfImagesConfig, imageId: string, variant: ImageVariant): string;
|
|
27
|
+
/** 모든 variant 의 URL map. */
|
|
28
|
+
declare function allVariantUrls(config: CfImagesConfig, imageId: string): Record<ImageVariant, string>;
|
|
29
|
+
/** imagedelivery delivery URL 여부. */
|
|
30
|
+
declare function isCfImageUrl(url: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.
|
|
33
|
+
* CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).
|
|
34
|
+
*/
|
|
35
|
+
declare function cfImageVariant(url: string, variant: ImageVariant | "public"): string;
|
|
36
|
+
/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */
|
|
37
|
+
declare function cfImageSrcSet(url: string, variants?: readonly ImageVariant[]): string | undefined;
|
|
38
|
+
|
|
39
|
+
interface CfImagesCredentials {
|
|
40
|
+
/** Cloudflare account id. */
|
|
41
|
+
accountId: string;
|
|
42
|
+
/** Images 쓰기 권한 토큰. */
|
|
43
|
+
apiToken: string;
|
|
44
|
+
/** imagedelivery.net account hash. */
|
|
45
|
+
accountHash: string;
|
|
46
|
+
/** 테스트/워커 주입용. 미지정 시 전역 fetch. */
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
}
|
|
49
|
+
interface CfImagesClient {
|
|
50
|
+
/**
|
|
51
|
+
* R2 public URL 의 이미지를 CF Images 로 "upload via URL" 한다.
|
|
52
|
+
* @returns CF Images image id. 실패 시 throw (호출부가 명확히 인지).
|
|
53
|
+
*/
|
|
54
|
+
uploadFromUrl(sourceUrl: string): Promise<string>;
|
|
55
|
+
/** image id → 4 variant(thumbnail/sm/md/lg) delivery URL map. */
|
|
56
|
+
variantUrls(imageId: string): Record<ImageVariant, string>;
|
|
57
|
+
/** CF Images 이미지 삭제 (orphan 방지). 실패 시 throw — 호출부가 best-effort 처리. */
|
|
58
|
+
deleteImage(imageId: string): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* CF Images 클라이언트. 자격증명 3종을 모두 받아야 만들 수 있다 —
|
|
62
|
+
* 미설정 판단(graceful skip)은 호출부 책임이다.
|
|
63
|
+
*/
|
|
64
|
+
declare function createCfImagesClient(credentials: CfImagesCredentials): CfImagesClient;
|
|
65
|
+
interface ImageVariantsResult {
|
|
66
|
+
/** CF Images delivery URL map. 미연결·실패 시 undefined. */
|
|
67
|
+
variantsJson?: Record<ImageVariant, string>;
|
|
68
|
+
/** `{ cfImageId }` — 삭제 시 orphan 정리에 쓴다. 미연결·실패 시 undefined. */
|
|
69
|
+
metaJson?: {
|
|
70
|
+
cfImageId: string;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 업로드된 이미지의 variant/meta 를 만든다 — **모든 업로드 경로의 공통 헬퍼**.
|
|
75
|
+
*
|
|
76
|
+
* 이미지가 아니거나 CF Images 미연결(client=null)이면 graceful skip, CF 실패도
|
|
77
|
+
* 삼켜서 빈 결과로 떨어뜨린다(미디어 등록 자체는 성공시킨다 — variant 없이도
|
|
78
|
+
* 원본은 R2 에 있다). 업로드 경로가 늘어나도 결과 모양이 갈라지지 않도록
|
|
79
|
+
* variant 채우기는 이 헬퍼를 통해서만 한다.
|
|
80
|
+
*/
|
|
81
|
+
declare function buildImageVariants(client: CfImagesClient | null, sourceUrl: string, onError?: (err: unknown) => void): Promise<ImageVariantsResult>;
|
|
82
|
+
|
|
1
83
|
interface NormalizedFilename {
|
|
2
84
|
/** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */
|
|
3
85
|
safe: string;
|
|
@@ -44,7 +126,7 @@ interface R2Object {
|
|
|
44
126
|
}
|
|
45
127
|
/**
|
|
46
128
|
* R2 client interface — environment-specific binding 이 본 interface 구현.
|
|
47
|
-
* admin
|
|
129
|
+
* tenant-admin 가 server action 안에서 inject (DI).
|
|
48
130
|
*/
|
|
49
131
|
interface R2Client {
|
|
50
132
|
put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;
|
|
@@ -80,7 +162,7 @@ interface S3ClientConfig {
|
|
|
80
162
|
}
|
|
81
163
|
/**
|
|
82
164
|
* AWS SDK 의존을 minimal — type-only declaration.
|
|
83
|
-
* 실 binding 은 admin
|
|
165
|
+
* 실 binding 은 tenant-admin 의 server action 또는 tenant-api 가 SDK 주입.
|
|
84
166
|
* 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.
|
|
85
167
|
*/
|
|
86
168
|
interface MinimalS3Sdk {
|
|
@@ -114,7 +196,7 @@ interface S3CommandFactories {
|
|
|
114
196
|
/**
|
|
115
197
|
* R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.
|
|
116
198
|
*
|
|
117
|
-
* 사용 예시 (admin
|
|
199
|
+
* 사용 예시 (tenant-admin):
|
|
118
200
|
* import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }
|
|
119
201
|
* from "@aws-sdk/client-s3";
|
|
120
202
|
* const r2 = new S3R2Client(
|
|
@@ -134,42 +216,4 @@ declare class S3R2Client implements R2Client {
|
|
|
134
216
|
getPublicUrl(key: string): string;
|
|
135
217
|
}
|
|
136
218
|
|
|
137
|
-
type ImageVariant
|
|
138
|
-
declare const VARIANT_NAMES: readonly ImageVariant[];
|
|
139
|
-
interface VariantSizes {
|
|
140
|
-
thumbnail: {
|
|
141
|
-
width: 96;
|
|
142
|
-
height: 96;
|
|
143
|
-
};
|
|
144
|
-
sm: {
|
|
145
|
-
width: 320;
|
|
146
|
-
height: 320;
|
|
147
|
-
};
|
|
148
|
-
md: {
|
|
149
|
-
width: 768;
|
|
150
|
-
height: 768;
|
|
151
|
-
};
|
|
152
|
-
lg: {
|
|
153
|
-
width: 1600;
|
|
154
|
-
height: 1600;
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
declare const VARIANT_SIZES: VariantSizes;
|
|
158
|
-
interface CfImagesConfig {
|
|
159
|
-
accountHash: string;
|
|
160
|
-
}
|
|
161
|
-
/** CF Images delivery URL 생성. */
|
|
162
|
-
declare function variantUrl(config: CfImagesConfig, imageId: string, variant: ImageVariant): string;
|
|
163
|
-
/** 모든 variant 의 URL map. */
|
|
164
|
-
declare function allVariantUrls(config: CfImagesConfig, imageId: string): Record<ImageVariant, string>;
|
|
165
|
-
/** imagedelivery delivery URL 여부. */
|
|
166
|
-
declare function isCfImageUrl(url: string): boolean;
|
|
167
|
-
/**
|
|
168
|
-
* delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.
|
|
169
|
-
* CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).
|
|
170
|
-
*/
|
|
171
|
-
declare function cfImageVariant(url: string, variant: ImageVariant | "public"): string;
|
|
172
|
-
/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */
|
|
173
|
-
declare function cfImageSrcSet(url: string, variants?: readonly ImageVariant[]): string | undefined;
|
|
174
|
-
|
|
175
|
-
export { type CfImagesConfig, type ImageVariant, type NormalizedFilename, type R2Client, type R2Object, type R2PutOptions, type S3ClientConfig, type S3CommandFactories, S3R2Client, TenantR2, VARIANT_NAMES, VARIANT_SIZES, type VariantSizes, allVariantUrls, cfImageSrcSet, cfImageVariant, isCfImageUrl, normalizeFilename, r2KeyForTenant, seoFilenameFromFilename, suggestAltFromFilename, variantUrl };
|
|
219
|
+
export { type CfImagesClient, type CfImagesConfig, type CfImagesCredentials, type ImageVariant, type ImageVariantsResult, type NormalizedFilename, type R2Client, type R2Object, type R2PutOptions, type S3ClientConfig, type S3CommandFactories, S3R2Client, TenantR2, VARIANT_NAMES, VARIANT_SIZES, type VariantSizes, allVariantUrls, buildImageVariants, cfImageSrcSet, cfImageVariant, createCfImagesClient, isCfImageUrl, normalizeFilename, r2KeyForTenant, seoFilenameFromFilename, suggestAltFromFilename, variantUrl };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
1
|
+
// src/variants.ts
|
|
2
|
+
var VARIANT_NAMES = [
|
|
3
|
+
"thumbnail",
|
|
4
|
+
"sm",
|
|
5
|
+
"md",
|
|
6
|
+
"lg"
|
|
7
|
+
];
|
|
8
|
+
var VARIANT_SIZES = {
|
|
9
|
+
thumbnail: { width: 96, height: 96 },
|
|
10
|
+
sm: { width: 320, height: 320 },
|
|
11
|
+
md: { width: 768, height: 768 },
|
|
12
|
+
lg: { width: 1600, height: 1600 }
|
|
13
|
+
};
|
|
14
|
+
function variantUrl(config, imageId, variant) {
|
|
15
|
+
return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;
|
|
16
|
+
}
|
|
17
|
+
function allVariantUrls(config, imageId) {
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)])
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
var CF_HOST = "imagedelivery.net";
|
|
23
|
+
var KNOWN_VARIANT_SUFFIX = /\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;
|
|
24
|
+
function isCfImageUrl(url) {
|
|
25
|
+
return typeof url === "string" && url.includes(CF_HOST);
|
|
26
|
+
}
|
|
27
|
+
function cfImageVariant(url, variant) {
|
|
28
|
+
if (!isCfImageUrl(url)) return url;
|
|
29
|
+
return KNOWN_VARIANT_SUFFIX.test(url) ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`) : `${url}/${variant}`;
|
|
30
|
+
}
|
|
31
|
+
function cfImageSrcSet(url, variants = ["sm", "md", "lg"]) {
|
|
32
|
+
if (!isCfImageUrl(url)) return void 0;
|
|
33
|
+
return variants.map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`).join(", ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/cf-images.ts
|
|
37
|
+
var UPLOAD_TIMEOUT_MS = 15e3;
|
|
38
|
+
var DELETE_TIMEOUT_MS = 1e4;
|
|
39
|
+
function createCfImagesClient(credentials) {
|
|
40
|
+
const { accountId, apiToken, accountHash } = credentials;
|
|
41
|
+
const config = { accountHash };
|
|
42
|
+
const doFetch = (input, init) => (credentials.fetchImpl ?? globalThis.fetch)(input, init);
|
|
43
|
+
async function withTimeout(timeoutMs, run) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
return await run(controller.signal);
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
async uploadFromUrl(sourceUrl) {
|
|
54
|
+
const form = new FormData();
|
|
55
|
+
form.set("url", sourceUrl);
|
|
56
|
+
return withTimeout(UPLOAD_TIMEOUT_MS, async (signal) => {
|
|
57
|
+
const res = await doFetch(
|
|
58
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { authorization: `Bearer ${apiToken}` },
|
|
62
|
+
body: form,
|
|
63
|
+
signal
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
const json = await res.json().catch(() => null);
|
|
67
|
+
if (!res.ok || !json?.success || !json.result?.id) {
|
|
68
|
+
const msg = json?.errors?.map((e) => e.message).join("; ") || `HTTP ${res.status}`;
|
|
69
|
+
throw new Error(`CF Images \uC5C5\uB85C\uB4DC \uC2E4\uD328: ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
return json.result.id;
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
variantUrls(imageId) {
|
|
75
|
+
return allVariantUrls(config, imageId);
|
|
76
|
+
},
|
|
77
|
+
async deleteImage(imageId) {
|
|
78
|
+
await withTimeout(DELETE_TIMEOUT_MS, async (signal) => {
|
|
79
|
+
const res = await doFetch(
|
|
80
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${encodeURIComponent(imageId)}`,
|
|
81
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${apiToken}` }, signal }
|
|
82
|
+
);
|
|
83
|
+
if (!res.ok && res.status !== 404) {
|
|
84
|
+
throw new Error(`CF Images \uC0AD\uC81C \uC2E4\uD328: HTTP ${res.status}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function buildImageVariants(client, sourceUrl, onError) {
|
|
91
|
+
if (!client) return {};
|
|
92
|
+
try {
|
|
93
|
+
const imageId = await client.uploadFromUrl(sourceUrl);
|
|
94
|
+
return {
|
|
95
|
+
variantsJson: client.variantUrls(imageId),
|
|
96
|
+
metaJson: { cfImageId: imageId }
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
onError?.(err);
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
1
104
|
// src/normalize-filename.ts
|
|
2
105
|
import { createHash } from "crypto";
|
|
3
106
|
var ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
@@ -150,49 +253,16 @@ var S3R2Client = class {
|
|
|
150
253
|
return `${base}/${key}`;
|
|
151
254
|
}
|
|
152
255
|
};
|
|
153
|
-
|
|
154
|
-
// src/variants.ts
|
|
155
|
-
var VARIANT_NAMES = [
|
|
156
|
-
"thumbnail",
|
|
157
|
-
"sm",
|
|
158
|
-
"md",
|
|
159
|
-
"lg"
|
|
160
|
-
];
|
|
161
|
-
var VARIANT_SIZES = {
|
|
162
|
-
thumbnail: { width: 96, height: 96 },
|
|
163
|
-
sm: { width: 320, height: 320 },
|
|
164
|
-
md: { width: 768, height: 768 },
|
|
165
|
-
lg: { width: 1600, height: 1600 }
|
|
166
|
-
};
|
|
167
|
-
function variantUrl(config, imageId, variant) {
|
|
168
|
-
return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;
|
|
169
|
-
}
|
|
170
|
-
function allVariantUrls(config, imageId) {
|
|
171
|
-
return Object.fromEntries(
|
|
172
|
-
VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)])
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
var CF_HOST = "imagedelivery.net";
|
|
176
|
-
var KNOWN_VARIANT_SUFFIX = /\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;
|
|
177
|
-
function isCfImageUrl(url) {
|
|
178
|
-
return typeof url === "string" && url.includes(CF_HOST);
|
|
179
|
-
}
|
|
180
|
-
function cfImageVariant(url, variant) {
|
|
181
|
-
if (!isCfImageUrl(url)) return url;
|
|
182
|
-
return KNOWN_VARIANT_SUFFIX.test(url) ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`) : `${url}/${variant}`;
|
|
183
|
-
}
|
|
184
|
-
function cfImageSrcSet(url, variants = ["sm", "md", "lg"]) {
|
|
185
|
-
if (!isCfImageUrl(url)) return void 0;
|
|
186
|
-
return variants.map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`).join(", ");
|
|
187
|
-
}
|
|
188
256
|
export {
|
|
189
257
|
S3R2Client,
|
|
190
258
|
TenantR2,
|
|
191
259
|
VARIANT_NAMES,
|
|
192
260
|
VARIANT_SIZES,
|
|
193
261
|
allVariantUrls,
|
|
262
|
+
buildImageVariants,
|
|
194
263
|
cfImageSrcSet,
|
|
195
264
|
cfImageVariant,
|
|
265
|
+
createCfImagesClient,
|
|
196
266
|
isCfImageUrl,
|
|
197
267
|
normalizeFilename,
|
|
198
268
|
r2KeyForTenant,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/normalize-filename.ts","../src/r2-client.ts","../src/s3-client.ts","../src/variants.ts"],"sourcesContent":["// ADR-0034 §7.1.1 test #4 — Korean filename normalization (한글 → hash)\n//\n// R2 object key 안전성: `safe` 는 **항상** 단일 path segment 로 안전하다 —\n// - 허용 문자: [a-zA-Z0-9._-] 만 (slash·공백·제어문자·CJK 불가)\n// - 디렉터리 성분(`/`·`\\`)·traversal(`..`)은 basename 추출 단계에서 제거\n// - 영숫자를 최소 1개 보장(dot-only `.`·`..` 방지), 길이 상한 적용\n// - 한글/CJK·비ASCII 는 base32(sha256) hash 로 변환, 확장자는 보존\n// 호출부가 `tenants/<id>/<category>/<safe>` 형태로 써도 prefix 를 벗어날 수 없다.\n\nimport { createHash } from \"node:crypto\";\n\n/** safe stem 에 허용되는 문자 — 단일 segment 안전 집합. */\nconst ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;\n/** 유효한 확장자 — dot + 영숫자만 (예: \".jpg\", \".webp\"). slash/공백 섞이면 무효. */\nconst EXT_PATTERN = /^\\.[a-z0-9]+$/;\n/** 영숫자 1개 이상 포함 여부 — dot-only/빈 stem 방지용. */\nconst HAS_ALNUM = /[a-z0-9]/i;\n/** safe stem 길이 상한 (R2 key 길이 예산). 확장자는 별도. */\nconst MAX_STEM_LEN = 80;\nconst MAX_ALT_LEN = 120;\nconst MAX_SEO_STEM_LEN = 96;\n\nexport interface NormalizedFilename {\n /** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */\n safe: string;\n /** 원본 파일명 (UI 표시용, DB media.original_name) */\n original: string;\n /** 확장자 (소문자, dot 포함; e.g. \".jpg\"). 없으면 \"\". */\n ext: string;\n}\n\n/** 짧은 base32 hash (16자). UUID 와 충돌 회피용 별도 namespace. */\nfunction shortHash(input: string): string {\n return createHash(\"sha256\")\n .update(input)\n .digest(\"base64url\")\n .slice(0, 16)\n .replace(/[-_]/g, \"x\");\n}\n\n/** 디렉터리 성분 제거 — basename 만. `/`·`\\` 모두 분리(크로스 플랫폼 파일명 방어). */\nfunction basenameOf(filename: string): string {\n const cut = Math.max(filename.lastIndexOf(\"/\"), filename.lastIndexOf(\"\\\\\"));\n return cut >= 0 ? filename.slice(cut + 1) : filename;\n}\n\n/**\n * 확장자 추출 — basename 기준 마지막 dot 이후. dot+영숫자(EXT_PATTERN) 만 유효로\n * 인정하고, 그 외(slash·공백 포함 등)는 확장자 없음으로 처리한다.\n */\nfunction extractExt(basename: string): string {\n const dotIdx = basename.lastIndexOf(\".\");\n if (dotIdx <= 0 || dotIdx === basename.length - 1) return \"\";\n const ext = basename.slice(dotIdx).toLowerCase();\n return EXT_PATTERN.test(ext) ? ext : \"\";\n}\n\nfunction stemOfFilename(filename: string): string {\n const basename = basenameOf(filename);\n const ext = extractExt(basename);\n return ext ? basename.slice(0, basename.length - ext.length) : basename;\n}\n\n/**\n * 파일명 정규화. ASCII-safe(영숫자 포함) 이면 소문자화해 그대로, 아니면 hash.\n * 결과 `safe` 는 항상 단일 segment 로 안전(위 모듈 주석 불변식).\n */\nexport function normalizeFilename(original: string): NormalizedFilename {\n const basename = basenameOf(original);\n const ext = extractExt(basename);\n const stem = stemOfFilename(original);\n\n let safeStem: string;\n if (ASCII_SAFE_PATTERN.test(stem) && HAS_ALNUM.test(stem)) {\n safeStem = stem.toLowerCase().slice(0, MAX_STEM_LEN);\n } else {\n // 비ASCII·slash·공백·dot-only·빈 stem → hash (원본 기준 안정적 변환).\n safeStem = shortHash(stem || basename || original);\n }\n\n // 방어적 재확인 — 어떤 경로로도 영숫자 없는 stem 은 남기지 않는다.\n if (!HAS_ALNUM.test(safeStem)) {\n safeStem = shortHash(original);\n }\n\n return { safe: `${safeStem}${ext}`, original, ext };\n}\n\n/**\n * 파일명에서 SEO/accessibility 기본 alt 를 제안한다.\n *\n * 원본 파일명을 그대로 노출하지 않고, 확장자·경로·구분자·카메라/스크린샷\n * 잡음을 제거해 사람이 읽는 짧은 설명으로 바꾼다. 실제 문맥이 있는 surface\n * (예: 작성자 프로필)는 호출부가 더 구체적인 alt 로 override 할 수 있다.\n */\nexport function suggestAltFromFilename(original: string): string {\n let text = stemOfFilename(original)\n .normalize(\"NFKC\")\n .replace(/%[0-9a-f]{2}/gi, \" \")\n .replace(/[()[\\]{}]/g, \" \")\n .replace(/[._-]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n text = text\n .replace(/\\b(copy|final|edited|edit|export|upload)\\b/gi, \" \")\n .replace(/\\b(screenshot|screen shot|image|img|photo|picture|pic)\\b/gi, \" \")\n .replace(/\\b(dsc|dcim|pxl|kakaotalk|wechatimg)\\s*\\d+\\b/gi, \" \")\n .replace(/\\b\\d{3,4}\\s*x\\s*\\d{3,4}\\b/gi, \" \")\n .replace(/\\b(19|20)\\d{2}[ ._-]?(0[1-9]|1[0-2])[ ._-]?([0-2]\\d|3[01])\\b/g, \" \")\n .replace(/\\b\\d{4,}\\b/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n if (!text || !/[0-9A-Za-z가-힣一-龥ぁ-んァ-ン]/.test(text)) {\n return \"업로드 이미지\";\n }\n\n const capped = text.slice(0, MAX_ALT_LEN).trim();\n return capped.charAt(0).toLocaleUpperCase(\"ko-KR\") + capped.slice(1);\n}\n\n/**\n * 업로드 객체명에 붙일 SEO-friendly filename.\n *\n * URL 경로에 의미 있는 단어가 남도록 한글/CJK/라틴 문자와 숫자는 보존하고,\n * 나머지는 hyphen 으로 접는다. 호출부는 앞에 UUID 를 붙여 충돌을 피한다.\n */\nexport function seoFilenameFromFilename(\n original: string,\n fallbackExt = \"\",\n): string {\n const ext = extractExt(basenameOf(original)) || fallbackExt.toLowerCase();\n const alt = suggestAltFromFilename(original);\n const source = alt === \"업로드 이미지\" ? \"uploaded image\" : alt;\n const stem = source\n .normalize(\"NFKC\")\n .toLocaleLowerCase(\"ko-KR\")\n .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, MAX_SEO_STEM_LEN)\n .replace(/-$/g, \"\");\n const safeStem = stem || \"uploaded-image\";\n return `${safeStem}${EXT_PATTERN.test(ext) ? ext : \"\"}`;\n}\n","// ADR-0034 §7.1.1 test #4 — R2 client wrapper (interface only, Phase 1 stub).\n//\n// Phase 1 environment-specific implementation:\n// - Cloudflare Workers/Pages = R2 binding (env.R2.put/get/delete)\n// - Vercel/Node = AWS SDK S3 client (S3-compatible API)\n//\n// 본 모듈은 interface + Korean filename + tenant prefix 강제 로직만.\n// 실 binding 은 admin-tenant 의 server action 또는 tenant-api 의 R2 handler 가 주입.\n\nimport { normalizeFilename } from \"./normalize-filename\";\n\n/** ADR-0016 multi-tenant 격리 의무 — r2_key prefix = `tenants/<tenant_id>/` */\nexport function r2KeyForTenant(\n tenantId: string,\n category: string,\n filename: string,\n): string {\n const { safe } = normalizeFilename(filename);\n return `tenants/${tenantId}/${category}/${safe}`;\n}\n\nexport interface R2PutOptions {\n contentType: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface R2Object {\n key: string;\n size: number;\n etag: string;\n uploaded: Date;\n contentType?: string;\n metadata?: Record<string, string>;\n}\n\n/**\n * R2 client interface — environment-specific binding 이 본 interface 구현.\n * admin-tenant 가 server action 안에서 inject (DI).\n */\nexport interface R2Client {\n put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;\n get(key: string): Promise<ArrayBuffer | null>;\n delete(key: string): Promise<void>;\n /** presigned URL — 외부 캐싱/CDN 용. Phase 1 = direct R2 public bucket */\n getPublicUrl(key: string): string;\n}\n\n/**\n * Tenant-aware R2 helper — key prefix 강제 + Korean filename normalize 자동.\n * tenant 외부에서 본 wrapper 우회하면 ESLint 의 no-raw-db-client 와 비슷한 rule\n * 추가 검토 (Phase 1b).\n */\nexport class TenantR2 {\n constructor(\n private readonly client: R2Client,\n private readonly tenantId: string,\n ) {}\n\n async uploadMedia(\n filename: string,\n body: ArrayBuffer | Uint8Array,\n contentType: string,\n ): Promise<{ key: string; publicUrl: string; object: R2Object }> {\n const key = r2KeyForTenant(this.tenantId, \"media\", filename);\n const object = await this.client.put(key, body, { contentType });\n return { key, publicUrl: this.client.getPublicUrl(key), object };\n }\n\n async deleteByKey(key: string): Promise<void> {\n if (!key.startsWith(`tenants/${this.tenantId}/`)) {\n throw new Error(\n `tenant 격리 위반: ${key} 는 tenants/${this.tenantId}/ prefix 와 일치하지 않음`,\n );\n }\n await this.client.delete(key);\n }\n}\n","// ADR-0034 §7.1.1 test #4 — AWS S3 SDK based R2Client 실 구현.\n//\n// Cloudflare R2 는 S3-compatible API 노출. @aws-sdk/client-s3 wrapper.\n// caller 가 endpoint + access key (Doppler) 주입.\n\nimport type { R2Client, R2Object, R2PutOptions } from \"./r2-client\";\n\nexport interface S3ClientConfig {\n endpoint: string; // e.g. https://<account>.r2.cloudflarestorage.com\n region?: string; // R2 = \"auto\"\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n publicBaseUrl: string; // CDN 또는 R2 public URL prefix\n}\n\n/**\n * AWS SDK 의존을 minimal — type-only declaration.\n * 실 binding 은 admin-tenant 의 server action 또는 tenant-api 가 SDK 주입.\n * 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.\n */\ninterface MinimalS3Sdk {\n send(command: unknown): Promise<{ ETag?: string; ContentLength?: number; Body?: AsyncIterable<Uint8Array> }>;\n}\n\ninterface S3PutObjectCommandInput {\n Bucket: string;\n Key: string;\n Body: Uint8Array;\n ContentType: string;\n CacheControl?: string;\n Metadata?: Record<string, string>;\n}\n\ninterface S3GetObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\ninterface S3DeleteObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\nexport interface S3CommandFactories {\n PutObjectCommand: new (input: S3PutObjectCommandInput) => unknown;\n GetObjectCommand: new (input: S3GetObjectCommandInput) => unknown;\n DeleteObjectCommand: new (input: S3DeleteObjectCommandInput) => unknown;\n}\n\n/**\n * R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.\n *\n * 사용 예시 (admin-tenant):\n * import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }\n * from \"@aws-sdk/client-s3\";\n * const r2 = new S3R2Client(\n * new S3Client({ endpoint, region: \"auto\", credentials: {...} }),\n * { PutObjectCommand, GetObjectCommand, DeleteObjectCommand },\n * config,\n * );\n */\nexport class S3R2Client implements R2Client {\n constructor(\n private readonly sdk: MinimalS3Sdk,\n private readonly commands: S3CommandFactories,\n private readonly config: S3ClientConfig,\n ) {}\n\n async put(\n key: string,\n body: ArrayBuffer | Uint8Array,\n options: R2PutOptions,\n ): Promise<R2Object> {\n const buf = body instanceof Uint8Array ? body : new Uint8Array(body);\n const cmd = new this.commands.PutObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n Body: buf,\n ContentType: options.contentType,\n CacheControl: options.cacheControl,\n Metadata: options.metadata,\n });\n const result = await this.sdk.send(cmd);\n return {\n key,\n size: buf.byteLength,\n etag: result.ETag ?? \"\",\n uploaded: new Date(),\n contentType: options.contentType,\n metadata: options.metadata,\n };\n }\n\n async get(key: string): Promise<ArrayBuffer | null> {\n const cmd = new this.commands.GetObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n try {\n const result = await this.sdk.send(cmd);\n if (!result.Body) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of result.Body) {\n chunks.push(chunk);\n }\n const total = chunks.reduce((a, c) => a + c.byteLength, 0);\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return merged.buffer;\n } catch (_err) {\n return null;\n }\n }\n\n async delete(key: string): Promise<void> {\n const cmd = new this.commands.DeleteObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n await this.sdk.send(cmd);\n }\n\n getPublicUrl(key: string): string {\n const base = this.config.publicBaseUrl.replace(/\\/$/, \"\");\n return `${base}/${key}`;\n }\n}\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":";AASA,SAAS,kBAAkB;AAG3B,IAAM,qBAAqB;AAE3B,IAAM,cAAc;AAEpB,IAAM,YAAY;AAElB,IAAM,eAAe;AACrB,IAAM,cAAc;AACpB,IAAM,mBAAmB;AAYzB,SAAS,UAAU,OAAuB;AACxC,SAAO,WAAW,QAAQ,EACvB,OAAO,KAAK,EACZ,OAAO,WAAW,EAClB,MAAM,GAAG,EAAE,EACX,QAAQ,SAAS,GAAG;AACzB;AAGA,SAAS,WAAW,UAA0B;AAC5C,QAAM,MAAM,KAAK,IAAI,SAAS,YAAY,GAAG,GAAG,SAAS,YAAY,IAAI,CAAC;AAC1E,SAAO,OAAO,IAAI,SAAS,MAAM,MAAM,CAAC,IAAI;AAC9C;AAMA,SAAS,WAAW,UAA0B;AAC5C,QAAM,SAAS,SAAS,YAAY,GAAG;AACvC,MAAI,UAAU,KAAK,WAAW,SAAS,SAAS,EAAG,QAAO;AAC1D,QAAM,MAAM,SAAS,MAAM,MAAM,EAAE,YAAY;AAC/C,SAAO,YAAY,KAAK,GAAG,IAAI,MAAM;AACvC;AAEA,SAAS,eAAe,UAA0B;AAChD,QAAM,WAAW,WAAW,QAAQ;AACpC,QAAM,MAAM,WAAW,QAAQ;AAC/B,SAAO,MAAM,SAAS,MAAM,GAAG,SAAS,SAAS,IAAI,MAAM,IAAI;AACjE;AAMO,SAAS,kBAAkB,UAAsC;AACtE,QAAM,WAAW,WAAW,QAAQ;AACpC,QAAM,MAAM,WAAW,QAAQ;AAC/B,QAAM,OAAO,eAAe,QAAQ;AAEpC,MAAI;AACJ,MAAI,mBAAmB,KAAK,IAAI,KAAK,UAAU,KAAK,IAAI,GAAG;AACzD,eAAW,KAAK,YAAY,EAAE,MAAM,GAAG,YAAY;AAAA,EACrD,OAAO;AAEL,eAAW,UAAU,QAAQ,YAAY,QAAQ;AAAA,EACnD;AAGA,MAAI,CAAC,UAAU,KAAK,QAAQ,GAAG;AAC7B,eAAW,UAAU,QAAQ;AAAA,EAC/B;AAEA,SAAO,EAAE,MAAM,GAAG,QAAQ,GAAG,GAAG,IAAI,UAAU,IAAI;AACpD;AASO,SAAS,uBAAuB,UAA0B;AAC/D,MAAI,OAAO,eAAe,QAAQ,EAC/B,UAAU,MAAM,EAChB,QAAQ,kBAAkB,GAAG,EAC7B,QAAQ,cAAc,GAAG,EACzB,QAAQ,WAAW,GAAG,EACtB,QAAQ,QAAQ,GAAG,EACnB,KAAK;AAER,SAAO,KACJ,QAAQ,gDAAgD,GAAG,EAC3D,QAAQ,8DAA8D,GAAG,EACzE,QAAQ,kDAAkD,GAAG,EAC7D,QAAQ,+BAA+B,GAAG,EAC1C,QAAQ,iEAAiE,GAAG,EAC5E,QAAQ,eAAe,GAAG,EAC1B,QAAQ,QAAQ,GAAG,EACnB,KAAK;AAER,MAAI,CAAC,QAAQ,CAAC,0BAA0B,KAAK,IAAI,GAAG;AAClD,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,KAAK,MAAM,GAAG,WAAW,EAAE,KAAK;AAC/C,SAAO,OAAO,OAAO,CAAC,EAAE,kBAAkB,OAAO,IAAI,OAAO,MAAM,CAAC;AACrE;AAQO,SAAS,wBACd,UACA,cAAc,IACN;AACR,QAAM,MAAM,WAAW,WAAW,QAAQ,CAAC,KAAK,YAAY,YAAY;AACxE,QAAM,MAAM,uBAAuB,QAAQ;AAC3C,QAAM,SAAS,QAAQ,0CAAY,mBAAmB;AACtD,QAAM,OAAO,OACV,UAAU,MAAM,EAChB,kBAAkB,OAAO,EACzB,QAAQ,oBAAoB,GAAG,EAC/B,QAAQ,OAAO,GAAG,EAClB,QAAQ,UAAU,EAAE,EACpB,MAAM,GAAG,gBAAgB,EACzB,QAAQ,OAAO,EAAE;AACpB,QAAM,WAAW,QAAQ;AACzB,SAAO,GAAG,QAAQ,GAAG,YAAY,KAAK,GAAG,IAAI,MAAM,EAAE;AACvD;;;ACrIO,SAAS,eACd,UACA,UACA,UACQ;AACR,QAAM,EAAE,KAAK,IAAI,kBAAkB,QAAQ;AAC3C,SAAO,WAAW,QAAQ,IAAI,QAAQ,IAAI,IAAI;AAChD;AAkCO,IAAM,WAAN,MAAe;AAAA,EACpB,YACmB,QACA,UACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAFgB;AAAA,EACA;AAAA,EAGnB,MAAM,YACJ,UACA,MACA,aAC+D;AAC/D,UAAM,MAAM,eAAe,KAAK,UAAU,SAAS,QAAQ;AAC3D,UAAM,SAAS,MAAM,KAAK,OAAO,IAAI,KAAK,MAAM,EAAE,YAAY,CAAC;AAC/D,WAAO,EAAE,KAAK,WAAW,KAAK,OAAO,aAAa,GAAG,GAAG,OAAO;AAAA,EACjE;AAAA,EAEA,MAAM,YAAY,KAA4B;AAC5C,QAAI,CAAC,IAAI,WAAW,WAAW,KAAK,QAAQ,GAAG,GAAG;AAChD,YAAM,IAAI;AAAA,QACR,qCAAiB,GAAG,mBAAc,KAAK,QAAQ;AAAA,MACjD;AAAA,IACF;AACA,UAAM,KAAK,OAAO,OAAO,GAAG;AAAA,EAC9B;AACF;;;ACfO,IAAM,aAAN,MAAqC;AAAA,EAC1C,YACmB,KACA,UACA,QACjB;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAHgB;AAAA,EACA;AAAA,EACA;AAAA,EAGnB,MAAM,IACJ,KACA,MACA,SACmB;AACnB,UAAM,MAAM,gBAAgB,aAAa,OAAO,IAAI,WAAW,IAAI;AACnE,UAAM,MAAM,IAAI,KAAK,SAAS,iBAAiB;AAAA,MAC7C,QAAQ,KAAK,OAAO;AAAA,MACpB,KAAK;AAAA,MACL,MAAM;AAAA,MACN,aAAa,QAAQ;AAAA,MACrB,cAAc,QAAQ;AAAA,MACtB,UAAU,QAAQ;AAAA,IACpB,CAAC;AACD,UAAM,SAAS,MAAM,KAAK,IAAI,KAAK,GAAG;AACtC,WAAO;AAAA,MACL;AAAA,MACA,MAAM,IAAI;AAAA,MACV,MAAM,OAAO,QAAQ;AAAA,MACrB,UAAU,oBAAI,KAAK;AAAA,MACnB,aAAa,QAAQ;AAAA,MACrB,UAAU,QAAQ;AAAA,IACpB;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,KAA0C;AAClD,UAAM,MAAM,IAAI,KAAK,SAAS,iBAAiB;AAAA,MAC7C,QAAQ,KAAK,OAAO;AAAA,MACpB,KAAK;AAAA,IACP,CAAC;AACD,QAAI;AACF,YAAM,SAAS,MAAM,KAAK,IAAI,KAAK,GAAG;AACtC,UAAI,CAAC,OAAO,KAAM,QAAO;AACzB,YAAM,SAAuB,CAAC;AAC9B,uBAAiB,SAAS,OAAO,MAAM;AACrC,eAAO,KAAK,KAAK;AAAA,MACnB;AACA,YAAM,QAAQ,OAAO,OAAO,CAAC,GAAG,MAAM,IAAI,EAAE,YAAY,CAAC;AACzD,YAAM,SAAS,IAAI,WAAW,KAAK;AACnC,UAAI,SAAS;AACb,iBAAW,SAAS,QAAQ;AAC1B,eAAO,IAAI,OAAO,MAAM;AACxB,kBAAU,MAAM;AAAA,MAClB;AACA,aAAO,OAAO;AAAA,IAChB,SAAS,MAAM;AACb,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,KAA4B;AACvC,UAAM,MAAM,IAAI,KAAK,SAAS,oBAAoB;AAAA,MAChD,QAAQ,KAAK,OAAO;AAAA,MACpB,KAAK;AAAA,IACP,CAAC;AACD,UAAM,KAAK,IAAI,KAAK,GAAG;AAAA,EACzB;AAAA,EAEA,aAAa,KAAqB;AAChC,UAAM,OAAO,KAAK,OAAO,cAAc,QAAQ,OAAO,EAAE;AACxD,WAAO,GAAG,IAAI,IAAI,GAAG;AAAA,EACvB;AACF;;;AC5HO,IAAM,gBAAyC;AAAA,EACpD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AASO,IAAM,gBAA8B;AAAA,EACzC,WAAW,EAAE,OAAO,IAAI,QAAQ,GAAG;AAAA,EACnC,IAAI,EAAE,OAAO,KAAK,QAAQ,IAAI;AAAA,EAC9B,IAAI,EAAE,OAAO,KAAK,QAAQ,IAAI;AAAA,EAC9B,IAAI,EAAE,OAAO,MAAM,QAAQ,KAAK;AAClC;AAOO,SAAS,WACd,QACA,SACA,SACQ;AACR,SAAO,6BAA6B,OAAO,WAAW,IAAI,OAAO,IAAI,OAAO;AAC9E;AAGO,SAAS,eACd,QACA,SAC8B;AAC9B,SAAO,OAAO;AAAA,IACZ,cAAc,IAAI,CAAC,MAAM,CAAC,GAAG,WAAW,QAAQ,SAAS,CAAC,CAAC,CAAC;AAAA,EAC9D;AACF;AAKA,IAAM,UAAU;AAChB,IAAM,uBACJ;AAGK,SAAS,aAAa,KAAsB;AACjD,SAAO,OAAO,QAAQ,YAAY,IAAI,SAAS,OAAO;AACxD;AAMO,SAAS,eACd,KACA,SACQ;AACR,MAAI,CAAC,aAAa,GAAG,EAAG,QAAO;AAC/B,SAAO,qBAAqB,KAAK,GAAG,IAChC,IAAI,QAAQ,sBAAsB,IAAI,OAAO,EAAE,IAC/C,GAAG,GAAG,IAAI,OAAO;AACvB;AAGO,SAAS,cACd,KACA,WAAoC,CAAC,MAAM,MAAM,IAAI,GACjC;AACpB,MAAI,CAAC,aAAa,GAAG,EAAG,QAAO;AAC/B,SAAO,SACJ,IAAI,CAAC,MAAM,GAAG,eAAe,KAAK,CAAC,CAAC,IAAI,cAAc,CAAC,EAAE,KAAK,GAAG,EACjE,KAAK,IAAI;AACd;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/variants.ts","../src/cf-images.ts","../src/normalize-filename.ts","../src/r2-client.ts","../src/s3-client.ts"],"sourcesContent":["// 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","// CF Images 업로드/삭제 seam (ADR-0034 §7.1.1 test #4) — variants.ts 가 delivery\n// URL 을 *조립*만 하던 것을, 실제 CF Images 업로드(→ image id)로 뒷받침한다.\n//\n// **이 파일이 단일 seam이다.** 예전엔 이 구현이 tenant-admin 안에만 있어서, 같은 일을\n// 하는 REST 업로드 경로(api-core `/v1/cms/media/uploads/complete`)가 CF Images 단계를\n// 통째로 빠뜨렸다. 그 결과 REST 로 올린 미디어는 `variants_json` 이 `{}` 로 남고,\n// 공개 posts API 는 featured 이미지를 variants_json 에서만 해석하므로\n// `featured_image_url` 이 영원히 null 이었다 (2026-07-26 발견 · 업로드·글 생성이\n// 모두 201 로 성공해 화면에서만 이미지가 빠지는 조용한 실패였다).\n//\n// 새 소비자는 반드시 이 팩토리를 쓸 것. 자격증명은 주입받는다 — Workers(env 바인딩)와\n// Next.js(process.env) 양쪽에서 같은 코드가 돌아야 하므로 여기서 env 를 읽지 않는다.\n\nimport { allVariantUrls, type CfImagesConfig, type ImageVariant } from \"./variants\";\n\n/** CF 가 sourceUrl 을 서버측에서 fetch → 보통 빠르나 큰 이미지 대비 넉넉히. */\nconst UPLOAD_TIMEOUT_MS = 15_000;\nconst DELETE_TIMEOUT_MS = 10_000;\n\nexport interface CfImagesCredentials {\n /** Cloudflare account id. */\n accountId: string;\n /** Images 쓰기 권한 토큰. */\n apiToken: string;\n /** imagedelivery.net account hash. */\n accountHash: string;\n /** 테스트/워커 주입용. 미지정 시 전역 fetch. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface CfImagesClient {\n /**\n * R2 public URL 의 이미지를 CF Images 로 \"upload via URL\" 한다.\n * @returns CF Images image id. 실패 시 throw (호출부가 명확히 인지).\n */\n uploadFromUrl(sourceUrl: string): Promise<string>;\n /** image id → 4 variant(thumbnail/sm/md/lg) delivery URL map. */\n variantUrls(imageId: string): Record<ImageVariant, string>;\n /** CF Images 이미지 삭제 (orphan 방지). 실패 시 throw — 호출부가 best-effort 처리. */\n deleteImage(imageId: string): Promise<void>;\n}\n\n/**\n * CF Images 클라이언트. 자격증명 3종을 모두 받아야 만들 수 있다 —\n * 미설정 판단(graceful skip)은 호출부 책임이다.\n */\nexport function createCfImagesClient(\n credentials: CfImagesCredentials,\n): CfImagesClient {\n const { accountId, apiToken, accountHash } = credentials;\n const config: CfImagesConfig = { accountHash };\n // 호출 시점에 해석해야 테스트의 global fetch 모킹이 적용된다.\n const doFetch = (input: RequestInfo | URL, init?: RequestInit) =>\n (credentials.fetchImpl ?? globalThis.fetch)(input, init);\n\n async function withTimeout<T>(\n timeoutMs: number,\n run: (signal: AbortSignal) => Promise<T>,\n ): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await run(controller.signal);\n } finally {\n clearTimeout(timer);\n }\n }\n\n return {\n async uploadFromUrl(sourceUrl: string): Promise<string> {\n const form = new FormData();\n form.set(\"url\", sourceUrl);\n\n return withTimeout(UPLOAD_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,\n {\n method: \"POST\",\n headers: { authorization: `Bearer ${apiToken}` },\n body: form,\n signal,\n },\n );\n const json = (await res.json().catch(() => null)) as {\n success?: boolean;\n result?: { id?: string };\n errors?: Array<{ message?: string }>;\n } | null;\n if (!res.ok || !json?.success || !json.result?.id) {\n const msg =\n json?.errors?.map((e) => e.message).join(\"; \") ||\n `HTTP ${res.status}`;\n throw new Error(`CF Images 업로드 실패: ${msg}`);\n }\n return json.result.id;\n });\n },\n\n variantUrls(imageId: string) {\n return allVariantUrls(config, imageId);\n },\n\n async deleteImage(imageId: string): Promise<void> {\n await withTimeout(DELETE_TIMEOUT_MS, async (signal) => {\n const res = await doFetch(\n `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${encodeURIComponent(imageId)}`,\n { method: \"DELETE\", headers: { authorization: `Bearer ${apiToken}` }, signal },\n );\n // 404 = 이미 없음 → idempotent 성공으로 간주.\n if (!res.ok && res.status !== 404) {\n throw new Error(`CF Images 삭제 실패: HTTP ${res.status}`);\n }\n });\n },\n };\n}\n\nexport interface ImageVariantsResult {\n /** CF Images delivery URL map. 미연결·실패 시 undefined. */\n variantsJson?: Record<ImageVariant, string>;\n /** `{ cfImageId }` — 삭제 시 orphan 정리에 쓴다. 미연결·실패 시 undefined. */\n metaJson?: { cfImageId: string };\n}\n\n/**\n * 업로드된 이미지의 variant/meta 를 만든다 — **모든 업로드 경로의 공통 헬퍼**.\n *\n * 이미지가 아니거나 CF Images 미연결(client=null)이면 graceful skip, CF 실패도\n * 삼켜서 빈 결과로 떨어뜨린다(미디어 등록 자체는 성공시킨다 — variant 없이도\n * 원본은 R2 에 있다). 업로드 경로가 늘어나도 결과 모양이 갈라지지 않도록\n * variant 채우기는 이 헬퍼를 통해서만 한다.\n */\nexport async function buildImageVariants(\n client: CfImagesClient | null,\n sourceUrl: string,\n onError?: (err: unknown) => void,\n): Promise<ImageVariantsResult> {\n if (!client) return {};\n try {\n const imageId = await client.uploadFromUrl(sourceUrl);\n return {\n variantsJson: client.variantUrls(imageId),\n metaJson: { cfImageId: imageId },\n };\n } catch (err) {\n onError?.(err);\n return {};\n }\n}\n","// ADR-0034 §7.1.1 test #4 — Korean filename normalization (한글 → hash)\n//\n// R2 object key 안전성: `safe` 는 **항상** 단일 path segment 로 안전하다 —\n// - 허용 문자: [a-zA-Z0-9._-] 만 (slash·공백·제어문자·CJK 불가)\n// - 디렉터리 성분(`/`·`\\`)·traversal(`..`)은 basename 추출 단계에서 제거\n// - 영숫자를 최소 1개 보장(dot-only `.`·`..` 방지), 길이 상한 적용\n// - 한글/CJK·비ASCII 는 base32(sha256) hash 로 변환, 확장자는 보존\n// 호출부가 `tenants/<id>/<category>/<safe>` 형태로 써도 prefix 를 벗어날 수 없다.\n\nimport { createHash } from \"node:crypto\";\n\n/** safe stem 에 허용되는 문자 — 단일 segment 안전 집합. */\nconst ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;\n/** 유효한 확장자 — dot + 영숫자만 (예: \".jpg\", \".webp\"). slash/공백 섞이면 무효. */\nconst EXT_PATTERN = /^\\.[a-z0-9]+$/;\n/** 영숫자 1개 이상 포함 여부 — dot-only/빈 stem 방지용. */\nconst HAS_ALNUM = /[a-z0-9]/i;\n/** safe stem 길이 상한 (R2 key 길이 예산). 확장자는 별도. */\nconst MAX_STEM_LEN = 80;\nconst MAX_ALT_LEN = 120;\nconst MAX_SEO_STEM_LEN = 96;\n\nexport interface NormalizedFilename {\n /** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */\n safe: string;\n /** 원본 파일명 (UI 표시용, DB media.original_name) */\n original: string;\n /** 확장자 (소문자, dot 포함; e.g. \".jpg\"). 없으면 \"\". */\n ext: string;\n}\n\n/** 짧은 base32 hash (16자). UUID 와 충돌 회피용 별도 namespace. */\nfunction shortHash(input: string): string {\n return createHash(\"sha256\")\n .update(input)\n .digest(\"base64url\")\n .slice(0, 16)\n .replace(/[-_]/g, \"x\");\n}\n\n/** 디렉터리 성분 제거 — basename 만. `/`·`\\` 모두 분리(크로스 플랫폼 파일명 방어). */\nfunction basenameOf(filename: string): string {\n const cut = Math.max(filename.lastIndexOf(\"/\"), filename.lastIndexOf(\"\\\\\"));\n return cut >= 0 ? filename.slice(cut + 1) : filename;\n}\n\n/**\n * 확장자 추출 — basename 기준 마지막 dot 이후. dot+영숫자(EXT_PATTERN) 만 유효로\n * 인정하고, 그 외(slash·공백 포함 등)는 확장자 없음으로 처리한다.\n */\nfunction extractExt(basename: string): string {\n const dotIdx = basename.lastIndexOf(\".\");\n if (dotIdx <= 0 || dotIdx === basename.length - 1) return \"\";\n const ext = basename.slice(dotIdx).toLowerCase();\n return EXT_PATTERN.test(ext) ? ext : \"\";\n}\n\nfunction stemOfFilename(filename: string): string {\n const basename = basenameOf(filename);\n const ext = extractExt(basename);\n return ext ? basename.slice(0, basename.length - ext.length) : basename;\n}\n\n/**\n * 파일명 정규화. ASCII-safe(영숫자 포함) 이면 소문자화해 그대로, 아니면 hash.\n * 결과 `safe` 는 항상 단일 segment 로 안전(위 모듈 주석 불변식).\n */\nexport function normalizeFilename(original: string): NormalizedFilename {\n const basename = basenameOf(original);\n const ext = extractExt(basename);\n const stem = stemOfFilename(original);\n\n let safeStem: string;\n if (ASCII_SAFE_PATTERN.test(stem) && HAS_ALNUM.test(stem)) {\n safeStem = stem.toLowerCase().slice(0, MAX_STEM_LEN);\n } else {\n // 비ASCII·slash·공백·dot-only·빈 stem → hash (원본 기준 안정적 변환).\n safeStem = shortHash(stem || basename || original);\n }\n\n // 방어적 재확인 — 어떤 경로로도 영숫자 없는 stem 은 남기지 않는다.\n if (!HAS_ALNUM.test(safeStem)) {\n safeStem = shortHash(original);\n }\n\n return { safe: `${safeStem}${ext}`, original, ext };\n}\n\n/**\n * 파일명에서 SEO/accessibility 기본 alt 를 제안한다.\n *\n * 원본 파일명을 그대로 노출하지 않고, 확장자·경로·구분자·카메라/스크린샷\n * 잡음을 제거해 사람이 읽는 짧은 설명으로 바꾼다. 실제 문맥이 있는 surface\n * (예: 작성자 프로필)는 호출부가 더 구체적인 alt 로 override 할 수 있다.\n */\nexport function suggestAltFromFilename(original: string): string {\n let text = stemOfFilename(original)\n .normalize(\"NFKC\")\n .replace(/%[0-9a-f]{2}/gi, \" \")\n .replace(/[()[\\]{}]/g, \" \")\n .replace(/[._-]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n text = text\n .replace(/\\b(copy|final|edited|edit|export|upload)\\b/gi, \" \")\n .replace(/\\b(screenshot|screen shot|image|img|photo|picture|pic)\\b/gi, \" \")\n .replace(/\\b(dsc|dcim|pxl|kakaotalk|wechatimg)\\s*\\d+\\b/gi, \" \")\n .replace(/\\b\\d{3,4}\\s*x\\s*\\d{3,4}\\b/gi, \" \")\n .replace(/\\b(19|20)\\d{2}[ ._-]?(0[1-9]|1[0-2])[ ._-]?([0-2]\\d|3[01])\\b/g, \" \")\n .replace(/\\b\\d{4,}\\b/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n\n if (!text || !/[0-9A-Za-z가-힣一-龥ぁ-んァ-ン]/.test(text)) {\n return \"업로드 이미지\";\n }\n\n const capped = text.slice(0, MAX_ALT_LEN).trim();\n return capped.charAt(0).toLocaleUpperCase(\"ko-KR\") + capped.slice(1);\n}\n\n/**\n * 업로드 객체명에 붙일 SEO-friendly filename.\n *\n * URL 경로에 의미 있는 단어가 남도록 한글/CJK/라틴 문자와 숫자는 보존하고,\n * 나머지는 hyphen 으로 접는다. 호출부는 앞에 UUID 를 붙여 충돌을 피한다.\n */\nexport function seoFilenameFromFilename(\n original: string,\n fallbackExt = \"\",\n): string {\n const ext = extractExt(basenameOf(original)) || fallbackExt.toLowerCase();\n const alt = suggestAltFromFilename(original);\n const source = alt === \"업로드 이미지\" ? \"uploaded image\" : alt;\n const stem = source\n .normalize(\"NFKC\")\n .toLocaleLowerCase(\"ko-KR\")\n .replace(/[^\\p{L}\\p{N}]+/gu, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, MAX_SEO_STEM_LEN)\n .replace(/-$/g, \"\");\n const safeStem = stem || \"uploaded-image\";\n return `${safeStem}${EXT_PATTERN.test(ext) ? ext : \"\"}`;\n}\n","// ADR-0034 §7.1.1 test #4 — R2 client wrapper (interface only, Phase 1 stub).\n//\n// Phase 1 environment-specific implementation:\n// - Cloudflare Workers/Pages = R2 binding (env.R2.put/get/delete)\n// - Vercel/Node = AWS SDK S3 client (S3-compatible API)\n//\n// 본 모듈은 interface + Korean filename + tenant prefix 강제 로직만.\n// 실 binding 은 tenant-admin 의 server action 또는 tenant-api 의 R2 handler 가 주입.\n\nimport { normalizeFilename } from \"./normalize-filename\";\n\n/** ADR-0016 multi-tenant 격리 의무 — r2_key prefix = `tenants/<tenant_id>/` */\nexport function r2KeyForTenant(\n tenantId: string,\n category: string,\n filename: string,\n): string {\n const { safe } = normalizeFilename(filename);\n return `tenants/${tenantId}/${category}/${safe}`;\n}\n\nexport interface R2PutOptions {\n contentType: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface R2Object {\n key: string;\n size: number;\n etag: string;\n uploaded: Date;\n contentType?: string;\n metadata?: Record<string, string>;\n}\n\n/**\n * R2 client interface — environment-specific binding 이 본 interface 구현.\n * tenant-admin 가 server action 안에서 inject (DI).\n */\nexport interface R2Client {\n put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;\n get(key: string): Promise<ArrayBuffer | null>;\n delete(key: string): Promise<void>;\n /** presigned URL — 외부 캐싱/CDN 용. Phase 1 = direct R2 public bucket */\n getPublicUrl(key: string): string;\n}\n\n/**\n * Tenant-aware R2 helper — key prefix 강제 + Korean filename normalize 자동.\n * tenant 외부에서 본 wrapper 우회하면 ESLint 의 no-raw-db-client 와 비슷한 rule\n * 추가 검토 (Phase 1b).\n */\nexport class TenantR2 {\n constructor(\n private readonly client: R2Client,\n private readonly tenantId: string,\n ) {}\n\n async uploadMedia(\n filename: string,\n body: ArrayBuffer | Uint8Array,\n contentType: string,\n ): Promise<{ key: string; publicUrl: string; object: R2Object }> {\n const key = r2KeyForTenant(this.tenantId, \"media\", filename);\n const object = await this.client.put(key, body, { contentType });\n return { key, publicUrl: this.client.getPublicUrl(key), object };\n }\n\n async deleteByKey(key: string): Promise<void> {\n if (!key.startsWith(`tenants/${this.tenantId}/`)) {\n throw new Error(\n `tenant 격리 위반: ${key} 는 tenants/${this.tenantId}/ prefix 와 일치하지 않음`,\n );\n }\n await this.client.delete(key);\n }\n}\n","// ADR-0034 §7.1.1 test #4 — AWS S3 SDK based R2Client 실 구현.\n//\n// Cloudflare R2 는 S3-compatible API 노출. @aws-sdk/client-s3 wrapper.\n// caller 가 endpoint + access key (Doppler) 주입.\n\nimport type { R2Client, R2Object, R2PutOptions } from \"./r2-client\";\n\nexport interface S3ClientConfig {\n endpoint: string; // e.g. https://<account>.r2.cloudflarestorage.com\n region?: string; // R2 = \"auto\"\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n publicBaseUrl: string; // CDN 또는 R2 public URL prefix\n}\n\n/**\n * AWS SDK 의존을 minimal — type-only declaration.\n * 실 binding 은 tenant-admin 의 server action 또는 tenant-api 가 SDK 주입.\n * 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.\n */\ninterface MinimalS3Sdk {\n send(command: unknown): Promise<{ ETag?: string; ContentLength?: number; Body?: AsyncIterable<Uint8Array> }>;\n}\n\ninterface S3PutObjectCommandInput {\n Bucket: string;\n Key: string;\n Body: Uint8Array;\n ContentType: string;\n CacheControl?: string;\n Metadata?: Record<string, string>;\n}\n\ninterface S3GetObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\ninterface S3DeleteObjectCommandInput {\n Bucket: string;\n Key: string;\n}\n\nexport interface S3CommandFactories {\n PutObjectCommand: new (input: S3PutObjectCommandInput) => unknown;\n GetObjectCommand: new (input: S3GetObjectCommandInput) => unknown;\n DeleteObjectCommand: new (input: S3DeleteObjectCommandInput) => unknown;\n}\n\n/**\n * R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.\n *\n * 사용 예시 (tenant-admin):\n * import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }\n * from \"@aws-sdk/client-s3\";\n * const r2 = new S3R2Client(\n * new S3Client({ endpoint, region: \"auto\", credentials: {...} }),\n * { PutObjectCommand, GetObjectCommand, DeleteObjectCommand },\n * config,\n * );\n */\nexport class S3R2Client implements R2Client {\n constructor(\n private readonly sdk: MinimalS3Sdk,\n private readonly commands: S3CommandFactories,\n private readonly config: S3ClientConfig,\n ) {}\n\n async put(\n key: string,\n body: ArrayBuffer | Uint8Array,\n options: R2PutOptions,\n ): Promise<R2Object> {\n const buf = body instanceof Uint8Array ? body : new Uint8Array(body);\n const cmd = new this.commands.PutObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n Body: buf,\n ContentType: options.contentType,\n CacheControl: options.cacheControl,\n Metadata: options.metadata,\n });\n const result = await this.sdk.send(cmd);\n return {\n key,\n size: buf.byteLength,\n etag: result.ETag ?? \"\",\n uploaded: new Date(),\n contentType: options.contentType,\n metadata: options.metadata,\n };\n }\n\n async get(key: string): Promise<ArrayBuffer | null> {\n const cmd = new this.commands.GetObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n try {\n const result = await this.sdk.send(cmd);\n if (!result.Body) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of result.Body) {\n chunks.push(chunk);\n }\n const total = chunks.reduce((a, c) => a + c.byteLength, 0);\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return merged.buffer;\n } catch (_err) {\n return null;\n }\n }\n\n async delete(key: string): Promise<void> {\n const cmd = new this.commands.DeleteObjectCommand({\n Bucket: this.config.bucket,\n Key: key,\n });\n await this.sdk.send(cmd);\n }\n\n getPublicUrl(key: string): string {\n const base = this.config.publicBaseUrl.replace(/\\/$/, \"\");\n return `${base}/${key}`;\n }\n}\n"],"mappings":"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","names":[]}
|
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 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"],"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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-media",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.1",
|
|
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",
|