@lumir-company/editor 0.4.3 → 0.4.5
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 +1299 -966
- package/dist/api/link-preview.d.mts +40 -0
- package/dist/api/link-preview.d.ts +40 -0
- package/dist/api/link-preview.js +190 -0
- package/dist/api/link-preview.js.map +1 -0
- package/dist/api/link-preview.mjs +163 -0
- package/dist/api/link-preview.mjs.map +1 -0
- package/dist/index.d.mts +18 -4
- package/dist/index.d.ts +18 -4
- package/dist/index.js +411 -89
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +412 -90
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +977 -964
- package/package.json +10 -4
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lumir-company/editor - Link Preview API Handler
|
|
3
|
+
*
|
|
4
|
+
* 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등
|
|
5
|
+
* Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.
|
|
6
|
+
*
|
|
7
|
+
* Next.js App Router 사용 예시:
|
|
8
|
+
* ```ts
|
|
9
|
+
* // src/app/api/link-preview/route.ts
|
|
10
|
+
* export { GET } from "@lumir-company/editor/api/link-preview";
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
interface LinkMetadata {
|
|
14
|
+
url: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
image?: string;
|
|
18
|
+
domain: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.
|
|
22
|
+
* 커스텀 서버 구현 시 직접 사용할 수 있습니다.
|
|
23
|
+
*/
|
|
24
|
+
declare function parseMetaTags(html: string, baseUrl: string): LinkMetadata;
|
|
25
|
+
/**
|
|
26
|
+
* URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).
|
|
27
|
+
* Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.
|
|
28
|
+
*/
|
|
29
|
+
declare function fetchUrlMetadata(url: string): Promise<LinkMetadata>;
|
|
30
|
+
/**
|
|
31
|
+
* 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).
|
|
32
|
+
* Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // Next.js: src/app/api/link-preview/route.ts
|
|
36
|
+
* export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
|
|
37
|
+
*/
|
|
38
|
+
declare function linkPreviewHandler(request: Request): Promise<Response>;
|
|
39
|
+
|
|
40
|
+
export { type LinkMetadata, fetchUrlMetadata, linkPreviewHandler, parseMetaTags };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lumir-company/editor - Link Preview API Handler
|
|
3
|
+
*
|
|
4
|
+
* 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등
|
|
5
|
+
* Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.
|
|
6
|
+
*
|
|
7
|
+
* Next.js App Router 사용 예시:
|
|
8
|
+
* ```ts
|
|
9
|
+
* // src/app/api/link-preview/route.ts
|
|
10
|
+
* export { GET } from "@lumir-company/editor/api/link-preview";
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
interface LinkMetadata {
|
|
14
|
+
url: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
image?: string;
|
|
18
|
+
domain: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.
|
|
22
|
+
* 커스텀 서버 구현 시 직접 사용할 수 있습니다.
|
|
23
|
+
*/
|
|
24
|
+
declare function parseMetaTags(html: string, baseUrl: string): LinkMetadata;
|
|
25
|
+
/**
|
|
26
|
+
* URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).
|
|
27
|
+
* Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.
|
|
28
|
+
*/
|
|
29
|
+
declare function fetchUrlMetadata(url: string): Promise<LinkMetadata>;
|
|
30
|
+
/**
|
|
31
|
+
* 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).
|
|
32
|
+
* Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // Next.js: src/app/api/link-preview/route.ts
|
|
36
|
+
* export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
|
|
37
|
+
*/
|
|
38
|
+
declare function linkPreviewHandler(request: Request): Promise<Response>;
|
|
39
|
+
|
|
40
|
+
export { type LinkMetadata, fetchUrlMetadata, linkPreviewHandler, parseMetaTags };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/api/link-preview.ts
|
|
21
|
+
var link_preview_exports = {};
|
|
22
|
+
__export(link_preview_exports, {
|
|
23
|
+
fetchUrlMetadata: () => fetchUrlMetadata,
|
|
24
|
+
linkPreviewHandler: () => linkPreviewHandler,
|
|
25
|
+
parseMetaTags: () => parseMetaTags
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(link_preview_exports);
|
|
28
|
+
function extractDomain(url) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
31
|
+
} catch {
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function decodeHtmlEntity(str) {
|
|
36
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ").replace(/'/g, "'").replace(///g, "/");
|
|
37
|
+
}
|
|
38
|
+
function getMetaContent(html, property, name) {
|
|
39
|
+
const patterns = [
|
|
40
|
+
new RegExp(
|
|
41
|
+
`<meta\\s+property=["']${property}["']\\s+content=["']([^"']+)["']`,
|
|
42
|
+
"i"
|
|
43
|
+
),
|
|
44
|
+
new RegExp(
|
|
45
|
+
`<meta\\s+content=["']([^"']+)["']\\s+property=["']${property}["']`,
|
|
46
|
+
"i"
|
|
47
|
+
)
|
|
48
|
+
];
|
|
49
|
+
if (name) {
|
|
50
|
+
patterns.push(
|
|
51
|
+
new RegExp(
|
|
52
|
+
`<meta\\s+name=["']${name}["']\\s+content=["']([^"']+)["']`,
|
|
53
|
+
"i"
|
|
54
|
+
),
|
|
55
|
+
new RegExp(
|
|
56
|
+
`<meta\\s+content=["']([^"']+)["']\\s+name=["']${name}["']`,
|
|
57
|
+
"i"
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
const match = html.match(pattern);
|
|
63
|
+
if (match?.[1]) return match[1].trim();
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function parseMetaTags(html, baseUrl) {
|
|
68
|
+
const domain = extractDomain(baseUrl);
|
|
69
|
+
const metadata = { url: baseUrl, title: domain, domain };
|
|
70
|
+
const ogTitle = getMetaContent(html, "og:title");
|
|
71
|
+
const ogDescription = getMetaContent(html, "og:description");
|
|
72
|
+
const ogImage = getMetaContent(html, "og:image");
|
|
73
|
+
const ogUrl = getMetaContent(html, "og:url");
|
|
74
|
+
const twitterTitle = getMetaContent(html, "", "twitter:title");
|
|
75
|
+
const twitterDescription = getMetaContent(html, "", "twitter:description");
|
|
76
|
+
const twitterImage = getMetaContent(html, "", "twitter:image");
|
|
77
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
78
|
+
const descriptionMatch = html.match(
|
|
79
|
+
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i
|
|
80
|
+
);
|
|
81
|
+
if (ogTitle) {
|
|
82
|
+
metadata.title = decodeHtmlEntity(ogTitle);
|
|
83
|
+
} else if (twitterTitle) {
|
|
84
|
+
metadata.title = decodeHtmlEntity(twitterTitle);
|
|
85
|
+
} else if (titleMatch?.[1]) {
|
|
86
|
+
metadata.title = decodeHtmlEntity(titleMatch[1].trim());
|
|
87
|
+
}
|
|
88
|
+
if (ogDescription) {
|
|
89
|
+
metadata.description = decodeHtmlEntity(ogDescription);
|
|
90
|
+
} else if (twitterDescription) {
|
|
91
|
+
metadata.description = decodeHtmlEntity(twitterDescription);
|
|
92
|
+
} else if (descriptionMatch?.[1]) {
|
|
93
|
+
metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());
|
|
94
|
+
}
|
|
95
|
+
let imageUrl;
|
|
96
|
+
if (ogImage) {
|
|
97
|
+
imageUrl = ogImage;
|
|
98
|
+
} else if (twitterImage) {
|
|
99
|
+
imageUrl = twitterImage;
|
|
100
|
+
}
|
|
101
|
+
if (imageUrl) {
|
|
102
|
+
imageUrl = decodeHtmlEntity(imageUrl);
|
|
103
|
+
if (imageUrl.trim()) {
|
|
104
|
+
try {
|
|
105
|
+
metadata.image = new URL(imageUrl, baseUrl).toString();
|
|
106
|
+
} catch {
|
|
107
|
+
metadata.image = void 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (ogUrl) {
|
|
112
|
+
try {
|
|
113
|
+
metadata.url = new URL(ogUrl, baseUrl).toString();
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return metadata;
|
|
118
|
+
}
|
|
119
|
+
async function fetchUrlMetadata(url) {
|
|
120
|
+
const targetUrl = new URL(url);
|
|
121
|
+
if (!["http:", "https:"].includes(targetUrl.protocol)) {
|
|
122
|
+
throw new Error("Only http and https URLs are allowed");
|
|
123
|
+
}
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeoutId = setTimeout(() => controller.abort(), 8e3);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(targetUrl.toString(), {
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
headers: {
|
|
130
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
131
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
132
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
133
|
+
},
|
|
134
|
+
redirect: "follow"
|
|
135
|
+
});
|
|
136
|
+
clearTimeout(timeoutId);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Failed to fetch URL: ${response.status} ${response.statusText}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const html = await response.text();
|
|
143
|
+
return parseMetaTags(html, targetUrl.toString());
|
|
144
|
+
} catch (error) {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function jsonResponse(data, status = 200) {
|
|
150
|
+
return new Response(JSON.stringify(data), {
|
|
151
|
+
status,
|
|
152
|
+
headers: { "Content-Type": "application/json" }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async function linkPreviewHandler(request) {
|
|
156
|
+
const { searchParams } = new URL(request.url);
|
|
157
|
+
const url = searchParams.get("url");
|
|
158
|
+
if (!url) {
|
|
159
|
+
return jsonResponse({ error: "url parameter is required" }, 400);
|
|
160
|
+
}
|
|
161
|
+
let targetUrl;
|
|
162
|
+
try {
|
|
163
|
+
targetUrl = new URL(url);
|
|
164
|
+
if (!["http:", "https:"].includes(targetUrl.protocol)) {
|
|
165
|
+
return jsonResponse(
|
|
166
|
+
{ error: "Only http and https URLs are allowed" },
|
|
167
|
+
400
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return jsonResponse({ error: "Invalid URL format" }, 400);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const metadata = await fetchUrlMetadata(targetUrl.toString());
|
|
175
|
+
return jsonResponse(metadata);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error.name === "AbortError") {
|
|
178
|
+
return jsonResponse({ error: "Request timeout" }, 408);
|
|
179
|
+
}
|
|
180
|
+
console.error("Error fetching link metadata:", error);
|
|
181
|
+
return jsonResponse({ error: "Failed to fetch link metadata" }, 500);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
185
|
+
0 && (module.exports = {
|
|
186
|
+
fetchUrlMetadata,
|
|
187
|
+
linkPreviewHandler,
|
|
188
|
+
parseMetaTags
|
|
189
|
+
});
|
|
190
|
+
//# sourceMappingURL=link-preview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\r\n * @lumir-company/editor - Link Preview API Handler\r\n *\r\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\r\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\r\n *\r\n * Next.js App Router 사용 예시:\r\n * ```ts\r\n * // src/app/api/link-preview/route.ts\r\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\r\n * ```\r\n */\r\n\r\nexport interface LinkMetadata {\r\n url: string;\r\n title: string;\r\n description?: string;\r\n image?: string;\r\n domain: string;\r\n}\r\n\r\nfunction extractDomain(url: string): string {\r\n try {\r\n return new URL(url).hostname.replace(/^www\\./, \"\");\r\n } catch {\r\n return url;\r\n }\r\n}\r\n\r\nfunction decodeHtmlEntity(str: string): string {\r\n return str\r\n .replace(/&/g, \"&\")\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/ /g, \" \")\r\n .replace(/'/g, \"'\")\r\n .replace(///g, \"/\");\r\n}\r\n\r\nfunction getMetaContent(\r\n html: string,\r\n property: string,\r\n name?: string\r\n): string | null {\r\n const patterns = [\r\n new RegExp(\r\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\r\n \"i\"\r\n ),\r\n ];\r\n\r\n if (name) {\r\n patterns.push(\r\n new RegExp(\r\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\r\n \"i\"\r\n )\r\n );\r\n }\r\n\r\n for (const pattern of patterns) {\r\n const match = html.match(pattern);\r\n if (match?.[1]) return match[1].trim();\r\n }\r\n\r\n return null;\r\n}\r\n\r\n/**\r\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\r\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\r\n */\r\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\r\n const domain = extractDomain(baseUrl);\r\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\r\n\r\n const ogTitle = getMetaContent(html, \"og:title\");\r\n const ogDescription = getMetaContent(html, \"og:description\");\r\n const ogImage = getMetaContent(html, \"og:image\");\r\n const ogUrl = getMetaContent(html, \"og:url\");\r\n\r\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\r\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\r\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\r\n\r\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\r\n const descriptionMatch = html.match(\r\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\r\n );\r\n\r\n if (ogTitle) {\r\n metadata.title = decodeHtmlEntity(ogTitle);\r\n } else if (twitterTitle) {\r\n metadata.title = decodeHtmlEntity(twitterTitle);\r\n } else if (titleMatch?.[1]) {\r\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\r\n }\r\n\r\n if (ogDescription) {\r\n metadata.description = decodeHtmlEntity(ogDescription);\r\n } else if (twitterDescription) {\r\n metadata.description = decodeHtmlEntity(twitterDescription);\r\n } else if (descriptionMatch?.[1]) {\r\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\r\n }\r\n\r\n let imageUrl: string | undefined;\r\n if (ogImage) {\r\n imageUrl = ogImage;\r\n } else if (twitterImage) {\r\n imageUrl = twitterImage;\r\n }\r\n\r\n if (imageUrl) {\r\n imageUrl = decodeHtmlEntity(imageUrl);\r\n if (imageUrl.trim()) {\r\n try {\r\n metadata.image = new URL(imageUrl, baseUrl).toString();\r\n } catch {\r\n metadata.image = undefined;\r\n }\r\n }\r\n }\r\n\r\n if (ogUrl) {\r\n try {\r\n metadata.url = new URL(ogUrl, baseUrl).toString();\r\n } catch {\r\n // keep original URL\r\n }\r\n }\r\n\r\n return metadata;\r\n}\r\n\r\n/**\r\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\r\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\r\n */\r\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\r\n const targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n throw new Error(\"Only http and https URLs are allowed\");\r\n }\r\n\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), 8000);\r\n\r\n try {\r\n const response = await fetch(targetUrl.toString(), {\r\n signal: controller.signal,\r\n headers: {\r\n \"User-Agent\":\r\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\r\n Accept:\r\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\r\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\r\n },\r\n redirect: \"follow\",\r\n });\r\n\r\n clearTimeout(timeoutId);\r\n\r\n if (!response.ok) {\r\n throw new Error(\r\n `Failed to fetch URL: ${response.status} ${response.statusText}`\r\n );\r\n }\r\n\r\n const html = await response.text();\r\n return parseMetaTags(html, targetUrl.toString());\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n throw error;\r\n }\r\n}\r\n\r\nfunction jsonResponse(data: unknown, status = 200): Response {\r\n return new Response(JSON.stringify(data), {\r\n status,\r\n headers: { \"Content-Type\": \"application/json\" },\r\n });\r\n}\r\n\r\n/**\r\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\r\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\r\n *\r\n * @example\r\n * // Next.js: src/app/api/link-preview/route.ts\r\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\r\n */\r\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\r\n const { searchParams } = new URL(request.url);\r\n const url = searchParams.get(\"url\");\r\n\r\n if (!url) {\r\n return jsonResponse({ error: \"url parameter is required\" }, 400);\r\n }\r\n\r\n let targetUrl: URL;\r\n try {\r\n targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n return jsonResponse(\r\n { error: \"Only http and https URLs are allowed\" },\r\n 400\r\n );\r\n }\r\n } catch {\r\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\r\n }\r\n\r\n try {\r\n const metadata = await fetchUrlMetadata(targetUrl.toString());\r\n return jsonResponse(metadata);\r\n } catch (error: any) {\r\n if (error.name === \"AbortError\") {\r\n return jsonResponse({ error: \"Request timeout\" }, 408);\r\n }\r\n\r\n console.error(\"Error fetching link metadata:\", error);\r\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/api/link-preview.ts
|
|
2
|
+
function extractDomain(url) {
|
|
3
|
+
try {
|
|
4
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
5
|
+
} catch {
|
|
6
|
+
return url;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function decodeHtmlEntity(str) {
|
|
10
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ").replace(/'/g, "'").replace(///g, "/");
|
|
11
|
+
}
|
|
12
|
+
function getMetaContent(html, property, name) {
|
|
13
|
+
const patterns = [
|
|
14
|
+
new RegExp(
|
|
15
|
+
`<meta\\s+property=["']${property}["']\\s+content=["']([^"']+)["']`,
|
|
16
|
+
"i"
|
|
17
|
+
),
|
|
18
|
+
new RegExp(
|
|
19
|
+
`<meta\\s+content=["']([^"']+)["']\\s+property=["']${property}["']`,
|
|
20
|
+
"i"
|
|
21
|
+
)
|
|
22
|
+
];
|
|
23
|
+
if (name) {
|
|
24
|
+
patterns.push(
|
|
25
|
+
new RegExp(
|
|
26
|
+
`<meta\\s+name=["']${name}["']\\s+content=["']([^"']+)["']`,
|
|
27
|
+
"i"
|
|
28
|
+
),
|
|
29
|
+
new RegExp(
|
|
30
|
+
`<meta\\s+content=["']([^"']+)["']\\s+name=["']${name}["']`,
|
|
31
|
+
"i"
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
for (const pattern of patterns) {
|
|
36
|
+
const match = html.match(pattern);
|
|
37
|
+
if (match?.[1]) return match[1].trim();
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function parseMetaTags(html, baseUrl) {
|
|
42
|
+
const domain = extractDomain(baseUrl);
|
|
43
|
+
const metadata = { url: baseUrl, title: domain, domain };
|
|
44
|
+
const ogTitle = getMetaContent(html, "og:title");
|
|
45
|
+
const ogDescription = getMetaContent(html, "og:description");
|
|
46
|
+
const ogImage = getMetaContent(html, "og:image");
|
|
47
|
+
const ogUrl = getMetaContent(html, "og:url");
|
|
48
|
+
const twitterTitle = getMetaContent(html, "", "twitter:title");
|
|
49
|
+
const twitterDescription = getMetaContent(html, "", "twitter:description");
|
|
50
|
+
const twitterImage = getMetaContent(html, "", "twitter:image");
|
|
51
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
52
|
+
const descriptionMatch = html.match(
|
|
53
|
+
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i
|
|
54
|
+
);
|
|
55
|
+
if (ogTitle) {
|
|
56
|
+
metadata.title = decodeHtmlEntity(ogTitle);
|
|
57
|
+
} else if (twitterTitle) {
|
|
58
|
+
metadata.title = decodeHtmlEntity(twitterTitle);
|
|
59
|
+
} else if (titleMatch?.[1]) {
|
|
60
|
+
metadata.title = decodeHtmlEntity(titleMatch[1].trim());
|
|
61
|
+
}
|
|
62
|
+
if (ogDescription) {
|
|
63
|
+
metadata.description = decodeHtmlEntity(ogDescription);
|
|
64
|
+
} else if (twitterDescription) {
|
|
65
|
+
metadata.description = decodeHtmlEntity(twitterDescription);
|
|
66
|
+
} else if (descriptionMatch?.[1]) {
|
|
67
|
+
metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());
|
|
68
|
+
}
|
|
69
|
+
let imageUrl;
|
|
70
|
+
if (ogImage) {
|
|
71
|
+
imageUrl = ogImage;
|
|
72
|
+
} else if (twitterImage) {
|
|
73
|
+
imageUrl = twitterImage;
|
|
74
|
+
}
|
|
75
|
+
if (imageUrl) {
|
|
76
|
+
imageUrl = decodeHtmlEntity(imageUrl);
|
|
77
|
+
if (imageUrl.trim()) {
|
|
78
|
+
try {
|
|
79
|
+
metadata.image = new URL(imageUrl, baseUrl).toString();
|
|
80
|
+
} catch {
|
|
81
|
+
metadata.image = void 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (ogUrl) {
|
|
86
|
+
try {
|
|
87
|
+
metadata.url = new URL(ogUrl, baseUrl).toString();
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return metadata;
|
|
92
|
+
}
|
|
93
|
+
async function fetchUrlMetadata(url) {
|
|
94
|
+
const targetUrl = new URL(url);
|
|
95
|
+
if (!["http:", "https:"].includes(targetUrl.protocol)) {
|
|
96
|
+
throw new Error("Only http and https URLs are allowed");
|
|
97
|
+
}
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timeoutId = setTimeout(() => controller.abort(), 8e3);
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetch(targetUrl.toString(), {
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
headers: {
|
|
104
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
105
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
106
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
107
|
+
},
|
|
108
|
+
redirect: "follow"
|
|
109
|
+
});
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Failed to fetch URL: ${response.status} ${response.statusText}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const html = await response.text();
|
|
117
|
+
return parseMetaTags(html, targetUrl.toString());
|
|
118
|
+
} catch (error) {
|
|
119
|
+
clearTimeout(timeoutId);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function jsonResponse(data, status = 200) {
|
|
124
|
+
return new Response(JSON.stringify(data), {
|
|
125
|
+
status,
|
|
126
|
+
headers: { "Content-Type": "application/json" }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function linkPreviewHandler(request) {
|
|
130
|
+
const { searchParams } = new URL(request.url);
|
|
131
|
+
const url = searchParams.get("url");
|
|
132
|
+
if (!url) {
|
|
133
|
+
return jsonResponse({ error: "url parameter is required" }, 400);
|
|
134
|
+
}
|
|
135
|
+
let targetUrl;
|
|
136
|
+
try {
|
|
137
|
+
targetUrl = new URL(url);
|
|
138
|
+
if (!["http:", "https:"].includes(targetUrl.protocol)) {
|
|
139
|
+
return jsonResponse(
|
|
140
|
+
{ error: "Only http and https URLs are allowed" },
|
|
141
|
+
400
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return jsonResponse({ error: "Invalid URL format" }, 400);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const metadata = await fetchUrlMetadata(targetUrl.toString());
|
|
149
|
+
return jsonResponse(metadata);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.name === "AbortError") {
|
|
152
|
+
return jsonResponse({ error: "Request timeout" }, 408);
|
|
153
|
+
}
|
|
154
|
+
console.error("Error fetching link metadata:", error);
|
|
155
|
+
return jsonResponse({ error: "Failed to fetch link metadata" }, 500);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
fetchUrlMetadata,
|
|
160
|
+
linkPreviewHandler,
|
|
161
|
+
parseMetaTags
|
|
162
|
+
};
|
|
163
|
+
//# sourceMappingURL=link-preview.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\r\n * @lumir-company/editor - Link Preview API Handler\r\n *\r\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\r\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\r\n *\r\n * Next.js App Router 사용 예시:\r\n * ```ts\r\n * // src/app/api/link-preview/route.ts\r\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\r\n * ```\r\n */\r\n\r\nexport interface LinkMetadata {\r\n url: string;\r\n title: string;\r\n description?: string;\r\n image?: string;\r\n domain: string;\r\n}\r\n\r\nfunction extractDomain(url: string): string {\r\n try {\r\n return new URL(url).hostname.replace(/^www\\./, \"\");\r\n } catch {\r\n return url;\r\n }\r\n}\r\n\r\nfunction decodeHtmlEntity(str: string): string {\r\n return str\r\n .replace(/&/g, \"&\")\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/ /g, \" \")\r\n .replace(/'/g, \"'\")\r\n .replace(///g, \"/\");\r\n}\r\n\r\nfunction getMetaContent(\r\n html: string,\r\n property: string,\r\n name?: string\r\n): string | null {\r\n const patterns = [\r\n new RegExp(\r\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\r\n \"i\"\r\n ),\r\n ];\r\n\r\n if (name) {\r\n patterns.push(\r\n new RegExp(\r\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\r\n \"i\"\r\n )\r\n );\r\n }\r\n\r\n for (const pattern of patterns) {\r\n const match = html.match(pattern);\r\n if (match?.[1]) return match[1].trim();\r\n }\r\n\r\n return null;\r\n}\r\n\r\n/**\r\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\r\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\r\n */\r\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\r\n const domain = extractDomain(baseUrl);\r\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\r\n\r\n const ogTitle = getMetaContent(html, \"og:title\");\r\n const ogDescription = getMetaContent(html, \"og:description\");\r\n const ogImage = getMetaContent(html, \"og:image\");\r\n const ogUrl = getMetaContent(html, \"og:url\");\r\n\r\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\r\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\r\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\r\n\r\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\r\n const descriptionMatch = html.match(\r\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\r\n );\r\n\r\n if (ogTitle) {\r\n metadata.title = decodeHtmlEntity(ogTitle);\r\n } else if (twitterTitle) {\r\n metadata.title = decodeHtmlEntity(twitterTitle);\r\n } else if (titleMatch?.[1]) {\r\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\r\n }\r\n\r\n if (ogDescription) {\r\n metadata.description = decodeHtmlEntity(ogDescription);\r\n } else if (twitterDescription) {\r\n metadata.description = decodeHtmlEntity(twitterDescription);\r\n } else if (descriptionMatch?.[1]) {\r\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\r\n }\r\n\r\n let imageUrl: string | undefined;\r\n if (ogImage) {\r\n imageUrl = ogImage;\r\n } else if (twitterImage) {\r\n imageUrl = twitterImage;\r\n }\r\n\r\n if (imageUrl) {\r\n imageUrl = decodeHtmlEntity(imageUrl);\r\n if (imageUrl.trim()) {\r\n try {\r\n metadata.image = new URL(imageUrl, baseUrl).toString();\r\n } catch {\r\n metadata.image = undefined;\r\n }\r\n }\r\n }\r\n\r\n if (ogUrl) {\r\n try {\r\n metadata.url = new URL(ogUrl, baseUrl).toString();\r\n } catch {\r\n // keep original URL\r\n }\r\n }\r\n\r\n return metadata;\r\n}\r\n\r\n/**\r\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\r\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\r\n */\r\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\r\n const targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n throw new Error(\"Only http and https URLs are allowed\");\r\n }\r\n\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), 8000);\r\n\r\n try {\r\n const response = await fetch(targetUrl.toString(), {\r\n signal: controller.signal,\r\n headers: {\r\n \"User-Agent\":\r\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\r\n Accept:\r\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\r\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\r\n },\r\n redirect: \"follow\",\r\n });\r\n\r\n clearTimeout(timeoutId);\r\n\r\n if (!response.ok) {\r\n throw new Error(\r\n `Failed to fetch URL: ${response.status} ${response.statusText}`\r\n );\r\n }\r\n\r\n const html = await response.text();\r\n return parseMetaTags(html, targetUrl.toString());\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n throw error;\r\n }\r\n}\r\n\r\nfunction jsonResponse(data: unknown, status = 200): Response {\r\n return new Response(JSON.stringify(data), {\r\n status,\r\n headers: { \"Content-Type\": \"application/json\" },\r\n });\r\n}\r\n\r\n/**\r\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\r\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\r\n *\r\n * @example\r\n * // Next.js: src/app/api/link-preview/route.ts\r\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\r\n */\r\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\r\n const { searchParams } = new URL(request.url);\r\n const url = searchParams.get(\"url\");\r\n\r\n if (!url) {\r\n return jsonResponse({ error: \"url parameter is required\" }, 400);\r\n }\r\n\r\n let targetUrl: URL;\r\n try {\r\n targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n return jsonResponse(\r\n { error: \"Only http and https URLs are allowed\" },\r\n 400\r\n );\r\n }\r\n } catch {\r\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\r\n }\r\n\r\n try {\r\n const metadata = await fetchUrlMetadata(targetUrl.toString());\r\n return jsonResponse(metadata);\r\n } catch (error: any) {\r\n if (error.name === \"AbortError\") {\r\n return jsonResponse({ error: \"Request timeout\" }, 408);\r\n }\r\n\r\n console.error(\"Error fetching link metadata:\", error);\r\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
|
package/dist/index.d.mts
CHANGED
|
@@ -39,8 +39,9 @@ declare class LumirEditorError extends Error {
|
|
|
39
39
|
static uploadFailed(message: string, originalError?: Error): LumirEditorError;
|
|
40
40
|
/**
|
|
41
41
|
* 잘못된 파일 형식 에러 생성
|
|
42
|
+
* @param allowVideoUpload true이면 "image and video" 메시지 사용
|
|
42
43
|
*/
|
|
43
|
-
static invalidFileType(fileName: string): LumirEditorError;
|
|
44
|
+
static invalidFileType(fileName: string, allowVideoUpload?: boolean): LumirEditorError;
|
|
44
45
|
/**
|
|
45
46
|
* S3 설정 에러 생성
|
|
46
47
|
*/
|
|
@@ -77,6 +78,12 @@ interface LumirEditorProps {
|
|
|
77
78
|
appendUUID?: boolean;
|
|
78
79
|
/** false로 설정하면 확장자를 자동으로 붙이지 않음 (기본: true) */
|
|
79
80
|
preserveExtension?: boolean;
|
|
81
|
+
/** 업로드 진행률(0–100) 콜백. S3 PUT 시에만 호출됨 */
|
|
82
|
+
onProgress?: (percent: number) => void;
|
|
83
|
+
/** PUT 요청 타임아웃(ms). 미설정 시 120000(120초). 대용량 비디오에 유리 */
|
|
84
|
+
uploadTimeoutMs?: number;
|
|
85
|
+
/** PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도) */
|
|
86
|
+
maxRetries?: number;
|
|
80
87
|
};
|
|
81
88
|
allowVideoUpload?: boolean;
|
|
82
89
|
allowAudioUpload?: boolean;
|
|
@@ -118,10 +125,11 @@ interface LumirEditorProps {
|
|
|
118
125
|
/** 에러 발생 시 호출되는 콜백 */
|
|
119
126
|
onError?: (error: LumirEditorError) => void;
|
|
120
127
|
/**
|
|
121
|
-
*
|
|
122
|
-
* - S3 등 외부 스토리지에서
|
|
128
|
+
* 이미지·비디오(미디어)가 에디터에서 삭제될 때 호출되는 콜백
|
|
129
|
+
* - S3 등 외부 스토리지에서 해당 미디어를 삭제하는 용도로 사용
|
|
130
|
+
* - 이미지 블록과 비디오 블록 삭제 시 모두 호출됨
|
|
123
131
|
* - Undo/Redo를 고려하여 지연 삭제를 권장
|
|
124
|
-
* @param imageUrl 삭제된
|
|
132
|
+
* @param imageUrl 삭제된 이미지 또는 비디오의 URL
|
|
125
133
|
*/
|
|
126
134
|
onImageDelete?: (imageUrl: string) => void;
|
|
127
135
|
}
|
|
@@ -210,6 +218,12 @@ interface S3UploaderConfig {
|
|
|
210
218
|
appendUUID?: boolean;
|
|
211
219
|
/** false로 설정하면 확장자를 자동으로 붙이지 않음 (기본: true) */
|
|
212
220
|
preserveExtension?: boolean;
|
|
221
|
+
/** 업로드 진행률(0–100) 콜백. S3 PUT 시에만 호출됨 */
|
|
222
|
+
onProgress?: (percent: number) => void;
|
|
223
|
+
/** PUT 요청 타임아웃(ms). 미설정 시 120000(120초). 대용량 비디오에 유리 */
|
|
224
|
+
uploadTimeoutMs?: number;
|
|
225
|
+
/** PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도) */
|
|
226
|
+
maxRetries?: number;
|
|
213
227
|
}
|
|
214
228
|
declare const createS3Uploader: (config: S3UploaderConfig) => (file: File) => Promise<string>;
|
|
215
229
|
|
package/dist/index.d.ts
CHANGED
|
@@ -39,8 +39,9 @@ declare class LumirEditorError extends Error {
|
|
|
39
39
|
static uploadFailed(message: string, originalError?: Error): LumirEditorError;
|
|
40
40
|
/**
|
|
41
41
|
* 잘못된 파일 형식 에러 생성
|
|
42
|
+
* @param allowVideoUpload true이면 "image and video" 메시지 사용
|
|
42
43
|
*/
|
|
43
|
-
static invalidFileType(fileName: string): LumirEditorError;
|
|
44
|
+
static invalidFileType(fileName: string, allowVideoUpload?: boolean): LumirEditorError;
|
|
44
45
|
/**
|
|
45
46
|
* S3 설정 에러 생성
|
|
46
47
|
*/
|
|
@@ -77,6 +78,12 @@ interface LumirEditorProps {
|
|
|
77
78
|
appendUUID?: boolean;
|
|
78
79
|
/** false로 설정하면 확장자를 자동으로 붙이지 않음 (기본: true) */
|
|
79
80
|
preserveExtension?: boolean;
|
|
81
|
+
/** 업로드 진행률(0–100) 콜백. S3 PUT 시에만 호출됨 */
|
|
82
|
+
onProgress?: (percent: number) => void;
|
|
83
|
+
/** PUT 요청 타임아웃(ms). 미설정 시 120000(120초). 대용량 비디오에 유리 */
|
|
84
|
+
uploadTimeoutMs?: number;
|
|
85
|
+
/** PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도) */
|
|
86
|
+
maxRetries?: number;
|
|
80
87
|
};
|
|
81
88
|
allowVideoUpload?: boolean;
|
|
82
89
|
allowAudioUpload?: boolean;
|
|
@@ -118,10 +125,11 @@ interface LumirEditorProps {
|
|
|
118
125
|
/** 에러 발생 시 호출되는 콜백 */
|
|
119
126
|
onError?: (error: LumirEditorError) => void;
|
|
120
127
|
/**
|
|
121
|
-
*
|
|
122
|
-
* - S3 등 외부 스토리지에서
|
|
128
|
+
* 이미지·비디오(미디어)가 에디터에서 삭제될 때 호출되는 콜백
|
|
129
|
+
* - S3 등 외부 스토리지에서 해당 미디어를 삭제하는 용도로 사용
|
|
130
|
+
* - 이미지 블록과 비디오 블록 삭제 시 모두 호출됨
|
|
123
131
|
* - Undo/Redo를 고려하여 지연 삭제를 권장
|
|
124
|
-
* @param imageUrl 삭제된
|
|
132
|
+
* @param imageUrl 삭제된 이미지 또는 비디오의 URL
|
|
125
133
|
*/
|
|
126
134
|
onImageDelete?: (imageUrl: string) => void;
|
|
127
135
|
}
|
|
@@ -210,6 +218,12 @@ interface S3UploaderConfig {
|
|
|
210
218
|
appendUUID?: boolean;
|
|
211
219
|
/** false로 설정하면 확장자를 자동으로 붙이지 않음 (기본: true) */
|
|
212
220
|
preserveExtension?: boolean;
|
|
221
|
+
/** 업로드 진행률(0–100) 콜백. S3 PUT 시에만 호출됨 */
|
|
222
|
+
onProgress?: (percent: number) => void;
|
|
223
|
+
/** PUT 요청 타임아웃(ms). 미설정 시 120000(120초). 대용량 비디오에 유리 */
|
|
224
|
+
uploadTimeoutMs?: number;
|
|
225
|
+
/** PUT 실패 시 재시도 횟수. 기본 2(최대 3회 시도) */
|
|
226
|
+
maxRetries?: number;
|
|
213
227
|
}
|
|
214
228
|
declare const createS3Uploader: (config: S3UploaderConfig) => (file: File) => Promise<string>;
|
|
215
229
|
|