diff --git a/README.ko.md b/README.ko.md index 95483fe..0d9c925 100644 --- a/README.ko.md +++ b/README.ko.md @@ -26,6 +26,7 @@ React에서 document head 요소를 관리하기 위한 가볍고 CSR에 최적 - ✅ **경량** - React 외에 의존성 제로 - ✅ **CSR 최적화** - 동기적 DOM 업데이트를 위해 `useLayoutEffect` 사용 - ✅ **Open Graph 지원** - 소셜 미디어 메타 태그 기본 지원 +- ✅ **Twitter Card 지원** - Open Graph 태그 설정 시 Twitter 태그 자동 생성 - ✅ **간단한 API** - props만 전달하면 되는 복잡하지 않은 설정 ## 설치 @@ -73,6 +74,7 @@ function MyPage() { - document title 설정 - meta description과 keywords 추가/업데이트 - 소셜 미디어용 Open Graph 태그 추가/업데이트 +- Twitter Card 태그 추가/업데이트 (Open Graph 태그에서 자동 생성) - 중복 태그 제거 ## API 레퍼런스 @@ -90,6 +92,16 @@ function MyPage() { | `ogUrl` | `string` | 소셜 미디어 공유를 위한 Open Graph URL (og:url) | | `ogType` | `string` | 소셜 미디어 공유를 위한 Open Graph 타입, 예: "website", "article" (og:type) | +### Twitter Card 지원 + +Open Graph 태그를 설정하면 해당하는 Twitter Card 태그가 자동으로 생성됩니다: + +| Open Graph Prop | 생성되는 Twitter 태그 | +| --------------- | --------------------- | +| `ogTitle` | `twitter:title` | +| `ogDescription` | `twitter:description` | +| `ogImage` | `twitter:image` + `twitter:card` (summary_large_image) | + ## 로컬 개발 예제 애플리케이션으로 로컬 변경사항을 테스트하려면: diff --git a/README.md b/README.md index c565ae3..902ef4a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A lightweight, CSR-focused alternative for managing document head elements in Re - ✅ **Lightweight** - Zero dependencies except React - ✅ **CSR Optimized** - Uses `useLayoutEffect` for synchronous DOM updates - ✅ **Open Graph Support** - Built-in support for social media meta tags +- ✅ **Twitter Card Support** - Automatically sets Twitter tags when Open Graph tags are provided - ✅ **Simple API** - Just pass props, no complex configuration ## Installation @@ -73,6 +74,7 @@ That's it! The component will automatically: - Set the document title - Add/update meta description and keywords - Add/update Open Graph tags for social media +- Add/update Twitter Card tags (automatically generated from Open Graph tags) - Remove any duplicate tags ## API Reference @@ -90,6 +92,16 @@ That's it! The component will automatically: | `ogUrl` | `string` | The canonical URL of your object that will be used as its permanent ID in the graph (og:url) | | `ogType` | `string` | The type of your object, e.g., "website", "article" (og:type) | +### Twitter Card Support + +When you set Open Graph tags, the corresponding Twitter Card tags are automatically generated: + +| Open Graph Prop | Twitter Tag Generated | +| --------------- | --------------------- | +| `ogTitle` | `twitter:title` | +| `ogDescription` | `twitter:description` | +| `ogImage` | `twitter:image` + `twitter:card` (summary_large_image) | + ## Local Development To test your local changes with the example application: diff --git a/package.json b/package.json index 89702cc..09e10fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-head-safe", - "version": "1.2.0", + "version": "1.3.0", "description": "A lightweight React head manager for CSR apps. Safely manage document title, meta tags, Open Graph, and SEO metadata without duplicates. TypeScript support included.", "author": "umsungjun", "license": "MIT", diff --git a/src/ReactHeadSafe.tsx b/src/ReactHeadSafe.tsx index ab58d20..52238b6 100644 --- a/src/ReactHeadSafe.tsx +++ b/src/ReactHeadSafe.tsx @@ -44,17 +44,21 @@ export const ReactHeadSafe: FC = ({ updateMetaTag('name', 'keywords', keywords); } - // Update Open Graph tags + // Update Open Graph tags and Twitter tags if (ogTitle !== undefined) { updateMetaTag('property', 'og:title', ogTitle); + updateMetaTag('name', 'twitter:title', ogTitle); } if (ogDescription !== undefined) { updateMetaTag('property', 'og:description', ogDescription); + updateMetaTag('name', 'twitter:description', ogDescription); } if (ogImage !== undefined) { updateMetaTag('property', 'og:image', ogImage); + updateMetaTag('name', 'twitter:image', ogImage); + updateMetaTag('name', 'twitter:card', 'summary_large_image'); } if (ogUrl !== undefined) { diff --git a/src/test/ReactHeadSafe.test.tsx b/src/test/ReactHeadSafe.test.tsx index 99508d4..4354d8b 100644 --- a/src/test/ReactHeadSafe.test.tsx +++ b/src/test/ReactHeadSafe.test.tsx @@ -196,6 +196,143 @@ describe('ReactHeadSafe', () => { }); }); + describe('Twitter tags', () => { + it('should create twitter:title meta tag when ogTitle is set', () => { + render(); + + const metaTag = document.querySelector('meta[name="twitter:title"]'); + expect(metaTag).toBeInTheDocument(); + expect(metaTag?.getAttribute('content')).toBe('Twitter Test Title'); + }); + + it('should create twitter:description meta tag when ogDescription is set', () => { + render(); + + const metaTag = document.querySelector( + 'meta[name="twitter:description"]' + ); + expect(metaTag).toBeInTheDocument(); + expect(metaTag?.getAttribute('content')).toBe('Twitter Test Description'); + }); + + it('should create twitter:image meta tag when ogImage is set', () => { + render(); + + const metaTag = document.querySelector('meta[name="twitter:image"]'); + expect(metaTag).toBeInTheDocument(); + expect(metaTag?.getAttribute('content')).toBe( + 'https://example.com/twitter-image.jpg' + ); + }); + + it('should create twitter:card meta tag with summary_large_image when ogImage is set', () => { + render(); + + const metaTag = document.querySelector('meta[name="twitter:card"]'); + expect(metaTag).toBeInTheDocument(); + expect(metaTag?.getAttribute('content')).toBe('summary_large_image'); + }); + + it('should prevent duplicate twitter:title meta tags', () => { + const { rerender } = render( + + ); + rerender(); + + const metaTags = document.querySelectorAll('meta[name="twitter:title"]'); + expect(metaTags).toHaveLength(1); + expect(metaTags[0].getAttribute('content')).toBe('Second Twitter Title'); + }); + + it('should prevent duplicate twitter:description meta tags', () => { + const { rerender } = render( + + ); + rerender(); + + const metaTags = document.querySelectorAll( + 'meta[name="twitter:description"]' + ); + expect(metaTags).toHaveLength(1); + expect(metaTags[0].getAttribute('content')).toBe( + 'Second Twitter Description' + ); + }); + + it('should prevent duplicate twitter:image meta tags', () => { + const { rerender } = render( + + ); + rerender(); + + const metaTags = document.querySelectorAll('meta[name="twitter:image"]'); + expect(metaTags).toHaveLength(1); + expect(metaTags[0].getAttribute('content')).toBe( + 'https://example.com/second.jpg' + ); + }); + + it('should prevent duplicate twitter:card meta tags', () => { + const { rerender } = render( + + ); + rerender(); + + const metaTags = document.querySelectorAll('meta[name="twitter:card"]'); + expect(metaTags).toHaveLength(1); + expect(metaTags[0].getAttribute('content')).toBe('summary_large_image'); + }); + + it('should set both og and twitter tags together', () => { + render( + + ); + + // OG tags + expect( + document + .querySelector('meta[property="og:title"]') + ?.getAttribute('content') + ).toBe('Shared Title'); + expect( + document + .querySelector('meta[property="og:description"]') + ?.getAttribute('content') + ).toBe('Shared Description'); + expect( + document + .querySelector('meta[property="og:image"]') + ?.getAttribute('content') + ).toBe('https://example.com/shared.jpg'); + + // Twitter tags + expect( + document + .querySelector('meta[name="twitter:title"]') + ?.getAttribute('content') + ).toBe('Shared Title'); + expect( + document + .querySelector('meta[name="twitter:description"]') + ?.getAttribute('content') + ).toBe('Shared Description'); + expect( + document + .querySelector('meta[name="twitter:image"]') + ?.getAttribute('content') + ).toBe('https://example.com/shared.jpg'); + expect( + document + .querySelector('meta[name="twitter:card"]') + ?.getAttribute('content') + ).toBe('summary_large_image'); + }); + }); + describe('multiple props', () => { it('should handle all props together', () => { render(