@lumir-company/editor 0.4.3 → 0.4.4

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 CHANGED
@@ -19,6 +19,8 @@
19
19
  - [커스텀 업로더](#2-커스텀-업로더)
20
20
  - [이미지 삭제](#이미지-삭제)
21
21
  - [HTML 미리보기](#html-미리보기)
22
+ - [Placeholder](#placeholder)
23
+ - [링크 프리뷰](#링크-프리뷰)
22
24
  - [Props API](#props-api)
23
25
  - [사용 예제](#사용-예제)
24
26
  - [스타일링](#스타일링)
@@ -91,7 +93,7 @@ import "@lumir-company/editor/style.css";
91
93
  const LumirEditor = dynamic(
92
94
  () =>
93
95
  import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
94
- { ssr: false }
96
+ { ssr: false },
95
97
  );
96
98
 
97
99
  export default function EditorPage() {
@@ -369,21 +371,28 @@ function Editor() {
369
371
  if (pendingDeletes.current.has(imageUrl)) return;
370
372
 
371
373
  // 5분 후 삭제 예약
372
- const timeoutId = setTimeout(async () => {
373
- pendingDeletes.current.delete(imageUrl);
374
-
375
- // S3에서 실제 삭제
376
- await fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
377
- method: "DELETE",
378
- });
379
- }, 5 * 60 * 1000); // 5분
374
+ const timeoutId = setTimeout(
375
+ async () => {
376
+ pendingDeletes.current.delete(imageUrl);
377
+
378
+ // S3에서 실제 삭제
379
+ await fetch(`/api/s3/delete?url=${encodeURIComponent(imageUrl)}`, {
380
+ method: "DELETE",
381
+ });
382
+ },
383
+ 5 * 60 * 1000,
384
+ ); // 5분
380
385
 
381
386
  pendingDeletes.current.set(imageUrl, timeoutId);
382
387
  }, []);
383
388
 
384
389
  return (
385
390
  <LumirEditor
386
- s3Upload={{ /* ... */ }}
391
+ s3Upload={
392
+ {
393
+ /* ... */
394
+ }
395
+ }
387
396
  onImageDelete={handleImageDelete}
388
397
  />
389
398
  );
@@ -425,7 +434,7 @@ export async function DELETE(req: NextRequest) {
425
434
  new DeleteObjectCommand({
426
435
  Bucket: process.env.AWS_S3_BUCKET!,
427
436
  Key: key,
428
- })
437
+ }),
429
438
  );
430
439
 
431
440
  return NextResponse.json({ success: true });
@@ -446,7 +455,7 @@ const handleImageDelete = (imageUrl: string) => {
446
455
  });
447
456
  };
448
457
 
449
- <LumirEditor onImageDelete={handleImageDelete} />
458
+ <LumirEditor onImageDelete={handleImageDelete} />;
450
459
  ```
451
460
 
452
461
  #### React + Express
@@ -454,15 +463,17 @@ const handleImageDelete = (imageUrl: string) => {
454
463
  **서버** (`server.js`):
455
464
 
456
465
  ```javascript
457
- app.delete('/api/images', async (req, res) => {
466
+ app.delete("/api/images", async (req, res) => {
458
467
  const { imageUrl } = req.body;
459
468
  const key = extractKeyFromS3Url(imageUrl);
460
-
461
- await s3Client.send(new DeleteObjectCommand({
462
- Bucket: process.env.S3_BUCKET,
463
- Key: key
464
- }));
465
-
469
+
470
+ await s3Client.send(
471
+ new DeleteObjectCommand({
472
+ Bucket: process.env.S3_BUCKET,
473
+ Key: key,
474
+ }),
475
+ );
476
+
466
477
  res.json({ success: true });
467
478
  });
468
479
  ```
@@ -471,47 +482,47 @@ app.delete('/api/images', async (req, res) => {
471
482
 
472
483
  ```tsx
473
484
  const handleImageDelete = async (imageUrl: string) => {
474
- await fetch('https://api.myapp.com/api/images', {
475
- method: 'DELETE',
476
- headers: { 'Content-Type': 'application/json' },
477
- body: JSON.stringify({ imageUrl })
485
+ await fetch("https://api.myapp.com/api/images", {
486
+ method: "DELETE",
487
+ headers: { "Content-Type": "application/json" },
488
+ body: JSON.stringify({ imageUrl }),
478
489
  });
479
490
  };
480
491
 
481
- <LumirEditor onImageDelete={handleImageDelete} />
492
+ <LumirEditor onImageDelete={handleImageDelete} />;
482
493
  ```
483
494
 
484
495
  #### React Native + Firebase Storage
485
496
 
486
497
  ```tsx
487
- import storage from '@react-native-firebase/storage';
498
+ import storage from "@react-native-firebase/storage";
488
499
 
489
500
  const handleImageDelete = async (imageUrl: string) => {
490
501
  const ref = storage().refFromURL(imageUrl);
491
502
  await ref.delete();
492
503
  };
493
504
 
494
- <LumirEditor onImageDelete={handleImageDelete} />
505
+ <LumirEditor onImageDelete={handleImageDelete} />;
495
506
  ```
496
507
 
497
508
  #### Vue + Axios + FastAPI
498
509
 
499
510
  ```typescript
500
511
  const handleImageDelete = async (imageUrl: string) => {
501
- await axios.delete('https://api.myapp.com/v1/images', {
502
- data: { imageUrl }
512
+ await axios.delete("https://api.myapp.com/v1/images", {
513
+ data: { imageUrl },
503
514
  });
504
515
  };
505
516
  ```
506
517
 
507
518
  ### 주의사항
508
519
 
509
- | 항목 | 설명 |
510
- |------|------|
511
- | **Undo/Redo** | 지연 삭제로 복원 가능하게 구현 (권장: 5-10분) |
512
- | **권한 검증** | 프로덕션에서는 인증/인가 필수 |
513
- | **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인 |
514
- | **삭제 로그** | 감사 추적을 위한 삭제 기록 저장 권장 |
520
+ | 항목 | 설명 |
521
+ | --------------- | --------------------------------------------- |
522
+ | **Undo/Redo** | 지연 삭제로 복원 가능하게 구현 (권장: 5-10분) |
523
+ | **권한 검증** | 프로덕션에서는 인증/인가 필수 |
524
+ | **참조 카운트** | 같은 이미지를 여러 문서에서 사용하는지 확인 |
525
+ | **삭제 로그** | 감사 추적을 위한 삭제 기록 저장 권장 |
515
526
 
516
527
  ---
517
528
 
@@ -601,21 +612,121 @@ editor.insertBlocks([
601
612
 
602
613
  ---
603
614
 
615
+ ## Placeholder
616
+
617
+ 에디터가 비어있을 때 사용자에게 안내 텍스트를 표시합니다.
618
+
619
+ ### 사용 방법
620
+
621
+ ```tsx
622
+ <LumirEditor placeholder="내용을 입력하세요..." />
623
+ ```
624
+
625
+ - 빈 블록에 연한 색상으로 안내 텍스트가 표시됩니다
626
+ - 사용자가 입력을 시작하면 자동으로 사라집니다
627
+ - 모든 빈 블록(첫 번째 블록 포함)에 동일한 텍스트가 적용됩니다
628
+
629
+ ---
630
+
631
+ ## 링크 프리뷰
632
+
633
+ URL을 붙여넣거나 슬래시 메뉴에서 선택하면 Open Graph 카드를 표시합니다.
634
+
635
+ > **서버 사이드 필수**: 링크 프리뷰는 외부 사이트의 OG 메타데이터를 가져오기 위해 **서버 사이드 API 라우트**가 필요합니다. 브라우저의 CORS 정책으로 인해 클라이언트에서 직접 외부 HTML을 가져올 수 없습니다.
636
+
637
+ ### 사용 조건
638
+
639
+ | 조건 | 설명 |
640
+ | -------------------------- | -------------------------------------------------------------------------- |
641
+ | **서버 환경** | Next.js, Remix, SvelteKit 등 서버 사이드 라우팅을 지원하는 프레임워크 필요 |
642
+ | **API 라우트** | 패키지 내장 핸들러를 re-export하는 1줄짜리 파일 필요 |
643
+ | **순수 React (CRA, Vite)** | 별도 백엔드 서버 없이는 사용 불가 |
644
+
645
+ ### 설정 방법 (Next.js App Router)
646
+
647
+ **1단계: API 라우트 생성** (1줄)
648
+
649
+ ```ts
650
+ // src/app/api/link-preview/route.ts
651
+ export { linkPreviewHandler as GET } from "@lumir-company/editor/api/link-preview";
652
+ ```
653
+
654
+ 패키지에 내장된 핸들러를 re-export하므로 별도 로직 작성이 불필요합니다.
655
+
656
+ **2단계: 에디터에 linkPreview prop 설정**
657
+
658
+ ```tsx
659
+ <LumirEditor linkPreview={{ apiEndpoint: "/api/link-preview" }} />
660
+ ```
661
+
662
+ ### 설정 방법 (Remix / SvelteKit)
663
+
664
+ 패키지의 `GET` 핸들러는 표준 Web API(`Request`/`Response`)를 사용하므로 Remix, SvelteKit 등에서도 동일하게 사용 가능합니다.
665
+
666
+ ```ts
667
+ // Remix: app/routes/api.link-preview.ts
668
+ export { linkPreviewHandler as loader } from "@lumir-company/editor/api/link-preview";
669
+ ```
670
+
671
+ ### 설정 방법 (Express / Fastify 등 커스텀 서버)
672
+
673
+ 패키지에서 `fetchUrlMetadata`와 `parseMetaTags`를 import하여 직접 라우트를 구현할 수 있습니다.
674
+
675
+ ```ts
676
+ import { fetchUrlMetadata } from "@lumir-company/editor/api/link-preview";
677
+
678
+ app.get("/api/link-preview", async (req, res) => {
679
+ const url = req.query.url as string;
680
+ if (!url) return res.status(400).json({ error: "url required" });
681
+
682
+ try {
683
+ const metadata = await fetchUrlMetadata(url);
684
+ res.json(metadata);
685
+ } catch {
686
+ res.status(500).json({ error: "Failed to fetch metadata" });
687
+ }
688
+ });
689
+ ```
690
+
691
+ ### 주요 기능
692
+
693
+ - URL 붙여넣기 시 자동 링크 프리뷰 블록 생성
694
+ - 슬래시 메뉴(`/`)에서 Link Preview 항목 선택
695
+ - 드래그 리사이즈 (좌우 너비, 하단 이미지 높이)
696
+ - 텍스트 링크를 링크 프리뷰로 전환 (링크 툴바 버튼)
697
+ - 메타데이터에 이미지 없으면 이미지 영역 자동 생략
698
+ - 에러 카드 클릭 시 링크 이동
699
+
700
+ ### 내장 API 핸들러 export 목록
701
+
702
+ `@lumir-company/editor/api/link-preview`에서 export되는 항목:
703
+
704
+ | Export | 타입 | 설명 |
705
+ | ------------------ | ------------------------------------------------- | ------------------------------------- |
706
+ | `linkPreviewHandler` | `(request: Request) => Promise<Response>` | 링크 프리뷰 메타데이터 조회 핸들러 (re-export용) |
707
+ | `fetchUrlMetadata` | `(url: string) => Promise<LinkMetadata>` | 서버에서 직접 메타데이터 조회 |
708
+ | `parseMetaTags` | `(html: string, baseUrl: string) => LinkMetadata` | HTML에서 OG 메타데이터 파싱 |
709
+ | `LinkMetadata` | `interface` | 메타데이터 타입 정의 |
710
+
711
+ ---
712
+
604
713
  ## Props API
605
714
 
606
715
  ### 핵심 Props
607
716
 
608
- | Prop | 타입 | 기본값 | 설명 |
609
- | ----------------- | ------------------------------------- | ----------- | ----------------------- |
610
- | `s3Upload` | `S3UploaderConfig` | `undefined` | S3 업로드 설정 |
611
- | `uploadFile` | `(file: File) => Promise<string>` | `undefined` | 커스텀 업로드 함수 |
612
- | `onContentChange` | `(blocks) => void` | `undefined` | 콘텐츠 변경 콜백 |
613
- | `onImageDelete` | `(imageUrl: string) => void` | `undefined` | 이미지 삭제 시 콜백 |
614
- | `onError` | `(error: LumirEditorError) => void` | `undefined` | 에러 발생 시 콜백 |
615
- | `initialContent` | `Block[] \| string` | `undefined` | 초기 콘텐츠 |
616
- | `editable` | `boolean` | `true` | 편집 가능 여부 |
617
- | `theme` | `"light" \| "dark"` | `"light"` | 테마 |
618
- | `className` | `string` | `""` | CSS 클래스 |
717
+ | Prop | 타입 | 기본값 | 설명 |
718
+ | ----------------- | ----------------------------------- | ----------- | ------------------- |
719
+ | `s3Upload` | `S3UploaderConfig` | `undefined` | S3 업로드 설정 |
720
+ | `uploadFile` | `(file: File) => Promise<string>` | `undefined` | 커스텀 업로드 함수 |
721
+ | `onContentChange` | `(blocks) => void` | `undefined` | 콘텐츠 변경 콜백 |
722
+ | `onImageDelete` | `(imageUrl: string) => void` | `undefined` | 이미지 삭제 시 콜백 |
723
+ | `onError` | `(error: LumirEditorError) => void` | `undefined` | 에러 발생 시 콜백 |
724
+ | `initialContent` | `Block[] \| string` | `undefined` | 초기 콘텐츠 |
725
+ | `editable` | `boolean` | `true` | 편집 가능 여부 |
726
+ | `placeholder` | `string` | `undefined` | 빈 블록 안내 텍스트 |
727
+ | `linkPreview` | `{ apiEndpoint: string }` | `undefined` | 링크 프리뷰 설정 |
728
+ | `theme` | `"light" \| "dark"` | `"light"` | 테마 |
729
+ | `className` | `string` | `""` | CSS 클래스 |
619
730
 
620
731
  ### S3UploaderConfig
621
732
 
@@ -757,7 +868,7 @@ import { LumirEditor, cn } from "@lumir-company/editor";
757
868
  className={cn(
758
869
  "min-h-[400px] rounded-xl",
759
870
  "border border-gray-200 shadow-lg",
760
- "focus-within:ring-2 focus-within:ring-blue-500"
871
+ "focus-within:ring-2 focus-within:ring-blue-500",
761
872
  )}
762
873
  />;
763
874
  ```
@@ -818,7 +929,7 @@ import { LumirEditor } from "@lumir-company/editor";
818
929
  const LumirEditor = dynamic(
819
930
  () =>
820
931
  import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
821
- { ssr: false }
932
+ { ssr: false },
822
933
  );
823
934
  ```
824
935
 
@@ -893,6 +1004,20 @@ const url = await uploader(imageFile);
893
1004
 
894
1005
  ## 변경 로그
895
1006
 
1007
+ ### v0.4.4
1008
+
1009
+ - **Link Preview API 핸들러 내장**
1010
+ - `@lumir-company/editor/api/link-preview` 서브패스 export 추가
1011
+ - 표준 Web API (`Request`/`Response`) 기반으로 Next.js, Remix, SvelteKit 호환
1012
+ - 소비자는 1줄 re-export만으로 API 라우트 설정 가능 (220줄 route.ts 제거)
1013
+ - `linkPreviewHandler`, `fetchUrlMetadata`, `parseMetaTags` 함수 export
1014
+ - **Placeholder 문서화**
1015
+ - Placeholder 사용 가이드 추가
1016
+ - **README 개선**
1017
+ - 링크 프리뷰 서버사이드 요구사항 및 설정 방법 가이드 추가
1018
+ - 내장 API 핸들러 export 목록 문서화
1019
+ - Props API 테이블에 `placeholder`, `linkPreview` 추가
1020
+
896
1021
  ### v0.4.3
897
1022
 
898
1023
  - **링크 프리뷰 기능 추가**
@@ -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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/&#x27;/g, "'").replace(/&#x2F;/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(/&amp;/g, \"&\")\r\n .replace(/&lt;/g, \"<\")\r\n .replace(/&gt;/g, \">\")\r\n .replace(/&quot;/g, '\"')\r\n .replace(/&#39;/g, \"'\")\r\n .replace(/&nbsp;/g, \" \")\r\n .replace(/&#x27;/g, \"'\")\r\n .replace(/&#x2F;/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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/&#x27;/g, "'").replace(/&#x2F;/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(/&amp;/g, \"&\")\r\n .replace(/&lt;/g, \"<\")\r\n .replace(/&gt;/g, \">\")\r\n .replace(/&quot;/g, '\"')\r\n .replace(/&#39;/g, \"'\")\r\n .replace(/&nbsp;/g, \" \")\r\n .replace(/&#x27;/g, \"'\")\r\n .replace(/&#x2F;/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumir-company/editor",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": false,
5
5
  "description": "Image-only BlockNote rich text editor with S3 upload, customizable filename transforms, UUID support, and loading spinner",
6
6
  "keywords": [
@@ -34,10 +34,16 @@
34
34
  },
35
35
  "exports": {
36
36
  ".": {
37
+ "types": "./dist/index.d.ts",
37
38
  "import": "./dist/index.mjs",
38
39
  "require": "./dist/index.js"
39
40
  },
40
- "./style.css": "./dist/style.css"
41
+ "./style.css": "./dist/style.css",
42
+ "./api/link-preview": {
43
+ "types": "./dist/api/link-preview.d.ts",
44
+ "import": "./dist/api/link-preview.mjs",
45
+ "require": "./dist/api/link-preview.js"
46
+ }
41
47
  },
42
48
  "files": [
43
49
  "dist"
@@ -47,8 +53,8 @@
47
53
  "access": "public"
48
54
  },
49
55
  "scripts": {
50
- "build": "tsup src/index.ts --dts --format cjs,esm --sourcemap && node scripts/copy-style.js",
51
- "dev": "tsup src/index.ts --dts --format cjs,esm --sourcemap --watch --onSuccess \"node scripts/copy-style.js\"",
56
+ "build": "tsup src/index.ts src/api/link-preview.ts --dts --format cjs,esm --sourcemap && node scripts/copy-style.js",
57
+ "dev": "tsup src/index.ts src/api/link-preview.ts --dts --format cjs,esm --sourcemap --watch --onSuccess \"node scripts/copy-style.js\"",
52
58
  "type-check": "tsc --noEmit",
53
59
  "test": "vitest run",
54
60
  "test:watch": "vitest",