diff --git a/README.md b/README.md index c80abc3..4fb3c74 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,23 @@ -# next-google-ads +# Google Adsense for Next.js -A lightweight Google AdSense component for Next.js with optimized script loading using Next.js's `next/script` feature. +Load Google Adsense script and place the ad code with support for the latest 2024-2025 features. -## Features +## 🎯 Features -- πŸš€ Optimized script loading with Next.js Script component -- πŸ“¦ TypeScript support out of the box -- πŸ”„ Full ESM and CommonJS support -- πŸ“± Responsive ad support -- ⚑ Zero runtime dependencies (only peer dependencies) +- βœ… **Auto Ads** - AI-powered automatic ad placement (2024) +- βœ… **Consent Mode v2** - GDPR compliance (required since March 2024) +- βœ… **Ad Intents** - New intent-driven ad format (2024) +- βœ… **Anchor Ads** - Collapsible mobile ads with position control (2024) +- βœ… **In-Article Ads** - Modern content-integrated ads +- βœ… **In-Feed Ads** - Ads for lists and feeds +- βœ… **Multiplex Ads** - Related content ads +- βœ… **Non-Personalized Ads** - Privacy-focused advertising +- βœ… **TypeScript** - Full type safety -## Requirements +## Requirement -- Next.js >= 16.0 -- React >= 19.0 -- Node.js >= 20.19.0 - -## Migration Guide (v0.x β†’ v1.0.0) - -If you're upgrading from v0.x to v1.0.0, please follow these steps: - -### 1. Update Your Dependencies - -v1.0.0 requires newer versions of Next.js, React, and Node.js: - -```bash -# Update Next.js to v16 or later -npm install next@^16.0.0 - -# Update React to v19 or later -npm install react@^19.0.0 react-dom@^19.0.0 - -# Ensure Node.js >= 20.19.0 -node --version # Should be >= 20.19.0 -``` - -### 2. Update the Package - -```bash -npm install next-google-ads@^1.0.0 -``` - -### 3. Code Changes - -**Good news**: The API remains the same! Your existing code should work without changes. However, please verify: - -- βœ… Component imports and usage remain unchanged -- βœ… Props interface is identical -- βœ… TypeScript types are compatible - -### 4. Breaking Changes - -- **Node.js**: Requires Node.js >= 20.19.0 (if you were using an older version) -- **Next.js**: Requires Next.js >= 16.0 (if you were using Next.js < 16) -- **React**: Requires React >= 19.0 (if you were using React < 19) - -### 5. Verify Your Setup - -After upgrading, test your AdSense components to ensure they're working correctly: - -```bash -# Run your development server -npm run dev - -# Check for any TypeScript errors -npm run build -``` - -### Need Help? - -If you encounter any issues during migration, please: -- Check the [GitHub Issues](https://github.com/hideokamoto/next-google-ads/issues) -- Review the [full changelog](https://github.com/hideokamoto/next-google-ads/releases/tag/v1.0.0) +You need to use Next.js >=11.0. +Because the library using `next/script` feature. ## Installation @@ -79,28 +25,14 @@ If you encounter any issues during migration, please: npm install next-google-ads ``` -or - -```bash -yarn add next-google-ads -``` +## πŸ“š Usage Examples -or +### Basic Display Ad -```bash -pnpm add next-google-ads -``` - -## Quick Start - -### Basic Usage - -The simplest way to use this library is with the default `GoogleAdsense` component, which includes both the script loader and the ad widget: - -```jsx +```tsx import GoogleAdsense from 'next-google-ads' -export default function MyPage() { +export const BasicAd = () => { return ( - ; + }), +})); + +describe('GoogleAdsenseWidget', () => { + it('renders with required props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + + act(() => { + root.unmount(); + }); + }); + + it('applies default values', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('auto'); + expect(ins?.getAttribute('data-npa-mode')).toBeNull(); + expect(ins?.getAttribute('data-full-width-responsive')).toBe('false'); + + act(() => { + root.unmount(); + }); + }); + + it('applies optional props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.className).toContain('custom-class'); + expect(ins?.className).toContain('adsbygoogle'); + expect(ins?.getAttribute('data-ad-layout')).toBe('in-article'); + expect(ins?.getAttribute('data-ad-layout-key')).toBe('key123'); + expect(ins?.getAttribute('data-ad-format')).toBe('fluid'); + expect(ins?.getAttribute('data-full-width-responsive')).toBe('true'); + expect(ins?.getAttribute('data-adtest')).toBe('on'); + expect(ins?.getAttribute('data-ad-channel')).toBe('channel1'); + + act(() => { + root.unmount(); + }); + }); + + it('sets data-npa-mode when npaMode is true', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('InArticleAd', () => { + it('renders with required props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + + act(() => { + root.unmount(); + }); + }); + + it('sets correct format and layout', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('fluid'); + expect(ins?.getAttribute('data-ad-layout')).toBe('in-article'); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); + + it('applies className and style', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.className).toContain('custom-class'); + expect(ins?.className).toContain('adsbygoogle'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('InFeedAd', () => { + it('renders with required props including layoutKey', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + expect(ins?.getAttribute('data-ad-layout-key')).toBe('key123'); + + act(() => { + root.unmount(); + }); + }); + + it('sets correct format and layout', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('fluid'); + expect(ins?.getAttribute('data-ad-layout')).toBe('in-feed'); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('MultiplexAd', () => { + it('renders with required props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + + act(() => { + root.unmount(); + }); + }); + + it('applies default format', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('autorelaxed'); + + act(() => { + root.unmount(); + }); + }); + + it('applies custom format', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('autorelaxed'); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('AnchorAd', () => { + it('renders with required props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + + act(() => { + root.unmount(); + }); + }); + + it('applies default values', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-ad-format')).toBe('anchor'); + expect(ins?.getAttribute('data-anchor-position')).toBe('bottom'); + expect(ins?.getAttribute('data-collapsible')).toBe('true'); + + act(() => { + root.unmount(); + }); + }); + + it('applies position and collapsible props', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-anchor-position')).toBe('top'); + expect(ins?.getAttribute('data-collapsible')).toBe('false'); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const ins = div.querySelector('ins'); + expect(ins?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('AutoAdsScript', () => { + beforeEach(() => { + window.adsbygoogle = []; + }); + + afterEach(() => { + window.adsbygoogle = []; + }); + + it('returns null when client is not provided', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + expect(div.firstChild).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it('renders Script component with client', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense-auto"]'); + expect(script).toBeTruthy(); + expect(script?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + + act(() => { + root.unmount(); + }); + }); + + it('renders Consent Mode v2 script when consentMode is provided', () => { + const consentMode = { + ad_storage: 'granted' as const, + analytics_storage: 'granted' as const, + }; + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const consentScript = div.querySelector( + 'script[id="google-consent-mode-v2"]' + ); + expect(consentScript).toBeTruthy(); + + act(() => { + root.unmount(); + }); + }); + + it('pushes config to window.adsbygoogle when enableAutoAds is true', async () => { + const config = { + enableAutoAds: true, + adDensity: 'medium' as const, + enableAdIntents: true, + }; + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + // Wait for onLoad to be called + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(window.adsbygoogle).toHaveLength(1); + expect(window.adsbygoogle?.[0]).toEqual({ + google_ad_client: 'ca-pub-123', + enable_page_level_ads: true, + ad_density: 'medium', + ad_intents: true, + }); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense-auto"]'); + expect(script?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('NextGoogleAdsenseScript', () => { + beforeEach(() => { + window.adsbygoogle = []; + }); + + afterEach(() => { + window.adsbygoogle = []; + }); + + it('returns null when client is not provided', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + expect(div.firstChild).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it('renders Script component with client', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + expect(script).toBeTruthy(); + expect(script?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + + act(() => { + root.unmount(); + }); + }); + + it('pushes empty object to window.adsbygoogle on load', async () => { + // Reset before test to ensure clean state + window.adsbygoogle = []; + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + // Wait for onLoad to be called + await new Promise(resolve => setTimeout(resolve, 10)); + + // Check that exactly one item was added + const initialLength = window.adsbygoogle?.length ?? 0; + expect(initialLength).toBeGreaterThanOrEqual(1); + // Check the last item (in case previous tests added items) + const lastItem = window.adsbygoogle?.[window.adsbygoogle.length - 1]; + expect(lastItem).toEqual({}); + + act(() => { + root.unmount(); + }); + }); + + it('applies crossOrigin correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + expect(script?.getAttribute('crossorigin')).toBe('anonymous'); + + act(() => { + root.unmount(); + }); + }); + + it('does not set crossOrigin when crossOrigin is false', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + expect(script?.getAttribute('crossorigin')).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it('applies npaMode correctly', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + expect(script?.getAttribute('data-npa-mode')).toBe('1'); + + act(() => { + root.unmount(); + }); + }); +}); + +describe('GoogleAdsense', () => { + beforeEach(() => { + window.adsbygoogle = []; + }); + + afterEach(() => { + window.adsbygoogle = []; + }); + + it('renders both NextGoogleAdsenseScript and GoogleAdsenseWidget', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render(); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + const ins = div.querySelector('ins'); + + expect(script).toBeTruthy(); + expect(ins).toBeTruthy(); + expect(ins?.getAttribute('data-ad-client')).toBe('ca-pub-123'); + expect(ins?.getAttribute('data-ad-slot')).toBe('123456'); + + act(() => { + root.unmount(); + }); + }); + + it('passes props correctly to child components', () => { + const div = document.createElement('div'); + const root = createRoot(div); + act(() => { + root.render( + + ); + }); + + const script = div.querySelector('script[id="google-adsense"]'); + const ins = div.querySelector('ins'); + + expect(script?.getAttribute('data-npa-mode')).toBe('1'); + expect(ins?.className).toContain('custom-class'); + + act(() => { + root.unmount(); + }); + }); +});