@roottale/cms-media 0.25.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 ADDED
@@ -0,0 +1,35 @@
1
+ # @roottale/cms-media
2
+
3
+ RootTale 미디어 패키지 — **R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트**. (ADR-0034 §3, `docs/image-standard.md`)
4
+
5
+ 두 엔트리:
6
+
7
+ - `@roottale/cms-media` — 서버(R2/S3 클라이언트, variant URL 생성, 파일명 정규화). Node.
8
+ - `@roottale/cms-media/react` — 클라이언트 `CfImage` 렌더 컴포넌트(`"use client"`). react는 optional peer.
9
+
10
+ ## CfImage (`@roottale/cms-media/react`)
11
+
12
+ `next/image` 대체. CF Images는 이미 variant(sm/md/lg, 자동 webp/avif)를 제공하므로 브라우저가 직접 받게 한다 → Vercel 이미지 최적화 함수(`/_next/image`, Fluid Compute) 비용 0.
13
+
14
+ ```tsx
15
+ import { CfImage } from "@roottale/cms-media/react";
16
+
17
+ <div style={{ position: "relative", height: 160 }}>
18
+ <CfImage src={coverUrl} alt="단지 조감도" fill sizes="(max-width:600px) 100vw, 400px" />
19
+ </div>
20
+ ```
21
+
22
+ - CF imagedelivery URL이면 sm/md/lg srcset 자동, 기본 src `/md`. 비-CF(로컬 SVG·외부)면 변형 없이 그대로.
23
+ - `placeholder`: `'blur'`(기본, CF thumbnail 흐림) | `'skeleton'`(shimmer) | `'none'`. 로딩 중 표시 후 `onLoad` 페이드인.
24
+ - `priority`로 LCP 즉시 로드. `fill`/`sizes`/`width`/`height`/`loading`/`onError`/`style` 등 next/image·`<img>` 호환.
25
+ - `"use client"` — 서버 컴포넌트에서도 직접 렌더 가능(자체 클라이언트 경계).
26
+
27
+ `import Image from "@roottale/cms-media/react"`(default)로 받아 기존 `next/image` 호출부를 그대로 두는 drop-in 교체도 가능.
28
+
29
+ ## 서버 헬퍼 (`@roottale/cms-media`)
30
+
31
+ - `variantUrl(config, imageId, variant)` / `allVariantUrls` — image id 기반 variant URL.
32
+ - `cfImageVariant(url, variant)` / `cfImageSrcSet(url)` / `isCfImageUrl(url)` — full delivery URL 변형 교체(렌더용, server/client 공용).
33
+ - `r2KeyForTenant`, `normalizeFilename`, R2/S3 클라이언트 인터페이스 — 업로드(R2 원본 → CF Images upload-via-URL) 구현용.
34
+
35
+ 변형명/픽셀: thumbnail 96 · sm 320 · md 768 · lg 1600 (ADR-0034 §7.1.1).
@@ -0,0 +1,175 @@
1
+ interface NormalizedFilename {
2
+ /** R2 key segment 로 안전한 파일명 (확장자 포함). 항상 [a-zA-Z0-9._-]+. */
3
+ safe: string;
4
+ /** 원본 파일명 (UI 표시용, DB media.original_name) */
5
+ original: string;
6
+ /** 확장자 (소문자, dot 포함; e.g. ".jpg"). 없으면 "". */
7
+ ext: string;
8
+ }
9
+ /**
10
+ * 파일명 정규화. ASCII-safe(영숫자 포함) 이면 소문자화해 그대로, 아니면 hash.
11
+ * 결과 `safe` 는 항상 단일 segment 로 안전(위 모듈 주석 불변식).
12
+ */
13
+ declare function normalizeFilename(original: string): NormalizedFilename;
14
+ /**
15
+ * 파일명에서 SEO/accessibility 기본 alt 를 제안한다.
16
+ *
17
+ * 원본 파일명을 그대로 노출하지 않고, 확장자·경로·구분자·카메라/스크린샷
18
+ * 잡음을 제거해 사람이 읽는 짧은 설명으로 바꾼다. 실제 문맥이 있는 surface
19
+ * (예: 작성자 프로필)는 호출부가 더 구체적인 alt 로 override 할 수 있다.
20
+ */
21
+ declare function suggestAltFromFilename(original: string): string;
22
+ /**
23
+ * 업로드 객체명에 붙일 SEO-friendly filename.
24
+ *
25
+ * URL 경로에 의미 있는 단어가 남도록 한글/CJK/라틴 문자와 숫자는 보존하고,
26
+ * 나머지는 hyphen 으로 접는다. 호출부는 앞에 UUID 를 붙여 충돌을 피한다.
27
+ */
28
+ declare function seoFilenameFromFilename(original: string, fallbackExt?: string): string;
29
+
30
+ /** ADR-0016 multi-tenant 격리 의무 — r2_key prefix = `tenants/<tenant_id>/` */
31
+ declare function r2KeyForTenant(tenantId: string, category: string, filename: string): string;
32
+ interface R2PutOptions {
33
+ contentType: string;
34
+ cacheControl?: string;
35
+ metadata?: Record<string, string>;
36
+ }
37
+ interface R2Object {
38
+ key: string;
39
+ size: number;
40
+ etag: string;
41
+ uploaded: Date;
42
+ contentType?: string;
43
+ metadata?: Record<string, string>;
44
+ }
45
+ /**
46
+ * R2 client interface — environment-specific binding 이 본 interface 구현.
47
+ * admin-tenant 가 server action 안에서 inject (DI).
48
+ */
49
+ interface R2Client {
50
+ put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;
51
+ get(key: string): Promise<ArrayBuffer | null>;
52
+ delete(key: string): Promise<void>;
53
+ /** presigned URL — 외부 캐싱/CDN 용. Phase 1 = direct R2 public bucket */
54
+ getPublicUrl(key: string): string;
55
+ }
56
+ /**
57
+ * Tenant-aware R2 helper — key prefix 강제 + Korean filename normalize 자동.
58
+ * tenant 외부에서 본 wrapper 우회하면 ESLint 의 no-raw-db-client 와 비슷한 rule
59
+ * 추가 검토 (Phase 1b).
60
+ */
61
+ declare class TenantR2 {
62
+ private readonly client;
63
+ private readonly tenantId;
64
+ constructor(client: R2Client, tenantId: string);
65
+ uploadMedia(filename: string, body: ArrayBuffer | Uint8Array, contentType: string): Promise<{
66
+ key: string;
67
+ publicUrl: string;
68
+ object: R2Object;
69
+ }>;
70
+ deleteByKey(key: string): Promise<void>;
71
+ }
72
+
73
+ interface S3ClientConfig {
74
+ endpoint: string;
75
+ region?: string;
76
+ bucket: string;
77
+ accessKeyId: string;
78
+ secretAccessKey: string;
79
+ publicBaseUrl: string;
80
+ }
81
+ /**
82
+ * AWS SDK 의존을 minimal — type-only declaration.
83
+ * 실 binding 은 admin-tenant 의 server action 또는 tenant-api 가 SDK 주입.
84
+ * 본 module 은 R2Client interface 의 S3-style 구현 스켈레톤.
85
+ */
86
+ interface MinimalS3Sdk {
87
+ send(command: unknown): Promise<{
88
+ ETag?: string;
89
+ ContentLength?: number;
90
+ Body?: AsyncIterable<Uint8Array>;
91
+ }>;
92
+ }
93
+ interface S3PutObjectCommandInput {
94
+ Bucket: string;
95
+ Key: string;
96
+ Body: Uint8Array;
97
+ ContentType: string;
98
+ CacheControl?: string;
99
+ Metadata?: Record<string, string>;
100
+ }
101
+ interface S3GetObjectCommandInput {
102
+ Bucket: string;
103
+ Key: string;
104
+ }
105
+ interface S3DeleteObjectCommandInput {
106
+ Bucket: string;
107
+ Key: string;
108
+ }
109
+ interface S3CommandFactories {
110
+ PutObjectCommand: new (input: S3PutObjectCommandInput) => unknown;
111
+ GetObjectCommand: new (input: S3GetObjectCommandInput) => unknown;
112
+ DeleteObjectCommand: new (input: S3DeleteObjectCommandInput) => unknown;
113
+ }
114
+ /**
115
+ * R2Client 구현 (S3 SDK based). caller 가 SDK + commands 주입.
116
+ *
117
+ * 사용 예시 (admin-tenant):
118
+ * import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand }
119
+ * from "@aws-sdk/client-s3";
120
+ * const r2 = new S3R2Client(
121
+ * new S3Client({ endpoint, region: "auto", credentials: {...} }),
122
+ * { PutObjectCommand, GetObjectCommand, DeleteObjectCommand },
123
+ * config,
124
+ * );
125
+ */
126
+ declare class S3R2Client implements R2Client {
127
+ private readonly sdk;
128
+ private readonly commands;
129
+ private readonly config;
130
+ constructor(sdk: MinimalS3Sdk, commands: S3CommandFactories, config: S3ClientConfig);
131
+ put(key: string, body: ArrayBuffer | Uint8Array, options: R2PutOptions): Promise<R2Object>;
132
+ get(key: string): Promise<ArrayBuffer | null>;
133
+ delete(key: string): Promise<void>;
134
+ getPublicUrl(key: string): string;
135
+ }
136
+
137
+ type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
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 };
package/dist/index.js ADDED
@@ -0,0 +1,203 @@
1
+ // src/normalize-filename.ts
2
+ import { createHash } from "crypto";
3
+ var ASCII_SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;
4
+ var EXT_PATTERN = /^\.[a-z0-9]+$/;
5
+ var HAS_ALNUM = /[a-z0-9]/i;
6
+ var MAX_STEM_LEN = 80;
7
+ var MAX_ALT_LEN = 120;
8
+ var MAX_SEO_STEM_LEN = 96;
9
+ function shortHash(input) {
10
+ return createHash("sha256").update(input).digest("base64url").slice(0, 16).replace(/[-_]/g, "x");
11
+ }
12
+ function basenameOf(filename) {
13
+ const cut = Math.max(filename.lastIndexOf("/"), filename.lastIndexOf("\\"));
14
+ return cut >= 0 ? filename.slice(cut + 1) : filename;
15
+ }
16
+ function extractExt(basename) {
17
+ const dotIdx = basename.lastIndexOf(".");
18
+ if (dotIdx <= 0 || dotIdx === basename.length - 1) return "";
19
+ const ext = basename.slice(dotIdx).toLowerCase();
20
+ return EXT_PATTERN.test(ext) ? ext : "";
21
+ }
22
+ function stemOfFilename(filename) {
23
+ const basename = basenameOf(filename);
24
+ const ext = extractExt(basename);
25
+ return ext ? basename.slice(0, basename.length - ext.length) : basename;
26
+ }
27
+ function normalizeFilename(original) {
28
+ const basename = basenameOf(original);
29
+ const ext = extractExt(basename);
30
+ const stem = stemOfFilename(original);
31
+ let safeStem;
32
+ if (ASCII_SAFE_PATTERN.test(stem) && HAS_ALNUM.test(stem)) {
33
+ safeStem = stem.toLowerCase().slice(0, MAX_STEM_LEN);
34
+ } else {
35
+ safeStem = shortHash(stem || basename || original);
36
+ }
37
+ if (!HAS_ALNUM.test(safeStem)) {
38
+ safeStem = shortHash(original);
39
+ }
40
+ return { safe: `${safeStem}${ext}`, original, ext };
41
+ }
42
+ function suggestAltFromFilename(original) {
43
+ let text = stemOfFilename(original).normalize("NFKC").replace(/%[0-9a-f]{2}/gi, " ").replace(/[()[\]{}]/g, " ").replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim();
44
+ text = text.replace(/\b(copy|final|edited|edit|export|upload)\b/gi, " ").replace(/\b(screenshot|screen shot|image|img|photo|picture|pic)\b/gi, " ").replace(/\b(dsc|dcim|pxl|kakaotalk|wechatimg)\s*\d+\b/gi, " ").replace(/\b\d{3,4}\s*x\s*\d{3,4}\b/gi, " ").replace(/\b(19|20)\d{2}[ ._-]?(0[1-9]|1[0-2])[ ._-]?([0-2]\d|3[01])\b/g, " ").replace(/\b\d{4,}\b/g, " ").replace(/\s+/g, " ").trim();
45
+ if (!text || !/[0-9A-Za-z가-힣一-龥ぁ-んァ-ン]/.test(text)) {
46
+ return "\uC5C5\uB85C\uB4DC \uC774\uBBF8\uC9C0";
47
+ }
48
+ const capped = text.slice(0, MAX_ALT_LEN).trim();
49
+ return capped.charAt(0).toLocaleUpperCase("ko-KR") + capped.slice(1);
50
+ }
51
+ function seoFilenameFromFilename(original, fallbackExt = "") {
52
+ const ext = extractExt(basenameOf(original)) || fallbackExt.toLowerCase();
53
+ const alt = suggestAltFromFilename(original);
54
+ const source = alt === "\uC5C5\uB85C\uB4DC \uC774\uBBF8\uC9C0" ? "uploaded image" : alt;
55
+ const stem = source.normalize("NFKC").toLocaleLowerCase("ko-KR").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SEO_STEM_LEN).replace(/-$/g, "");
56
+ const safeStem = stem || "uploaded-image";
57
+ return `${safeStem}${EXT_PATTERN.test(ext) ? ext : ""}`;
58
+ }
59
+
60
+ // src/r2-client.ts
61
+ function r2KeyForTenant(tenantId, category, filename) {
62
+ const { safe } = normalizeFilename(filename);
63
+ return `tenants/${tenantId}/${category}/${safe}`;
64
+ }
65
+ var TenantR2 = class {
66
+ constructor(client, tenantId) {
67
+ this.client = client;
68
+ this.tenantId = tenantId;
69
+ }
70
+ client;
71
+ tenantId;
72
+ async uploadMedia(filename, body, contentType) {
73
+ const key = r2KeyForTenant(this.tenantId, "media", filename);
74
+ const object = await this.client.put(key, body, { contentType });
75
+ return { key, publicUrl: this.client.getPublicUrl(key), object };
76
+ }
77
+ async deleteByKey(key) {
78
+ if (!key.startsWith(`tenants/${this.tenantId}/`)) {
79
+ throw new Error(
80
+ `tenant \uACA9\uB9AC \uC704\uBC18: ${key} \uB294 tenants/${this.tenantId}/ prefix \uC640 \uC77C\uCE58\uD558\uC9C0 \uC54A\uC74C`
81
+ );
82
+ }
83
+ await this.client.delete(key);
84
+ }
85
+ };
86
+
87
+ // src/s3-client.ts
88
+ var S3R2Client = class {
89
+ constructor(sdk, commands, config) {
90
+ this.sdk = sdk;
91
+ this.commands = commands;
92
+ this.config = config;
93
+ }
94
+ sdk;
95
+ commands;
96
+ config;
97
+ async put(key, body, options) {
98
+ const buf = body instanceof Uint8Array ? body : new Uint8Array(body);
99
+ const cmd = new this.commands.PutObjectCommand({
100
+ Bucket: this.config.bucket,
101
+ Key: key,
102
+ Body: buf,
103
+ ContentType: options.contentType,
104
+ CacheControl: options.cacheControl,
105
+ Metadata: options.metadata
106
+ });
107
+ const result = await this.sdk.send(cmd);
108
+ return {
109
+ key,
110
+ size: buf.byteLength,
111
+ etag: result.ETag ?? "",
112
+ uploaded: /* @__PURE__ */ new Date(),
113
+ contentType: options.contentType,
114
+ metadata: options.metadata
115
+ };
116
+ }
117
+ async get(key) {
118
+ const cmd = new this.commands.GetObjectCommand({
119
+ Bucket: this.config.bucket,
120
+ Key: key
121
+ });
122
+ try {
123
+ const result = await this.sdk.send(cmd);
124
+ if (!result.Body) return null;
125
+ const chunks = [];
126
+ for await (const chunk of result.Body) {
127
+ chunks.push(chunk);
128
+ }
129
+ const total = chunks.reduce((a, c) => a + c.byteLength, 0);
130
+ const merged = new Uint8Array(total);
131
+ let offset = 0;
132
+ for (const chunk of chunks) {
133
+ merged.set(chunk, offset);
134
+ offset += chunk.byteLength;
135
+ }
136
+ return merged.buffer;
137
+ } catch (_err) {
138
+ return null;
139
+ }
140
+ }
141
+ async delete(key) {
142
+ const cmd = new this.commands.DeleteObjectCommand({
143
+ Bucket: this.config.bucket,
144
+ Key: key
145
+ });
146
+ await this.sdk.send(cmd);
147
+ }
148
+ getPublicUrl(key) {
149
+ const base = this.config.publicBaseUrl.replace(/\/$/, "");
150
+ return `${base}/${key}`;
151
+ }
152
+ };
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
+ export {
189
+ S3R2Client,
190
+ TenantR2,
191
+ VARIANT_NAMES,
192
+ VARIANT_SIZES,
193
+ allVariantUrls,
194
+ cfImageSrcSet,
195
+ cfImageVariant,
196
+ isCfImageUrl,
197
+ normalizeFilename,
198
+ r2KeyForTenant,
199
+ seoFilenameFromFilename,
200
+ suggestAltFromFilename,
201
+ variantUrl
202
+ };
203
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":"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","names":[]}
@@ -0,0 +1,46 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ImgHTMLAttributes, CSSProperties } 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'(CF thumbnail 저해상도 흐림) | 'skeleton'(shimmer) | 'none'. 기본 'blur'.
13
+ * - loader: 원본 onLoad 시 opacity 0→1 페이드인.
14
+ *
15
+ * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.
16
+ */
17
+ type CfImagePlaceholder = "blur" | "skeleton" | "none";
18
+ interface CfImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "placeholder"> {
19
+ src: string;
20
+ alt: string;
21
+ /** next/image의 fill 처럼 부모(position:relative)를 채운다. */
22
+ fill?: boolean;
23
+ /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */
24
+ priority?: boolean;
25
+ /** fill일 때 object-fit (기본 cover). */
26
+ objectFit?: CSSProperties["objectFit"];
27
+ /** 로딩 중 표시. 기본 'blur'(CF가 아니면 skeleton로 폴백). */
28
+ placeholder?: CfImagePlaceholder;
29
+ /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */
30
+ wrapperClassName?: string;
31
+ wrapperStyle?: CSSProperties;
32
+ }
33
+ declare function CfImage({ src, alt, fill, priority, objectFit, placeholder, sizes, width, height, loading, style, className, wrapperClassName, wrapperStyle, onLoad, onError, ...rest }: CfImageProps): react_jsx_runtime.JSX.Element;
34
+
35
+ type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
36
+ /** imagedelivery delivery URL 여부. */
37
+ declare function isCfImageUrl(url: string): boolean;
38
+ /**
39
+ * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.
40
+ * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).
41
+ */
42
+ declare function cfImageVariant(url: string, variant: ImageVariant | "public"): string;
43
+ /** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */
44
+ declare function cfImageSrcSet(url: string, variants?: readonly ImageVariant[]): string | undefined;
45
+
46
+ export { CfImage, type CfImagePlaceholder, type CfImageProps, type ImageVariant, cfImageSrcSet, cfImageVariant, CfImage as default, isCfImageUrl };
package/dist/react.js ADDED
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ // src/react/CfImage.tsx
4
+ import {
5
+ useEffect,
6
+ useRef,
7
+ useState
8
+ } from "react";
9
+
10
+ // src/variants.ts
11
+ var VARIANT_SIZES = {
12
+ thumbnail: { width: 96, height: 96 },
13
+ sm: { width: 320, height: 320 },
14
+ md: { width: 768, height: 768 },
15
+ lg: { width: 1600, height: 1600 }
16
+ };
17
+ var CF_HOST = "imagedelivery.net";
18
+ var KNOWN_VARIANT_SUFFIX = /\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;
19
+ function isCfImageUrl(url) {
20
+ return typeof url === "string" && url.includes(CF_HOST);
21
+ }
22
+ function cfImageVariant(url, variant) {
23
+ if (!isCfImageUrl(url)) return url;
24
+ return KNOWN_VARIANT_SUFFIX.test(url) ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`) : `${url}/${variant}`;
25
+ }
26
+ function cfImageSrcSet(url, variants = ["sm", "md", "lg"]) {
27
+ if (!isCfImageUrl(url)) return void 0;
28
+ return variants.map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`).join(", ");
29
+ }
30
+
31
+ // src/react/CfImage.tsx
32
+ import { jsx, jsxs } from "react/jsx-runtime";
33
+ var SHIMMER_STYLE_ID = "roottale-cf-image-shimmer";
34
+ var SHIMMER_KEYFRAMES = `@keyframes rtCfImgShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}`;
35
+ function ensureShimmerKeyframes() {
36
+ if (typeof document === "undefined") return;
37
+ if (document.getElementById(SHIMMER_STYLE_ID)) return;
38
+ const el = document.createElement("style");
39
+ el.id = SHIMMER_STYLE_ID;
40
+ el.textContent = SHIMMER_KEYFRAMES;
41
+ document.head.appendChild(el);
42
+ }
43
+ function CfImage({
44
+ src,
45
+ alt,
46
+ fill,
47
+ priority,
48
+ objectFit = "cover",
49
+ placeholder = "blur",
50
+ sizes,
51
+ width,
52
+ height,
53
+ loading,
54
+ style,
55
+ className,
56
+ wrapperClassName,
57
+ wrapperStyle,
58
+ onLoad,
59
+ onError,
60
+ ...rest
61
+ }) {
62
+ const [loaded, setLoaded] = useState(false);
63
+ const imgRef = useRef(null);
64
+ useEffect(() => {
65
+ if (imgRef.current?.complete) setLoaded(true);
66
+ }, []);
67
+ const cf = isCfImageUrl(src);
68
+ const effPlaceholder = placeholder === "blur" && !cf ? "skeleton" : placeholder;
69
+ useEffect(() => {
70
+ if (effPlaceholder === "skeleton") ensureShimmerKeyframes();
71
+ }, [effPlaceholder]);
72
+ const baseSrc = cf ? cfImageVariant(src, "md") : src;
73
+ const srcSet = cf ? cfImageSrcSet(src) : void 0;
74
+ const showPlaceholder = effPlaceholder !== "none" && !loaded && !priority;
75
+ const wrapper = fill ? { position: "absolute", inset: 0, overflow: "hidden", ...wrapperStyle } : {
76
+ position: "relative",
77
+ display: "inline-block",
78
+ overflow: "hidden",
79
+ ...wrapperStyle
80
+ };
81
+ const imgStyle = {
82
+ ...fill ? { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit } : { display: "block", width: width ? "100%" : void 0, height: "auto", objectFit },
83
+ opacity: loaded || priority ? 1 : 0,
84
+ transition: "opacity 0.4s ease",
85
+ ...style
86
+ };
87
+ const placeholderBase = {
88
+ position: "absolute",
89
+ inset: 0,
90
+ pointerEvents: "none",
91
+ opacity: showPlaceholder ? 1 : 0,
92
+ transition: "opacity 0.4s ease"
93
+ };
94
+ const placeholderStyle = effPlaceholder === "blur" ? {
95
+ ...placeholderBase,
96
+ backgroundImage: `url(${cfImageVariant(src, "thumbnail")})`,
97
+ backgroundSize: "cover",
98
+ backgroundPosition: "center",
99
+ filter: "blur(16px)",
100
+ transform: "scale(1.1)"
101
+ } : {
102
+ ...placeholderBase,
103
+ background: "linear-gradient(100deg,#eceff1 30%,#f5f7f8 50%,#eceff1 70%)",
104
+ backgroundSize: "200% 100%",
105
+ animation: "rtCfImgShimmer 1.4s ease-in-out infinite"
106
+ };
107
+ return /* @__PURE__ */ jsxs("span", { className: wrapperClassName, style: wrapper, children: [
108
+ effPlaceholder !== "none" && /* @__PURE__ */ jsx("span", { "aria-hidden": true, style: placeholderStyle }),
109
+ /* @__PURE__ */ jsx(
110
+ "img",
111
+ {
112
+ ...rest,
113
+ ref: imgRef,
114
+ src: baseSrc,
115
+ srcSet,
116
+ sizes,
117
+ alt,
118
+ width: fill ? void 0 : width,
119
+ height: fill ? void 0 : height,
120
+ loading: loading ?? (priority ? "eager" : "lazy"),
121
+ fetchPriority: priority ? "high" : void 0,
122
+ decoding: "async",
123
+ className,
124
+ style: imgStyle,
125
+ onLoad: (e) => {
126
+ setLoaded(true);
127
+ onLoad?.(e);
128
+ },
129
+ onError
130
+ }
131
+ )
132
+ ] });
133
+ }
134
+ var CfImage_default = CfImage;
135
+ export {
136
+ CfImage,
137
+ cfImageSrcSet,
138
+ cfImageVariant,
139
+ CfImage_default as default,
140
+ isCfImageUrl
141
+ };
142
+ //# sourceMappingURL=react.js.map
@@ -0,0 +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} 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'(CF thumbnail 저해상도 흐림) | 'skeleton'(shimmer) | 'none'. 기본 'blur'.\n * - loader: 원본 onLoad 시 opacity 0→1 페이드인.\n *\n * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.\n */\n\nexport type CfImagePlaceholder = \"blur\" | \"skeleton\" | \"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로 폴백). */\n placeholder?: CfImagePlaceholder;\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 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 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 const effPlaceholder: CfImagePlaceholder =\n placeholder === \"blur\" && !cf ? \"skeleton\" : placeholder;\n useEffect(() => {\n if (effPlaceholder === \"skeleton\") ensureShimmerKeyframes();\n }, [effPlaceholder]);\n\n const baseSrc = cf ? cfImageVariant(src, \"md\") : src;\n const srcSet = cf ? cfImageSrcSet(src) : undefined;\n const showPlaceholder = effPlaceholder !== \"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 placeholderBase: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n opacity: showPlaceholder ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n };\n\n const placeholderStyle: CSSProperties =\n effPlaceholder === \"blur\"\n ? {\n ...placeholderBase,\n backgroundImage: `url(${cfImageVariant(src, \"thumbnail\")})`,\n backgroundSize: \"cover\",\n backgroundPosition: \"center\",\n filter: \"blur(16px)\",\n transform: \"scale(1.1)\",\n }\n : {\n ...placeholderBase,\n background:\n \"linear-gradient(100deg,#eceff1 30%,#f5f7f8 50%,#eceff1 70%)\",\n backgroundSize: \"200% 100%\",\n animation: \"rtCfImgShimmer 1.4s ease-in-out infinite\",\n };\n\n return (\n <span className={wrapperClassName} style={wrapper}>\n {effPlaceholder !== \"none\" && <span aria-hidden style={placeholderStyle} />}\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 ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@roottale/cms-media",
3
+ "version": "0.25.0",
4
+ "type": "module",
5
+ "description": "RootTale 미디어 — R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트(@roottale/cms-media/react). ADR-0034 §3 / docs/image-standard.md.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./react": {
15
+ "types": "./dist/react.d.ts",
16
+ "import": "./dist/react.js",
17
+ "default": "./dist/react.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist/",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "prepack": "pnpm build",
28
+ "type-check": "tsc --noEmit",
29
+ "test": "vitest run --passWithNoTests"
30
+ },
31
+ "dependencies": {},
32
+ "peerDependencies": {
33
+ "react": "^18.3.0 || ^19.0.0",
34
+ "react-dom": "^18.3.0 || ^19.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "react": {
38
+ "optional": true
39
+ },
40
+ "react-dom": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "@types/react": "^19.0.0",
47
+ "@types/react-dom": "^19.0.0",
48
+ "react": "^19.0.0",
49
+ "react-dom": "^19.0.0",
50
+ "tsup": "^8.0.0",
51
+ "typescript": "^5.7.0",
52
+ "vitest": "^2.1.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "keywords": [
58
+ "roottale",
59
+ "cloudflare-images",
60
+ "r2",
61
+ "media",
62
+ "react",
63
+ "next-image-alternative"
64
+ ],
65
+ "license": "UNLICENSED",
66
+ "homepage": "https://github.com/RootTale/roottale-platform/tree/main/packages/cms-media",
67
+ "repository": {
68
+ "type": "git",
69
+ "url": "https://github.com/RootTale/roottale-platform.git",
70
+ "directory": "packages/cms-media"
71
+ }
72
+ }