diff --git a/README.ko.md b/README.ko.md index 0d9c925..66c0153 100644 --- a/README.ko.md +++ b/README.ko.md @@ -62,6 +62,7 @@ function MyPage() { ogImage="https://example.com/image.jpg" ogUrl="https://example.com/page" ogType="website" + canonicalUrl="https://example.com/page" />
Your page content...
@@ -75,6 +76,7 @@ function MyPage() { - meta description과 keywords 추가/업데이트 - 소셜 미디어용 Open Graph 태그 추가/업데이트 - Twitter Card 태그 추가/업데이트 (Open Graph 태그에서 자동 생성) +- canonical URL 링크 태그 추가/업데이트 - 중복 태그 제거 ## API 레퍼런스 @@ -91,6 +93,7 @@ function MyPage() { | `ogImage` | `string` | 소셜 미디어 공유를 위한 Open Graph 이미지 URL (og:image) | | `ogUrl` | `string` | 소셜 미디어 공유를 위한 Open Graph URL (og:url) | | `ogType` | `string` | 소셜 미디어 공유를 위한 Open Graph 타입, 예: "website", "article" (og:type) | +| `canonicalUrl` | `string` | SEO를 위한 페이지의 대표 URL (``) | ### Twitter Card 지원 diff --git a/README.md b/README.md index 902ef4a..7408b50 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ function MyPage() { ogImage="https://example.com/image.jpg" ogUrl="https://example.com/page" ogType="website" + canonicalUrl="https://example.com/page" />
Your page content...
@@ -75,6 +76,7 @@ That's it! The component will automatically: - 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) +- Add/update the canonical URL link tag - Remove any duplicate tags ## API Reference @@ -91,6 +93,7 @@ That's it! The component will automatically: | `ogImage` | `string` | The Open Graph image URL (og:image) for social media sharing | | `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) | +| `canonicalUrl` | `string` | The canonical URL of the page for SEO (``) | ### Twitter Card Support diff --git a/examples/basic/src/pages/About.tsx b/examples/basic/src/pages/About.tsx index 7986ec5..690dee9 100644 --- a/examples/basic/src/pages/About.tsx +++ b/examples/basic/src/pages/About.tsx @@ -12,6 +12,7 @@ export default function About() { ogImage={`${window.location.origin}/logo.png`} ogUrl={window.location.href} ogType="website" + canonicalUrl={window.location.href} />
@@ -66,6 +67,7 @@ function MyPage() { ogImage="https://example.com/image.jpg" ogUrl="https://example.com/page" ogType="website" + canonicalUrl="https://example.com/page" />
Your page content here
diff --git a/examples/basic/src/pages/Contact.tsx b/examples/basic/src/pages/Contact.tsx index e8481c3..5fb1963 100644 --- a/examples/basic/src/pages/Contact.tsx +++ b/examples/basic/src/pages/Contact.tsx @@ -12,6 +12,7 @@ export default function Contact() { ogImage={`${window.location.origin}/logo.png`} ogUrl={window.location.href} ogType="website" + canonicalUrl={window.location.href} />
diff --git a/examples/basic/src/pages/Home.tsx b/examples/basic/src/pages/Home.tsx index fe1e9d9..d5721ff 100644 --- a/examples/basic/src/pages/Home.tsx +++ b/examples/basic/src/pages/Home.tsx @@ -12,6 +12,7 @@ export default function Home() { ogImage={`${window.location.origin}/logo.png`} ogUrl={window.location.href} ogType="website" + canonicalUrl={window.location.href} />
diff --git a/package.json b/package.json index 09e10fc..334e22d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-head-safe", - "version": "1.3.0", + "version": "1.4.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 52238b6..550d896 100644 --- a/src/ReactHeadSafe.tsx +++ b/src/ReactHeadSafe.tsx @@ -16,6 +16,7 @@ import { type ReactHeadSafeProps } from './types'; * ogImage="https://example.com/image.jpg" * ogUrl="https://example.com/page" * ogType="website" + * canonicalUrl="https://example.com/page" * /> */ export const ReactHeadSafe: FC = ({ @@ -27,6 +28,7 @@ export const ReactHeadSafe: FC = ({ ogImage, ogUrl, ogType, + canonicalUrl, }) => { useLayoutEffect(() => { // Update title @@ -68,6 +70,11 @@ export const ReactHeadSafe: FC = ({ if (ogType !== undefined) { updateMetaTag('property', 'og:type', ogType); } + + // Update canonical URL + if (canonicalUrl !== undefined) { + updateLinkTag('canonical', canonicalUrl); + } }, [ title, description, @@ -77,11 +84,28 @@ export const ReactHeadSafe: FC = ({ ogImage, ogUrl, ogType, + canonicalUrl, ]); return null; }; +/** + * Updates or creates a link tag in the document head. + * Removes existing tag with the same rel to prevent duplicates. + */ +function updateLinkTag(rel: string, href: string): void { + const existingTag = document.querySelector(`link[rel="${rel}"]`); + if (existingTag) { + existingTag.remove(); + } + + const linkTag = document.createElement('link'); + linkTag.setAttribute('rel', rel); + linkTag.setAttribute('href', href); + document.head.appendChild(linkTag); +} + /** * Updates or creates a meta tag in the document head. * Removes existing tag with the same identifier to prevent duplicates. diff --git a/src/test/ReactHeadSafe.test.tsx b/src/test/ReactHeadSafe.test.tsx index 4354d8b..9ed65c8 100644 --- a/src/test/ReactHeadSafe.test.tsx +++ b/src/test/ReactHeadSafe.test.tsx @@ -333,6 +333,43 @@ describe('ReactHeadSafe', () => { }); }); + describe('canonical URL', () => { + it('should create link rel="canonical" tag', () => { + render(); + + const linkTag = document.querySelector('link[rel="canonical"]'); + expect(linkTag).toBeInTheDocument(); + expect(linkTag?.getAttribute('href')).toBe('https://example.com/page'); + }); + + it('should update canonical URL when prop changes', () => { + const { rerender } = render( + + ); + + let linkTag = document.querySelector('link[rel="canonical"]'); + expect(linkTag?.getAttribute('href')).toBe('https://example.com/page-1'); + + rerender(); + + linkTag = document.querySelector('link[rel="canonical"]'); + expect(linkTag?.getAttribute('href')).toBe('https://example.com/page-2'); + }); + + it('should prevent duplicate canonical link tags', () => { + const { rerender } = render( + + ); + rerender(); + + const linkTags = document.querySelectorAll('link[rel="canonical"]'); + expect(linkTags).toHaveLength(1); + expect(linkTags[0].getAttribute('href')).toBe( + 'https://example.com/second' + ); + }); + }); + describe('multiple props', () => { it('should handle all props together', () => { render( @@ -345,6 +382,7 @@ describe('ReactHeadSafe', () => { ogImage="https://example.com/image.jpg" ogUrl="https://example.com/page" ogType="website" + canonicalUrl="https://example.com/page" /> ); @@ -369,6 +407,11 @@ describe('ReactHeadSafe', () => { .querySelector('meta[property="og:type"]') ?.getAttribute('content') ).toBe('website'); + expect( + document + .querySelector('link[rel="canonical"]') + ?.getAttribute('href') + ).toBe('https://example.com/page'); }); it('should update only changed props', () => { diff --git a/src/types.ts b/src/types.ts index f8ddfc6..0a61fc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,4 +15,6 @@ export interface ReactHeadSafeProps { ogUrl?: string; /** The type of your object, e.g., "website", "article" (og:type) */ ogType?: string; + /** The canonical URL of the page for SEO (link rel="canonical") */ + canonicalUrl?: string; }