@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 +172 -47
- 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/package.json +10 -4
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(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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(
|
|
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(
|
|
462
|
-
|
|
463
|
-
|
|
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(
|
|
475
|
-
method:
|
|
476
|
-
headers: {
|
|
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
|
|
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(
|
|
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**
|
|
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`
|
|
611
|
-
| `uploadFile` | `(file: File) => Promise<string>`
|
|
612
|
-
| `onContentChange` | `(blocks) => void`
|
|
613
|
-
| `onImageDelete` | `(imageUrl: string) => void`
|
|
614
|
-
| `onError` | `(error: LumirEditorError) => void`
|
|
615
|
-
| `initialContent` | `Block[] \| string`
|
|
616
|
-
| `editable` | `boolean`
|
|
617
|
-
| `
|
|
618
|
-
| `
|
|
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(/&/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumir-company/editor",
|
|
3
|
-
"version": "0.4.
|
|
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",
|