Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function MyPage() {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
canonicalUrl="https://example.com/page"
/>
<div>Your page content...</div>
</>
Expand All @@ -75,6 +76,7 @@ function MyPage() {
- meta description과 keywords 추가/업데이트
- 소셜 미디어용 Open Graph 태그 추가/업데이트
- Twitter Card 태그 추가/업데이트 (Open Graph 태그에서 자동 생성)
- canonical URL 링크 태그 추가/업데이트
- 중복 태그 제거

## API 레퍼런스
Expand All @@ -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 (`<link rel="canonical">`) |

### Twitter Card 지원

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function MyPage() {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
canonicalUrl="https://example.com/page"
/>
<div>Your page content...</div>
</>
Expand All @@ -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
Expand All @@ -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 (`<link rel="canonical">`) |

### Twitter Card Support

Expand Down
2 changes: 2 additions & 0 deletions examples/basic/src/pages/About.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function About() {
ogImage={`${window.location.origin}/logo.png`}
ogUrl={window.location.href}
ogType="website"
canonicalUrl={window.location.href}
/>

<div className="page-container">
Expand Down Expand Up @@ -66,6 +67,7 @@ function MyPage() {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
canonicalUrl="https://example.com/page"
/>

<div>Your page content here</div>
Expand Down
1 change: 1 addition & 0 deletions examples/basic/src/pages/Contact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function Contact() {
ogImage={`${window.location.origin}/logo.png`}
ogUrl={window.location.href}
ogType="website"
canonicalUrl={window.location.href}
/>

<div className="page-container">
Expand Down
1 change: 1 addition & 0 deletions examples/basic/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function Home() {
ogImage={`${window.location.origin}/logo.png`}
ogUrl={window.location.href}
ogType="website"
canonicalUrl={window.location.href}
/>

<div className="page-container">
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 24 additions & 0 deletions src/ReactHeadSafe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactHeadSafeProps> = ({
Expand All @@ -27,6 +28,7 @@ export const ReactHeadSafe: FC<ReactHeadSafeProps> = ({
ogImage,
ogUrl,
ogType,
canonicalUrl,
}) => {
useLayoutEffect(() => {
// Update title
Expand Down Expand Up @@ -68,6 +70,11 @@ export const ReactHeadSafe: FC<ReactHeadSafeProps> = ({
if (ogType !== undefined) {
updateMetaTag('property', 'og:type', ogType);
}

// Update canonical URL
if (canonicalUrl !== undefined) {
updateLinkTag('canonical', canonicalUrl);
}
}, [
title,
description,
Expand All @@ -77,11 +84,28 @@ export const ReactHeadSafe: FC<ReactHeadSafeProps> = ({
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.
Expand Down
43 changes: 43 additions & 0 deletions src/test/ReactHeadSafe.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,43 @@ describe('ReactHeadSafe', () => {
});
});

describe('canonical URL', () => {
it('should create link rel="canonical" tag', () => {
render(<ReactHeadSafe canonicalUrl="https://example.com/page" />);

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(
<ReactHeadSafe canonicalUrl="https://example.com/page-1" />
);

let linkTag = document.querySelector('link[rel="canonical"]');
expect(linkTag?.getAttribute('href')).toBe('https://example.com/page-1');

rerender(<ReactHeadSafe canonicalUrl="https://example.com/page-2" />);

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(
<ReactHeadSafe canonicalUrl="https://example.com/first" />
);
rerender(<ReactHeadSafe canonicalUrl="https://example.com/second" />);

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(
Expand All @@ -345,6 +382,7 @@ describe('ReactHeadSafe', () => {
ogImage="https://example.com/image.jpg"
ogUrl="https://example.com/page"
ogType="website"
canonicalUrl="https://example.com/page"
/>
);

Expand All @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}