@lumir-company/editor 0.4.23 → 0.4.26
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 +1728 -1703
- package/dist/api/link-preview.js.map +1 -1
- package/dist/api/link-preview.mjs.map +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +1463 -1463
- package/package.json +103 -100
|
@@ -1 +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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\n * @lumir-company/editor - Link Preview API Handler\n *\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\n *\n * Next.js App Router 사용 예시:\n * ```ts\n * // src/app/api/link-preview/route.ts\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\n * ```\n */\n\nexport interface LinkMetadata {\n url: string;\n title: string;\n description?: string;\n image?: string;\n domain: string;\n}\n\nfunction extractDomain(url: string): string {\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n } catch {\n return url;\n }\n}\n\nfunction decodeHtmlEntity(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/ /g, \" \")\n .replace(/'/g, \"'\")\n .replace(///g, \"/\");\n}\n\nfunction getMetaContent(\n html: string,\n property: string,\n name?: string\n): string | null {\n const patterns = [\n new RegExp(\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\n \"i\"\n ),\n ];\n\n if (name) {\n patterns.push(\n new RegExp(\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\n \"i\"\n )\n );\n }\n\n for (const pattern of patterns) {\n const match = html.match(pattern);\n if (match?.[1]) return match[1].trim();\n }\n\n return null;\n}\n\n/**\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\n */\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\n const domain = extractDomain(baseUrl);\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\n\n const ogTitle = getMetaContent(html, \"og:title\");\n const ogDescription = getMetaContent(html, \"og:description\");\n const ogImage = getMetaContent(html, \"og:image\");\n const ogUrl = getMetaContent(html, \"og:url\");\n\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\n\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const descriptionMatch = html.match(\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\n );\n\n if (ogTitle) {\n metadata.title = decodeHtmlEntity(ogTitle);\n } else if (twitterTitle) {\n metadata.title = decodeHtmlEntity(twitterTitle);\n } else if (titleMatch?.[1]) {\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\n }\n\n if (ogDescription) {\n metadata.description = decodeHtmlEntity(ogDescription);\n } else if (twitterDescription) {\n metadata.description = decodeHtmlEntity(twitterDescription);\n } else if (descriptionMatch?.[1]) {\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\n }\n\n let imageUrl: string | undefined;\n if (ogImage) {\n imageUrl = ogImage;\n } else if (twitterImage) {\n imageUrl = twitterImage;\n }\n\n if (imageUrl) {\n imageUrl = decodeHtmlEntity(imageUrl);\n if (imageUrl.trim()) {\n try {\n metadata.image = new URL(imageUrl, baseUrl).toString();\n } catch {\n metadata.image = undefined;\n }\n }\n }\n\n if (ogUrl) {\n try {\n metadata.url = new URL(ogUrl, baseUrl).toString();\n } catch {\n // keep original URL\n }\n }\n\n return metadata;\n}\n\n/**\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\n */\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\n const targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n throw new Error(\"Only http and https URLs are allowed\");\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n try {\n const response = await fetch(targetUrl.toString(), {\n signal: controller.signal,\n headers: {\n \"User-Agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n Accept:\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\n },\n redirect: \"follow\",\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n throw new Error(\n `Failed to fetch URL: ${response.status} ${response.statusText}`\n );\n }\n\n const html = await response.text();\n return parseMetaTags(html, targetUrl.toString());\n } catch (error) {\n clearTimeout(timeoutId);\n throw error;\n }\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\n/**\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\n *\n * @example\n * // Next.js: src/app/api/link-preview/route.ts\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\n */\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const url = searchParams.get(\"url\");\n\n if (!url) {\n return jsonResponse({ error: \"url parameter is required\" }, 400);\n }\n\n let targetUrl: URL;\n try {\n targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n return jsonResponse(\n { error: \"Only http and https URLs are allowed\" },\n 400\n );\n }\n } catch {\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\n }\n\n try {\n const metadata = await fetchUrlMetadata(targetUrl.toString());\n return jsonResponse(metadata);\n } catch (error: any) {\n if (error.name === \"AbortError\") {\n return jsonResponse({ error: \"Request timeout\" }, 408);\n }\n\n console.error(\"Error fetching link metadata:\", error);\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +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":";AAqBA,SAAS,cAAc,KAAqB;AAC1C,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE,SAAS,QAAQ,UAAU,EAAE;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,iBAAiB,KAAqB;AAC7C,SAAO,IACJ,QAAQ,UAAU,GAAG,EACrB,QAAQ,SAAS,GAAG,EACpB,QAAQ,SAAS,GAAG,EACpB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,WAAW,GAAG,EACtB,QAAQ,WAAW,GAAG,EACtB,QAAQ,WAAW,GAAG;AAC3B;AAEA,SAAS,eACP,MACA,UACA,MACe;AACf,QAAM,WAAW;AAAA,IACf,IAAI;AAAA,MACF,yBAAyB,QAAQ;AAAA,MACjC;AAAA,IACF;AAAA,IACA,IAAI;AAAA,MACF,qDAAqD,QAAQ;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AAEA,MAAI,MAAM;AACR,aAAS;AAAA,MACP,IAAI;AAAA,QACF,qBAAqB,IAAI;AAAA,QACzB;AAAA,MACF;AAAA,MACA,IAAI;AAAA,QACF,iDAAiD,IAAI;AAAA,QACrD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,aAAW,WAAW,UAAU;AAC9B,UAAM,QAAQ,KAAK,MAAM,OAAO;AAChC,QAAI,QAAQ,CAAC,EAAG,QAAO,MAAM,CAAC,EAAE,KAAK;AAAA,EACvC;AAEA,SAAO;AACT;AAMO,SAAS,cAAc,MAAc,SAA+B;AACzE,QAAM,SAAS,cAAc,OAAO;AACpC,QAAM,WAAyB,EAAE,KAAK,SAAS,OAAO,QAAQ,OAAO;AAErE,QAAM,UAAU,eAAe,MAAM,UAAU;AAC/C,QAAM,gBAAgB,eAAe,MAAM,gBAAgB;AAC3D,QAAM,UAAU,eAAe,MAAM,UAAU;AAC/C,QAAM,QAAQ,eAAe,MAAM,QAAQ;AAE3C,QAAM,eAAe,eAAe,MAAM,IAAI,eAAe;AAC7D,QAAM,qBAAqB,eAAe,MAAM,IAAI,qBAAqB;AACzE,QAAM,eAAe,eAAe,MAAM,IAAI,eAAe;AAE7D,QAAM,aAAa,KAAK,MAAM,+BAA+B;AAC7D,QAAM,mBAAmB,KAAK;AAAA,IAC5B;AAAA,EACF;AAEA,MAAI,SAAS;AACX,aAAS,QAAQ,iBAAiB,OAAO;AAAA,EAC3C,WAAW,cAAc;AACvB,aAAS,QAAQ,iBAAiB,YAAY;AAAA,EAChD,WAAW,aAAa,CAAC,GAAG;AAC1B,aAAS,QAAQ,iBAAiB,WAAW,CAAC,EAAE,KAAK,CAAC;AAAA,EACxD;AAEA,MAAI,eAAe;AACjB,aAAS,cAAc,iBAAiB,aAAa;AAAA,EACvD,WAAW,oBAAoB;AAC7B,aAAS,cAAc,iBAAiB,kBAAkB;AAAA,EAC5D,WAAW,mBAAmB,CAAC,GAAG;AAChC,aAAS,cAAc,iBAAiB,iBAAiB,CAAC,EAAE,KAAK,CAAC;AAAA,EACpE;AAEA,MAAI;AACJ,MAAI,SAAS;AACX,eAAW;AAAA,EACb,WAAW,cAAc;AACvB,eAAW;AAAA,EACb;AAEA,MAAI,UAAU;AACZ,eAAW,iBAAiB,QAAQ;AACpC,QAAI,SAAS,KAAK,GAAG;AACnB,UAAI;AACF,iBAAS,QAAQ,IAAI,IAAI,UAAU,OAAO,EAAE,SAAS;AAAA,MACvD,QAAQ;AACN,iBAAS,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO;AACT,QAAI;AACF,eAAS,MAAM,IAAI,IAAI,OAAO,OAAO,EAAE,SAAS;AAAA,IAClD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO;AACT;AAMA,eAAsB,iBAAiB,KAAoC;AACzE,QAAM,YAAY,IAAI,IAAI,GAAG;AAC7B,MAAI,CAAC,CAAC,SAAS,QAAQ,EAAE,SAAS,UAAU,QAAQ,GAAG;AACrD,UAAM,IAAI,MAAM,sCAAsC;AAAA,EACxD;AAEA,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,GAAI;AAE3D,MAAI;AACF,UAAM,WAAW,MAAM,MAAM,UAAU,SAAS,GAAG;AAAA,MACjD,QAAQ,WAAW;AAAA,MACnB,SAAS;AAAA,QACP,cACE;AAAA,QACF,QACE;AAAA,QACF,mBAAmB;AAAA,MACrB;AAAA,MACA,UAAU;AAAA,IACZ,CAAC;AAED,iBAAa,SAAS;AAEtB,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI;AAAA,QACR,wBAAwB,SAAS,MAAM,IAAI,SAAS,UAAU;AAAA,MAChE;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,SAAS,KAAK;AACjC,WAAO,cAAc,MAAM,UAAU,SAAS,CAAC;AAAA,EACjD,SAAS,OAAO;AACd,iBAAa,SAAS;AACtB,UAAM;AAAA,EACR;AACF;AAEA,SAAS,aAAa,MAAe,SAAS,KAAe;AAC3D,SAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;AAAA,IACxC;AAAA,IACA,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,EAChD,CAAC;AACH;AAUA,eAAsB,mBAAmB,SAAqC;AAC5E,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,QAAQ,GAAG;AAC5C,QAAM,MAAM,aAAa,IAAI,KAAK;AAElC,MAAI,CAAC,KAAK;AACR,WAAO,aAAa,EAAE,OAAO,4BAA4B,GAAG,GAAG;AAAA,EACjE;AAEA,MAAI;AACJ,MAAI;AACF,gBAAY,IAAI,IAAI,GAAG;AACvB,QAAI,CAAC,CAAC,SAAS,QAAQ,EAAE,SAAS,UAAU,QAAQ,GAAG;AACrD,aAAO;AAAA,QACL,EAAE,OAAO,uCAAuC;AAAA,QAChD;AAAA,MACF;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO,aAAa,EAAE,OAAO,qBAAqB,GAAG,GAAG;AAAA,EAC1D;AAEA,MAAI;AACF,UAAM,WAAW,MAAM,iBAAiB,UAAU,SAAS,CAAC;AAC5D,WAAO,aAAa,QAAQ;AAAA,EAC9B,SAAS,OAAY;AACnB,QAAI,MAAM,SAAS,cAAc;AAC/B,aAAO,aAAa,EAAE,OAAO,kBAAkB,GAAG,GAAG;AAAA,IACvD;AAEA,YAAQ,MAAM,iCAAiC,KAAK;AACpD,WAAO,aAAa,EAAE,OAAO,gCAAgC,GAAG,GAAG;AAAA,EACrE;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\n * @lumir-company/editor - Link Preview API Handler\n *\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\n *\n * Next.js App Router 사용 예시:\n * ```ts\n * // src/app/api/link-preview/route.ts\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\n * ```\n */\n\nexport interface LinkMetadata {\n url: string;\n title: string;\n description?: string;\n image?: string;\n domain: string;\n}\n\nfunction extractDomain(url: string): string {\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n } catch {\n return url;\n }\n}\n\nfunction decodeHtmlEntity(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/ /g, \" \")\n .replace(/'/g, \"'\")\n .replace(///g, \"/\");\n}\n\nfunction getMetaContent(\n html: string,\n property: string,\n name?: string\n): string | null {\n const patterns = [\n new RegExp(\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\n \"i\"\n ),\n ];\n\n if (name) {\n patterns.push(\n new RegExp(\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\n \"i\"\n )\n );\n }\n\n for (const pattern of patterns) {\n const match = html.match(pattern);\n if (match?.[1]) return match[1].trim();\n }\n\n return null;\n}\n\n/**\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\n */\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\n const domain = extractDomain(baseUrl);\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\n\n const ogTitle = getMetaContent(html, \"og:title\");\n const ogDescription = getMetaContent(html, \"og:description\");\n const ogImage = getMetaContent(html, \"og:image\");\n const ogUrl = getMetaContent(html, \"og:url\");\n\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\n\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const descriptionMatch = html.match(\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\n );\n\n if (ogTitle) {\n metadata.title = decodeHtmlEntity(ogTitle);\n } else if (twitterTitle) {\n metadata.title = decodeHtmlEntity(twitterTitle);\n } else if (titleMatch?.[1]) {\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\n }\n\n if (ogDescription) {\n metadata.description = decodeHtmlEntity(ogDescription);\n } else if (twitterDescription) {\n metadata.description = decodeHtmlEntity(twitterDescription);\n } else if (descriptionMatch?.[1]) {\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\n }\n\n let imageUrl: string | undefined;\n if (ogImage) {\n imageUrl = ogImage;\n } else if (twitterImage) {\n imageUrl = twitterImage;\n }\n\n if (imageUrl) {\n imageUrl = decodeHtmlEntity(imageUrl);\n if (imageUrl.trim()) {\n try {\n metadata.image = new URL(imageUrl, baseUrl).toString();\n } catch {\n metadata.image = undefined;\n }\n }\n }\n\n if (ogUrl) {\n try {\n metadata.url = new URL(ogUrl, baseUrl).toString();\n } catch {\n // keep original URL\n }\n }\n\n return metadata;\n}\n\n/**\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\n */\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\n const targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n throw new Error(\"Only http and https URLs are allowed\");\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n try {\n const response = await fetch(targetUrl.toString(), {\n signal: controller.signal,\n headers: {\n \"User-Agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n Accept:\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\n },\n redirect: \"follow\",\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n throw new Error(\n `Failed to fetch URL: ${response.status} ${response.statusText}`\n );\n }\n\n const html = await response.text();\n return parseMetaTags(html, targetUrl.toString());\n } catch (error) {\n clearTimeout(timeoutId);\n throw error;\n }\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\n/**\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\n *\n * @example\n * // Next.js: src/app/api/link-preview/route.ts\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\n */\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const url = searchParams.get(\"url\");\n\n if (!url) {\n return jsonResponse({ error: \"url parameter is required\" }, 400);\n }\n\n let targetUrl: URL;\n try {\n targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n return jsonResponse(\n { error: \"Only http and https URLs are allowed\" },\n 400\n );\n }\n } catch {\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\n }\n\n try {\n const metadata = await fetchUrlMetadata(targetUrl.toString());\n return jsonResponse(metadata);\n } catch (error: any) {\n if (error.name === \"AbortError\") {\n return jsonResponse({ error: \"Request timeout\" }, 408);\n }\n\n console.error(\"Error fetching link metadata:\", error);\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import react__default from 'react';
|
|
2
3
|
import * as _blocknote_core from '@blocknote/core';
|
|
3
4
|
import { PartialBlock, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
|
|
4
5
|
export { BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, PartialBlock } from '@blocknote/core';
|
|
5
|
-
import React from 'react';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* LumirEditor 커스텀 에러 클래스
|
|
@@ -256,7 +256,7 @@ declare class EditorConfig {
|
|
|
256
256
|
*/
|
|
257
257
|
static getDisabledExtensions(userExtensions?: string[], allowVideo?: boolean, allowAudio?: boolean, allowFile?: boolean): string[];
|
|
258
258
|
}
|
|
259
|
-
declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps):
|
|
259
|
+
declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react.JSX.Element;
|
|
260
260
|
|
|
261
261
|
declare function cn(...inputs: (string | undefined | null | false)[]): string;
|
|
262
262
|
|
|
@@ -1062,7 +1062,7 @@ interface FloatingMenuProps {
|
|
|
1062
1062
|
/**
|
|
1063
1063
|
* FloatingMenu - 에디터 상단 고정 툴바
|
|
1064
1064
|
*/
|
|
1065
|
-
declare const FloatingMenu:
|
|
1065
|
+
declare const FloatingMenu: react__default.FC<FloatingMenuProps>;
|
|
1066
1066
|
|
|
1067
1067
|
/**
|
|
1068
1068
|
* 인라인 글자 크기 커스텀 스타일.
|
|
@@ -1097,7 +1097,7 @@ declare function clampFontSizePx(px: number): number;
|
|
|
1097
1097
|
/** 숫자 → "Npx" (clamp 포함). */
|
|
1098
1098
|
declare function toFontSizeValue(px: number): string;
|
|
1099
1099
|
|
|
1100
|
-
declare function FontSizeButton():
|
|
1100
|
+
declare function FontSizeButton(): react.JSX.Element | null;
|
|
1101
1101
|
|
|
1102
1102
|
/**
|
|
1103
1103
|
* 색상 팔레트 상수
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import react__default from 'react';
|
|
2
3
|
import * as _blocknote_core from '@blocknote/core';
|
|
3
4
|
import { PartialBlock, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
|
|
4
5
|
export { BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, PartialBlock } from '@blocknote/core';
|
|
5
|
-
import React from 'react';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* LumirEditor 커스텀 에러 클래스
|
|
@@ -256,7 +256,7 @@ declare class EditorConfig {
|
|
|
256
256
|
*/
|
|
257
257
|
static getDisabledExtensions(userExtensions?: string[], allowVideo?: boolean, allowAudio?: boolean, allowFile?: boolean): string[];
|
|
258
258
|
}
|
|
259
|
-
declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps):
|
|
259
|
+
declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react.JSX.Element;
|
|
260
260
|
|
|
261
261
|
declare function cn(...inputs: (string | undefined | null | false)[]): string;
|
|
262
262
|
|
|
@@ -1062,7 +1062,7 @@ interface FloatingMenuProps {
|
|
|
1062
1062
|
/**
|
|
1063
1063
|
* FloatingMenu - 에디터 상단 고정 툴바
|
|
1064
1064
|
*/
|
|
1065
|
-
declare const FloatingMenu:
|
|
1065
|
+
declare const FloatingMenu: react__default.FC<FloatingMenuProps>;
|
|
1066
1066
|
|
|
1067
1067
|
/**
|
|
1068
1068
|
* 인라인 글자 크기 커스텀 스타일.
|
|
@@ -1097,7 +1097,7 @@ declare function clampFontSizePx(px: number): number;
|
|
|
1097
1097
|
/** 숫자 → "Npx" (clamp 포함). */
|
|
1098
1098
|
declare function toFontSizeValue(px: number): string;
|
|
1099
1099
|
|
|
1100
|
-
declare function FontSizeButton():
|
|
1100
|
+
declare function FontSizeButton(): react.JSX.Element | null;
|
|
1101
1101
|
|
|
1102
1102
|
/**
|
|
1103
1103
|
* 색상 팔레트 상수
|