diff --git a/.github/copilot/integrate-analytics.prompt.md b/.github/copilot/integrate-analytics.prompt.md index 60cd595..cc8f4c6 100644 --- a/.github/copilot/integrate-analytics.prompt.md +++ b/.github/copilot/integrate-analytics.prompt.md @@ -41,14 +41,14 @@ forwards to whatever tool they choose. ## 2 — Core events catalogue -| `eventName` | `interactionType` | Fires when | -| ---------------------------- | ----------------- | ----------------------------------------------- | -| `social_share_popup_open` | `popup_open` | Share modal/popup opens | -| `social_share_popup_close` | `popup_close` | Modal closes (button, overlay, or Esc key) | -| `social_share_click` | `share` | User clicks a platform button (share intent) | -| `social_share_success` | `share` | Platform share window opened successfully | -| `social_share_copy` | `copy` | User copies the link to clipboard | -| `social_share_error` | `error` | Share or copy action failed | +| `eventName` | `interactionType` | Fires when | +| -------------------------- | ----------------- | -------------------------------------------- | +| `social_share_popup_open` | `popup_open` | Share modal/popup opens | +| `social_share_popup_close` | `popup_close` | Modal closes (button, overlay, or Esc key) | +| `social_share_click` | `share` | User clicks a platform button (share intent) | +| `social_share_success` | `share` | Platform share window opened successfully | +| `social_share_copy` | `copy` | User copies the link to clipboard | +| `social_share_error` | `error` | Share or copy action failed | --- @@ -59,7 +59,7 @@ forwards to whatever tool they choose. ```js // Fires on the container element and bubbles through the DOM (composed:true // means it also crosses shadow-DOM boundaries). -document.addEventListener("social-share", (e) => { +document.addEventListener('social-share', (e) => { const payload = e.detail; // Forward to your analytics tool here }); @@ -72,7 +72,7 @@ and Mixpanel both need the same event. ```js new SocialShareButton({ - container: "#share-button", + container: '#share-button', onAnalytics: (payload) => { // Forward to your analytics tool here }, @@ -96,11 +96,8 @@ Load the adapters file **in addition to** the main library script: const { GoogleAnalyticsAdapter, MixpanelAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", - analyticsPlugins: [ - new GoogleAnalyticsAdapter(), - new MixpanelAdapter(), - ], + container: '#share-button', + analyticsPlugins: [new GoogleAnalyticsAdapter(), new MixpanelAdapter()], }); ``` @@ -125,7 +122,7 @@ Prerequisite: GA4 `gtag.js` snippet loaded by the host. const { GoogleAnalyticsAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", + container: '#share-button', analyticsPlugins: [new GoogleAnalyticsAdapter()], }); @@ -135,7 +132,7 @@ new SocialShareButton({ Custom event category (optional): ```js -new GoogleAnalyticsAdapter({ eventCategory: "engagement" }) +new GoogleAnalyticsAdapter({ eventCategory: 'engagement' }); ``` ### Mixpanel @@ -145,7 +142,7 @@ Prerequisite: `mixpanel-browser` snippet or SDK loaded. ```js const { MixpanelAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", + container: '#share-button', analyticsPlugins: [new MixpanelAdapter()], }); // Calls: mixpanel.track(eventName, { platform, url, ... }) @@ -156,7 +153,7 @@ new SocialShareButton({ ```js const { SegmentAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", + container: '#share-button', analyticsPlugins: [new SegmentAdapter()], }); // Calls: analytics.track(eventName, { platform, url, ... }) @@ -169,7 +166,7 @@ Prerequisite: Plausible `script.js` loaded with custom events enabled. ```js const { PlausibleAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", + container: '#share-button', analyticsPlugins: [new PlausibleAdapter()], }); // Calls: plausible(eventName, { props: { platform, url, ... } }) @@ -180,7 +177,7 @@ new SocialShareButton({ ```js const { PostHogAdapter } = window.SocialShareAnalytics; new SocialShareButton({ - container: "#share-button", + container: '#share-button', analyticsPlugins: [new PostHogAdapter()], }); // Calls: posthog.capture(eventName, { platform, url, ... }) @@ -195,8 +192,8 @@ const { CustomAdapter } = window.SocialShareAnalytics; new SocialShareButton({ analyticsPlugins: [ new CustomAdapter((payload) => { - fetch("/api/analytics", { - method: "POST", + fetch('/api/analytics', { + method: 'POST', body: JSON.stringify(payload), }); }), @@ -231,17 +228,17 @@ GA4, Mixpanel, and a custom endpoint at the same time: ```js const { GoogleAnalyticsAdapter, MixpanelAdapter, CustomAdapter } = window.SocialShareAnalytics; -document.addEventListener("social-share", (e) => { - console.log("Raw event:", e.detail); // Debugging / logging +document.addEventListener('social-share', (e) => { + console.log('Raw event:', e.detail); // Debugging / logging }); new SocialShareButton({ - container: "#share-button", - componentId: "homepage-hero", + container: '#share-button', + componentId: 'homepage-hero', analyticsPlugins: [ new GoogleAnalyticsAdapter(), new MixpanelAdapter(), - new CustomAdapter((p) => fetch("/log", { method: "POST", body: JSON.stringify(p) })), + new CustomAdapter((p) => fetch('/log', { method: 'POST', body: JSON.stringify(p) })), ], }); ``` @@ -255,7 +252,7 @@ development. Remove or set to `false` in production. ```js new SocialShareButton({ - container: "#share-button", + container: '#share-button', debug: true, // → [SocialShareButton Analytics] { version: '1.0', source: 'social-share-button', ... } }); @@ -271,7 +268,7 @@ instrumentation must be explicitly consented to before activation. ```js new SocialShareButton({ - container: "#share-button", + container: '#share-button', analytics: false, }); ``` diff --git a/.github/copilot/integrate-social-share-button.prompt.md b/.github/copilot/integrate-social-share-button.prompt.md index 3b7a24b..ffd571d 100644 --- a/.github/copilot/integrate-social-share-button.prompt.md +++ b/.github/copilot/integrate-social-share-button.prompt.md @@ -25,11 +25,11 @@ You are helping a developer integrate the **SocialShareButton** library The README defines **3 installation methods**. Ask (or infer) which the developer wants: -| Method | When to use | -|--------|-------------| -| **Method 1 — CDN (Recommended)** | Most projects. No build step needed. Load via ` ``` @@ -99,8 +102,8 @@ No framework. Just add the CDN tags directly: **Step 2:** Open an **existing** component that renders on every page — typically `src/components/Header.jsx`, `src/layouts/MainLayout.jsx`, or your root `App.jsx`. Add the snippet below to that component so the share button is consistently available across your app. ```jsx -import { useEffect, useRef } from "react"; -import { useLocation } from "react-router-dom"; // omit if not using React Router +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; // omit if not using React Router // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -113,7 +116,7 @@ function Header() { if (initRef.current || !window.SocialShareButton) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; @@ -150,13 +153,9 @@ function Header() { **Step 1:** Add CDN to `app/layout.tsx`: ```tsx -import Script from "next/script"; +import Script from 'next/script'; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( @@ -180,10 +179,10 @@ export default function RootLayout({ **Step 2:** Because `SocialShareButton` manipulates the DOM, it must run inside a **Client Component** (note the `"use client"` directive at the top). Add the snippet below to an existing component such as `app/components/Header.tsx` or `app/components/Navbar.tsx` — any component already included in your layout. ```tsx -"use client"; +'use client'; -import { useEffect, useRef } from "react"; -import { usePathname } from "next/navigation"; +import { useEffect, useRef } from 'react'; +import { usePathname } from 'next/navigation'; // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -195,11 +194,10 @@ export default function Header() { useEffect(() => { const initButton = () => { - if (initRef.current || !window.SocialShareButton || !containerRef.current) - return; + if (initRef.current || !window.SocialShareButton || !containerRef.current) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; }; @@ -262,7 +260,7 @@ declare global { **Step 1:** Add CDN to `pages/_document.tsx`: ```tsx -import { Html, Head, Main, NextScript } from "next/document"; +import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( @@ -286,8 +284,8 @@ export default function Document() { **Step 2:** Open an existing component that is rendered on every page — typically `components/Header.tsx`, `components/Navbar.tsx`, or `components/Layout.tsx`. Since `_document.tsx` loads the script globally, the button is ready to initialize in any of these components. ```tsx -import { useEffect, useRef } from "react"; -import { useRouter } from "next/router"; +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -299,11 +297,10 @@ export default function Header() { useEffect(() => { const initButton = () => { - if (initRef.current || !window.SocialShareButton || !containerRef.current) - return; + if (initRef.current || !window.SocialShareButton || !containerRef.current) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; }; @@ -384,7 +381,7 @@ declare global { // Add
to your component's template/HTML first, // then initialize once the DOM is ready (e.g., in mounted(), ngAfterViewInit(), or useEffect()): new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); ``` @@ -395,10 +392,10 @@ new window.SocialShareButton({ Use when the project has a bundler (Webpack, Vite, etc.) and the developer prefers `import` syntax. Works in any framework. ```javascript -import SocialShareButton from "social-share-button-aossie"; -import "social-share-button-aossie/src/social-share-button.css"; +import SocialShareButton from 'social-share-button-aossie'; +import 'social-share-button-aossie/src/social-share-button.css'; -new SocialShareButton({ container: "#share-button" }); +new SocialShareButton({ container: '#share-button' }); ``` > No CDN tags needed — the npm package includes both JS and CSS. @@ -412,10 +409,10 @@ Only use this when the developer **explicitly** wants a reusable JSX component. Tell them to copy `src/social-share-button-react.jsx` from the library into their project — **do not create a new file from scratch**. ```jsx -import { SocialShareButton } from "./components/SocialShareButton"; +import { SocialShareButton } from './components/SocialShareButton'; function App() { - return ; + return ; } ``` @@ -423,30 +420,30 @@ function App() { ## All constructor options -| Option | Type | Default | Description | -| ------------------ | -------------- | ---------------------- | -------------------------------------------------- | -| `container` | string/Element | — | **Required.** CSS selector or DOM element | -| `url` | string | `window.location.href` | URL to share | -| `title` | string | `document.title` | Share title/headline | -| `description` | string | `''` | Additional description text | -| `hashtags` | array | `[]` | e.g. `['js', 'webdev']` | -| `via` | string | `''` | Twitter handle (without @) | +| Option | Type | Default | Description | +| ------------------ | -------------- | ---------------------- | ---------------------------------------------------------- | +| `container` | string/Element | — | **Required.** CSS selector or DOM element | +| `url` | string | `window.location.href` | URL to share | +| `title` | string | `document.title` | Share title/headline | +| `description` | string | `''` | Additional description text | +| `hashtags` | array | `[]` | e.g. `['js', 'webdev']` | +| `via` | string | `''` | Twitter handle (without @) | | `platforms` | array | All platforms | `whatsapp facebook twitter linkedin telegram reddit email` | -| `buttonText` | string | `'Share'` | Button label text | -| `buttonStyle` | string | `'default'` | `default` `primary` `compact` `icon-only` | -| `buttonColor` | string | `''` | Custom button background color | -| `buttonHoverColor` | string | `''` | Custom button hover color | -| `customClass` | string | `''` | Additional CSS class for button | -| `theme` | string | `'dark'` | `dark` or `light` | -| `modalPosition` | string | `'center'` | Modal position on screen | -| `showButton` | boolean | `true` | Show/hide the share button | -| `onShare` | function | `null` | `(platform, url) => void` | -| `onCopy` | function | `null` | `(url) => void` | -| `analytics` | boolean | `true` | Set `false` to disable all event emission | -| `onAnalytics` | function | `null` | `(payload) => void` — direct analytics hook | -| `analyticsPlugins` | array | `[]` | Adapter instances from `social-share-analytics.js` | -| `componentId` | string | `null` | Label this instance for analytics tracking | -| `debug` | boolean | `false` | Log analytics events to console | +| `buttonText` | string | `'Share'` | Button label text | +| `buttonStyle` | string | `'default'` | `default` `primary` `compact` `icon-only` | +| `buttonColor` | string | `''` | Custom button background color | +| `buttonHoverColor` | string | `''` | Custom button hover color | +| `customClass` | string | `''` | Additional CSS class for button | +| `theme` | string | `'dark'` | `dark` or `light` | +| `modalPosition` | string | `'center'` | Modal position on screen | +| `showButton` | boolean | `true` | Show/hide the share button | +| `onShare` | function | `null` | `(platform, url) => void` | +| `onCopy` | function | `null` | `(url) => void` | +| `analytics` | boolean | `true` | Set `false` to disable all event emission | +| `onAnalytics` | function | `null` | `(payload) => void` — direct analytics hook | +| `analyticsPlugins` | array | `[]` | Adapter instances from `social-share-analytics.js` | +| `componentId` | string | `null` | Label this instance for analytics tracking | +| `debug` | boolean | `false` | Log analytics events to console | --- @@ -469,7 +466,7 @@ const shareButton = useRef(null); useEffect(() => { shareButton.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); }, []); @@ -487,25 +484,25 @@ useEffect(() => { ## Troubleshooting -| Symptom | Cause | Fix | -|---------|-------|-----| -| Multiple buttons appearing | Component re-renders creating duplicate instances | Use `useRef` + `initRef` guard (shown in all examples above) | -| Button not appearing | Script loads after component renders | Add `if (window.SocialShareButton)` null check | -| Modal not opening | CSS not loaded or ID mismatch | Verify CSS CDN in ``; match `container: '#share-button'` with `
` | -| `TypeError: SocialShareButton is not a constructor` | CDN script not loaded yet | Use interval polling (see Next.js examples above) | -| URL not updating on navigation | Component initialized once, doesn't track routes | Use `updateOptions()` on route change | +| Symptom | Cause | Fix | +| --------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Multiple buttons appearing | Component re-renders creating duplicate instances | Use `useRef` + `initRef` guard (shown in all examples above) | +| Button not appearing | Script loads after component renders | Add `if (window.SocialShareButton)` null check | +| Modal not opening | CSS not loaded or ID mismatch | Verify CSS CDN in ``; match `container: '#share-button'` with `
` | +| `TypeError: SocialShareButton is not a constructor` | CDN script not loaded yet | Use interval polling (see Next.js examples above) | +| URL not updating on navigation | Component initialized once, doesn't track routes | Use `updateOptions()` on route change | --- ## Common mistakes to prevent -| ❌ Wrong | ✅ Correct | -|---------|-----------| -| Creating `ShareButton.jsx` / `ShareButton.tsx` | Add directly to existing `Header.jsx`, `Navbar.tsx`, etc. | -| Calling `new SocialShareButton()` inside JSX `return` | Call only inside `useEffect` / lifecycle hook | -| Not calling `destroy()` on unmount | Always clean up — prevents duplicate modals on re-mount | -| Mismatched container ID | `container: '#share-button'` must exactly match `
` | -| Script loads after component renders in Next.js | Use `strategy="beforeInteractive"` **or** poll with `setInterval` | +| ❌ Wrong | ✅ Correct | +| ----------------------------------------------------- | ------------------------------------------------------------------------- | +| Creating `ShareButton.jsx` / `ShareButton.tsx` | Add directly to existing `Header.jsx`, `Navbar.tsx`, etc. | +| Calling `new SocialShareButton()` inside JSX `return` | Call only inside `useEffect` / lifecycle hook | +| Not calling `destroy()` on unmount | Always clean up — prevents duplicate modals on re-mount | +| Mismatched container ID | `container: '#share-button'` must exactly match `
` | +| Script loads after component renders in Next.js | Use `strategy="beforeInteractive"` **or** poll with `setInterval` | --- @@ -516,4 +513,3 @@ useEffect(() => { - Always modify **existing** files — never suggest creating new component files. - When modifying an existing file, mark additions with `// ADD THIS`. - Do not add abstractions, wrappers, or extra files beyond what the README shows. - diff --git a/README.md b/README.md index bf8c605..55e0b54 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@
- > ⚠️ **IMPORTANT** > > All project discussions happens on **[Discord](https://discord.com/channels/1022871757289422898/1479012884209078365)**. @@ -75,6 +74,7 @@ Lightweight social sharing component for web applications. Zero dependencies, fr - 🎯 Zero dependencies - pure vanilla JavaScript - ⚛️ Framework support: React, Next.js, Vue, Angular, or plain HTML - 🔄 Auto-detects current URL and page title +- 🧠 Intelligent content detection — automatically extracts title & description from page metadata and semantic HTML - 📱 Fully responsive and mobile-ready - 🎨 Customizable themes (dark/light) - ⚡ Lightweight (< 10KB gzipped) @@ -86,7 +86,10 @@ Lightweight social sharing component for web applications. Zero dependencies, fr ### Via CDN (Recommended) ```html - + ``` @@ -101,11 +104,11 @@ Lightweight social sharing component for web applications. Zero dependencies, fr No matter which framework you use, integration always follows the same 3 steps: -| Step | What to do | Where | -|------|-----------|-------| -| **1️⃣ Load Library** | Add CSS + JS (CDN links) | Global layout file — `index.html` / `layout.tsx` / `_document.tsx` | -| **2️⃣ Add Container** | Place `
` | The UI component where you want the button to appear | -| **3️⃣ Initialize** | Call `new SocialShareButton({ container: "#share-button" })` | Inside that component, after the DOM is ready (e.g. `useEffect`, `mounted`, `ngAfterViewInit`) | +| Step | What to do | Where | +| -------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| **1️⃣ Load Library** | Add CSS + JS (CDN links) | Global layout file — `index.html` / `layout.tsx` / `_document.tsx` | +| **2️⃣ Add Container** | Place `
` | The UI component where you want the button to appear | +| **3️⃣ Initialize** | Call `new SocialShareButton({ container: "#share-button" })` | Inside that component, after the DOM is ready (e.g. `useEffect`, `mounted`, `ngAfterViewInit`) | > 💡 Pick your framework below for the full copy-paste snippet: @@ -132,8 +135,8 @@ No matter which framework you use, integration always follows the same 3 steps: Open an **existing** component that renders on every page — typically `src/components/Header.jsx`, `src/layouts/MainLayout.jsx`, or your root `App.jsx`. Add the snippet below to that component so the share button is consistently available across your app. ```jsx -import { useEffect, useRef } from "react"; -import { useLocation } from "react-router-dom"; // omit if not using React Router +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; // omit if not using React Router // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -146,7 +149,7 @@ function Header() { if (initRef.current || !window.SocialShareButton) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; @@ -184,13 +187,9 @@ function Header() { ### Step 1: Add CDN to `app/layout.tsx` ```tsx -import Script from "next/script"; +import Script from 'next/script'; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( @@ -216,10 +215,10 @@ export default function RootLayout({ Because `SocialShareButton` manipulates the DOM, it must run inside a **Client Component** (note the `"use client"` directive at the top). Add the snippet below to an existing component such as `app/components/Header.tsx` or `app/components/Navbar.tsx` — any component already included in your layout. ```tsx -"use client"; +'use client'; -import { useEffect, useRef } from "react"; -import { usePathname } from "next/navigation"; +import { useEffect, useRef } from 'react'; +import { usePathname } from 'next/navigation'; // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -231,11 +230,10 @@ export default function Header() { useEffect(() => { const initButton = () => { - if (initRef.current || !window.SocialShareButton || !containerRef.current) - return; + if (initRef.current || !window.SocialShareButton || !containerRef.current) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; }; @@ -299,7 +297,7 @@ declare global { ### Step 1: Add CDN to `pages/_document.tsx` ```tsx -import { Html, Head, Main, NextScript } from "next/document"; +import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( @@ -325,8 +323,8 @@ export default function Document() { Open an existing component that is rendered on every page — typically `components/Header.tsx`, `components/Navbar.tsx`, or `components/Layout.tsx`. Since `_document.tsx` loads the script globally, the button is ready to initialize in any of these components. ```tsx -import { useEffect, useRef } from "react"; -import { useRouter } from "next/router"; +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; // ⬇️ Replace 'Header' with the name of the component where you want the // share button to appear — e.g. Navbar, MainLayout, App, etc. @@ -338,11 +336,10 @@ export default function Header() { useEffect(() => { const initButton = () => { - if (initRef.current || !window.SocialShareButton || !containerRef.current) - return; + if (initRef.current || !window.SocialShareButton || !containerRef.current) return; shareButtonRef.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); initRef.current = true; }; @@ -426,7 +423,7 @@ Open your root or layout component (e.g., `App.vue`, `app.component.html`, or `A // Add
to your component's template/HTML first, // then initialize once the DOM is ready (e.g., in mounted(), ngAfterViewInit(), or useEffect()): new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); ``` @@ -434,43 +431,124 @@ new window.SocialShareButton({ --- +## Automatic Content Detection + +SocialShareButton can automatically detect the title and description for sharing from your page — no manual configuration needed. + +### How It Works + +When `title` or `description` props are not provided, the component runs a lightweight detection pass over the page: + +| Priority | Signal | Used for | +| -------- | -------------------------------------------------------- | ---------------------- | +| 1 | `` | Title | +| 2 | `` | Title | +| 3 | First `

` in `
` / `
` / `[role="main"]` | Title | +| 4 | Common CMS selectors (`.post-title`, `.entry-title`, …) | Title | +| 5 | `document.title` (strips common site name suffixes) | Title (fallback) | +| 1 | `` | Description | +| 2 | `` | Description | +| 3 | `` | Description | +| 4 | Main content body excerpt (150–200 chars) | Description (fallback) | + +No external libraries are loaded. The detector is inlined directly in the script — zero added bytes to `node_modules` and no network requests. + +### Zero-Config Usage + +```html + +
+ +``` + +Or in React: + +```jsx +// No title or description props needed — auto-detected from the page + +``` + +### Manual Props Always Win + +If you supply `title` or `description`, auto-detection is bypassed for those fields. You can mix-and-match: + +```jsx +new SocialShareButton({ + container: '#share-button', + title: 'My Custom Title', // explicit — used as-is + // description omitted — auto-detected from page meta/body +}); +``` + +### Disabling Auto-Detection + +```jsx +new SocialShareButton({ + container: '#share-button', + autoDetect: false, // skip detection entirely + title: 'My Title', + description: 'My description', +}); +``` + +### SPA / Client-Side Navigation + +When the route changes, call `clearContentCache()` before updating options. The component will automatically re-detect content when `autoDetect` is enabled (default): + +```jsx +// React Router / Next.js +useEffect(() => { + SocialShareButton.clearContentCache(); + shareButtonRef.current?.updateOptions({ + url: window.location.href, + }); +}, [pathname]); +``` + +Note: You only need to provide `url` — if `autoDetect` is enabled (default), the component will automatically re-detect `title` and `description` from the new page's metadata. If you explicitly provide `title` or `description` in `updateOptions`, those values will be used instead of auto-detection. + +--- + ## Configuration ### Basic Options ```jsx new SocialShareButton({ - container: "#share-button", // Required: CSS selector or DOM element - url: "https://example.com", // Optional: defaults to window.location.href - title: "Custom Title", // Optional: defaults to document.title - buttonText: "Share", // Optional: button label text - buttonStyle: "primary", // default | primary | compact | icon-only - theme: "dark", // dark | light - platforms: ["twitter", "linkedin"], // Optional: defaults to all platforms + container: '#share-button', // Required: CSS selector or DOM element + url: 'https://example.com', // Optional: defaults to window.location.href + title: 'Custom Title', // Optional: auto-detected from page metadata when omitted + buttonText: 'Share', // Optional: button label text + buttonStyle: 'primary', // default | primary | compact | icon-only + theme: 'dark', // dark | light + platforms: ['twitter', 'linkedin'], // Optional: defaults to all platforms }); ``` ### All Available Options -| Option | Type | Default | Description | -| ------------------ | -------------- | ---------------------- | -------------------------------------------------- | -| `container` | string/Element | - | **Required.** CSS selector or DOM element | -| `url` | string | `window.location.href` | URL to share | -| `title` | string | `document.title` | Share title/headline | -| `description` | string | `''` | Additional description text | -| `hashtags` | array | `[]` | Hashtags for posts (e.g., `['js', 'webdev']`) | -| `via` | string | `''` | Twitter handle (without @) | -| `platforms` | array | All platforms | Platforms to show (see below) | -| `buttonText` | string | `'Share'` | Button label text | -| `buttonStyle` | string | `'default'` | `default`, `primary`, `compact`, `icon-only` | -| `buttonColor` | string | `''` | Custom button background color | -| `buttonHoverColor` | string | `''` | Custom button hover color | -| `customClass` | string | `''` | Additional CSS class for button | -| `theme` | string | `'dark'` | `dark` or `light` | -| `modalPosition` | string | `'center'` | Modal position on screen | -| `showButton` | boolean | `true` | Show/hide the share button | -| `onShare` | function | `null` | Callback when user shares: `(platform, url) => {}` | -| `onCopy` | function | `null` | Callback when user copies link: `(url) => {}` | +| Option | Type | Default | Description | +| ------------------ | -------------- | ----------------------------------------------------------- | -------------------------------------------------------------------- | +| `container` | string/Element | - | **Required.** CSS selector or DOM element | +| `url` | string | `window.location.href` | URL to share | +| `title` | string | auto-detected (og:title → h1 → document.title) | Share title/headline — auto-detected from page metadata when omitted | +| `description` | string | auto-detected (og:description → meta description → excerpt) | Additional description text — auto-detected when omitted | +| `hashtags` | array | `[]` | Hashtags for posts (e.g., `['js', 'webdev']`) | +| `via` | string | `''` | Twitter handle (without @) | +| `platforms` | array | All platforms | Platforms to show (see below) | +| `buttonText` | string | `'Share'` | Button label text | +| `buttonStyle` | string | `'default'` | `default`, `primary`, `compact`, `icon-only` | +| `buttonColor` | string | `''` | Custom button background color | +| `buttonHoverColor` | string | `''` | Custom button hover color | +| `customClass` | string | `''` | Additional CSS class for button | +| `theme` | string | `'dark'` | `dark` or `light` | +| `modalPosition` | string | `'center'` | Modal position on screen | +| `showButton` | boolean | `true` | Show/hide the share button | +| `onShare` | function | `null` | Callback when user shares: `(platform, url) => {}` | +| `onCopy` | function | `null` | Callback when user copies link: `(url) => {}` | +| `autoDetect` | boolean | `true` | Auto-detect title/description from page when omitted | **Available Platforms:** `whatsapp`, `facebook`, `twitter`, `linkedin`, `telegram`, `reddit`, `email` @@ -481,12 +559,12 @@ Control the text that appears when users share to social platforms: ```jsx new SocialShareButton({ - container: "#share-button", - url: "https://myproject.com", - title: "Check out my awesome project!", // Main title/headline - description: "An amazing tool for developers", // Additional description - hashtags: ["javascript", "webdev", "opensource"], // Hashtags included in posts - via: "MyProjectHandle", // Your Twitter handle + container: '#share-button', + url: 'https://myproject.com', + title: 'Check out my awesome project!', // Main title/headline + description: 'An amazing tool for developers', // Additional description + hashtags: ['javascript', 'webdev', 'opensource'], // Hashtags included in posts + via: 'MyProjectHandle', // Your Twitter handle }); ``` @@ -506,8 +584,8 @@ new SocialShareButton({ ```jsx new SocialShareButton({ - container: "#share-button", - buttonStyle: "primary", // or 'default', 'compact', 'icon-only' + container: '#share-button', + buttonStyle: 'primary', // or 'default', 'compact', 'icon-only' }); ``` @@ -517,9 +595,9 @@ Pass `buttonColor` and `buttonHoverColor` to match your project's color scheme: ```jsx new SocialShareButton({ - container: "#share-button", - buttonColor: "#ff6b6b", // Button background color - buttonHoverColor: "#ff5252", // Hover state color + container: '#share-button', + buttonColor: '#ff6b6b', // Button background color + buttonHoverColor: '#ff5252', // Hover state color }); ``` @@ -529,9 +607,9 @@ For more complex styling, use a custom CSS class: ```jsx new SocialShareButton({ - container: "#share-button", - buttonStyle: "primary", - customClass: "my-custom-button", + container: '#share-button', + buttonStyle: 'primary', + customClass: 'my-custom-button', }); ``` @@ -555,23 +633,23 @@ Then in your CSS file: ```jsx // Material Design Red new SocialShareButton({ - container: "#share-button", - buttonColor: "#f44336", - buttonHoverColor: "#da190b", + container: '#share-button', + buttonColor: '#f44336', + buttonHoverColor: '#da190b', }); // Tailwind Blue new SocialShareButton({ - container: "#share-button", - buttonColor: "#3b82f6", - buttonHoverColor: "#2563eb", + container: '#share-button', + buttonColor: '#3b82f6', + buttonHoverColor: '#2563eb', }); // Custom Brand Color new SocialShareButton({ - container: "#share-button", - buttonColor: "#your-brand-color", - buttonHoverColor: "#your-brand-color-dark", + container: '#share-button', + buttonColor: '#your-brand-color', + buttonHoverColor: '#your-brand-color-dark', }); ``` @@ -588,12 +666,12 @@ new SocialShareButton({ ```jsx new SocialShareButton({ - container: "#share-button", + container: '#share-button', onShare: (platform, url) => { console.log(`Shared on ${platform}: ${url}`); }, onCopy: (url) => { - console.log("Link copied:", url); + console.log('Link copied:', url); }, }); ``` @@ -605,11 +683,11 @@ new SocialShareButton({ ### Using npm Package ```javascript -import SocialShareButton from "social-share-button-aossie"; -import "social-share-button-aossie/src/social-share-button.css"; +import SocialShareButton from 'social-share-button-aossie'; +import 'social-share-button-aossie/src/social-share-button.css'; new SocialShareButton({ - container: "#share-button", + container: '#share-button', }); ``` @@ -618,13 +696,22 @@ new SocialShareButton({ If you want a reusable React component, copy `src/social-share-button-react.jsx` to your project: ```jsx -import { SocialShareButton } from "./components/SocialShareButton"; +import { SocialShareButton } from './components/SocialShareButton'; function App() { - return ; + return ; } ``` +> **Performance tip:** Array and callback props (`hashtags`, `platforms`, `analyticsPlugins`, `onShare`, `onCopy`, `onAnalytics`) are compared by reference. Passing inline literals (e.g. `platforms={['twitter']}`) creates a new reference on every parent render and triggers `updateOptions()` unnecessarily. Wrap them with `useMemo` / `useCallback` in your parent component: +> +> ```jsx +> const platforms = useMemo(() => ['twitter', 'linkedin'], []); +> const onShare = useCallback((platform, url) => console.log(platform, url), []); +> +> return ; +> ``` + ### Update URL Dynamically (SPA) ```jsx @@ -640,7 +727,7 @@ const shareButton = useRef(null); useEffect(() => { shareButton.current = new window.SocialShareButton({ - container: "#share-button", + container: '#share-button', }); }, []); @@ -676,7 +763,7 @@ useEffect(() => { ```jsx if (window.SocialShareButton) { - new window.SocialShareButton({ container: "#share-button" }); + new window.SocialShareButton({ container: '#share-button' }); } ``` @@ -729,14 +816,14 @@ if (window.SocialShareButton) { ```jsx // Professional networks only new SocialShareButton({ - container: "#share-button", - platforms: ["linkedin", "twitter", "email"], + container: '#share-button', + platforms: ['linkedin', 'twitter', 'email'], }); // Messaging apps only new SocialShareButton({ - container: "#share-button", - platforms: ["whatsapp", "telegram"], + container: '#share-button', + platforms: ['whatsapp', 'telegram'], }); ``` @@ -744,9 +831,9 @@ new SocialShareButton({ ```jsx new SocialShareButton({ - container: "#share-button", - buttonStyle: "icon-only", - theme: "light", + container: '#share-button', + buttonStyle: 'icon-only', + theme: 'light', }); ``` diff --git a/docs/client-guide.md b/docs/client-guide.md index 6b49350..da7d57d 100644 --- a/docs/client-guide.md +++ b/docs/client-guide.md @@ -8,12 +8,12 @@ Your audience is already sharing. Give them a button worth clicking. -| What you get | What you skip | -|---|---| -| One-line install | Backend servers | +| What you get | What you skip | +| ---------------------- | --------------------------- | +| One-line install | Backend servers | | Works on any framework | Tracking or data collection | -| Fully customizable | Lock-in or licensing fees | -| Lightweight & fast | Build step (CDN option) | +| Fully customizable | Lock-in or licensing fees | +| Lightweight & fast | Build step (CDN option) | --- diff --git a/eslint.config.js b/eslint.config.js index 701e191..9b85c82 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,13 +1,13 @@ -import js from "@eslint/js"; -import globals from "globals"; +import js from '@eslint/js'; +import globals from 'globals'; export default [ js.configs.recommended, { - files: ["src/**/*.{js,jsx}"], + files: ['src/**/*.{js,jsx}'], languageOptions: { - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true, @@ -20,15 +20,15 @@ export default [ }, }, rules: { - "no-console": "error", - "no-unused-vars": ["warn", { "caughtErrorsIgnorePattern": "^_" }], - "semi": ["error", "always"], + 'no-console': 'error', + 'no-unused-vars': ['warn', { caughtErrorsIgnorePattern: '^_' }], + semi: ['error', 'always'], }, }, { - files: ["eslint.config.js", "**/*.config.js"], + files: ['eslint.config.js', '**/*.config.js'], languageOptions: { - sourceType: "module", + sourceType: 'module', globals: { ...globals.node, }, diff --git a/index.html b/index.html index 90db0da..f75efb6 100644 --- a/index.html +++ b/index.html @@ -391,6 +391,43 @@

Messaging

+ +
+

🤖 Content Auto-Detection

+

Automatically detects page title and extracts descriptions from meta tags or page content. Zero config required!

+ +
+ + + +<!-- Include via CDN --> +<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/AOSSIE-Org/SocialShareButton@feature/socialshare-autodetect/src/social-share-button.css"> +<script src="https://cdn.jsdelivr.net/gh/AOSSIE-Org/SocialShareButton@feature/socialshare-autodetect/src/social-share-button.js"></script> + +<div id="demo-autodetect"></div> + +<script> + // Initialize with zero config for auto-detection + new SocialShareButton({ + container: '#demo-autodetect' + }); +</script> +
+ +
+
+

Auto-Detect (Enabled)

+

Title and description automatically populated

+
+
+
+

Auto-Detect (Disabled)

+

Manual props overriding auto-detection

+
+
+
+
+

🚀 Quick Start

@@ -546,6 +583,21 @@

Ready to Get Started?

platforms: ['whatsapp', 'telegram'], buttonText: 'Share' }); + // Auto-Detect Enabled + new SocialShareButton({ + container: '#demo-autodetect', + buttonText: 'Auto-Detect Share' + }); + + // Auto-Detect Disabled + new SocialShareButton({ + container: '#demo-no-autodetect', + autoDetect: false, + title: 'Manual Title Override', + description: 'This uses explicit manual props instead of auto-detection.', + buttonText: 'Manual Share' + }); + // Custom Colors - Red new SocialShareButton({ diff --git a/package-lock.json b/package-lock.json index cc856f2..f3b84a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,141 @@ "@eslint/js": "^9.39.4", "eslint": "^9.39.4", "globals": "^17.4.0", + "jsdom": "^24.0.0", "prettier": "^3.2.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -231,6 +363,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -248,6 +381,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -288,6 +431,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -306,6 +456,20 @@ "concat-map": "0.0.1" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -353,6 +517,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -375,6 +552,41 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -393,6 +605,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -400,6 +619,93 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -419,6 +725,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -665,6 +972,72 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -691,6 +1064,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -701,6 +1087,102 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -761,6 +1243,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -781,6 +1270,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", + "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.7", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -849,6 +1379,46 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -876,6 +1446,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -939,6 +1516,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -985,6 +1575,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -995,6 +1598,20 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1005,6 +1622,33 @@ "node": ">=4" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1054,6 +1698,42 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1067,6 +1747,16 @@ "node": ">= 0.8.0" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -1077,6 +1767,78 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1103,6 +1865,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c0fc830..a9c0eaf 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "src/social-share-button.css", "src/social-share-button-react.jsx", "src/social-share-analytics.js", + "src/utils/extractContent.js", "README.md", "LICENSE" ], @@ -15,7 +16,8 @@ "lint": "eslint src/**/*.{js,jsx} --max-warnings=0", "lint:fix": "eslint src/**/*.{js,jsx} --fix", "format": "prettier --write \"**/*.{js,jsx,json,css,md,html}\"", - "format:check": "prettier --check \"**/*.{js,jsx,json,css,md,html}\"" + "format:check": "prettier --check \"**/*.{js,jsx,json,css,md,html}\"", + "test": "node --test tests/**/*.test.js" }, "repository": { "type": "git", @@ -44,6 +46,7 @@ "@eslint/js": "^9.39.4", "eslint": "^9.39.4", "globals": "^17.4.0", + "jsdom": "^24.0.0", "prettier": "^3.2.4" }, "type": "module" diff --git a/src/social-share-analytics.js b/src/social-share-analytics.js index 0caf0d7..4b5fd3a 100644 --- a/src/social-share-analytics.js +++ b/src/social-share-analytics.js @@ -123,14 +123,14 @@ class GoogleAnalyticsAdapter extends SocialShareAnalyticsPlugin { */ constructor(config = {}) { super(); - this.eventCategory = config.eventCategory || "social_share"; + this.eventCategory = config.eventCategory || 'social_share'; } track(payload) { - if (typeof window === "undefined" || typeof window.gtag !== "function") { + if (typeof window === 'undefined' || typeof window.gtag !== 'function') { return; } - window.gtag("event", payload.eventName, { + window.gtag('event', payload.eventName, { event_category: this.eventCategory, event_label: payload.platform, share_platform: payload.platform, @@ -151,9 +151,9 @@ class GoogleAnalyticsAdapter extends SocialShareAnalyticsPlugin { class MixpanelAdapter extends SocialShareAnalyticsPlugin { track(payload) { if ( - typeof window === "undefined" || - typeof window.mixpanel === "undefined" || - typeof window.mixpanel.track !== "function" + typeof window === 'undefined' || + typeof window.mixpanel === 'undefined' || + typeof window.mixpanel.track !== 'function' ) { return; } @@ -177,9 +177,9 @@ class MixpanelAdapter extends SocialShareAnalyticsPlugin { class SegmentAdapter extends SocialShareAnalyticsPlugin { track(payload) { if ( - typeof window === "undefined" || - typeof window.analytics === "undefined" || - typeof window.analytics.track !== "function" + typeof window === 'undefined' || + typeof window.analytics === 'undefined' || + typeof window.analytics.track !== 'function' ) { return; } @@ -201,7 +201,7 @@ class SegmentAdapter extends SocialShareAnalyticsPlugin { // ----------------------------------------------------------------------------- class PlausibleAdapter extends SocialShareAnalyticsPlugin { track(payload) { - if (typeof window === "undefined" || typeof window.plausible !== "function") { + if (typeof window === 'undefined' || typeof window.plausible !== 'function') { return; } window.plausible(payload.eventName, { @@ -223,9 +223,9 @@ class PlausibleAdapter extends SocialShareAnalyticsPlugin { class PostHogAdapter extends SocialShareAnalyticsPlugin { track(payload) { if ( - typeof window === "undefined" || - typeof window.posthog === "undefined" || - typeof window.posthog.capture !== "function" + typeof window === 'undefined' || + typeof window.posthog === 'undefined' || + typeof window.posthog.capture !== 'function' ) { return; } @@ -256,8 +256,8 @@ class CustomAdapter extends SocialShareAnalyticsPlugin { */ constructor(onTrack) { super(); - if (typeof onTrack !== "function") { - throw new TypeError("CustomAdapter expects a function argument."); + if (typeof onTrack !== 'function') { + throw new TypeError('CustomAdapter expects a function argument.'); } this._onTrack = onTrack; } @@ -281,10 +281,10 @@ const adapters = { CustomAdapter, }; -if (typeof module !== "undefined" && module.exports) { +if (typeof module !== 'undefined' && module.exports) { module.exports = adapters; } -if (typeof window !== "undefined") { +if (typeof window !== 'undefined') { window.SocialShareAnalytics = adapters; } diff --git a/src/social-share-button-react.jsx b/src/social-share-button-react.jsx index 43bf022..cd2da34 100644 --- a/src/social-share-button-react.jsx +++ b/src/social-share-button-react.jsx @@ -1,51 +1,54 @@ -import { useEffect, useRef } from "react"; +'use client'; + +import { useEffect, useRef } from 'react'; + +// Import SocialShareButton directly for ESM bundles, fall back to window for CDN +import SocialShareButtonCore from './social-share-button.js'; export const SocialShareButton = ({ url, title, - description = "", + description, hashtags = [], - via = "", - platforms = [ - "whatsapp", - "facebook", - "twitter", - "linkedin", - "telegram", - "reddit", - ], - theme = "dark", - buttonText = "Share", - customClass = "", + via = '', + platforms = ['whatsapp', 'facebook', 'twitter', 'linkedin', 'telegram', 'reddit'], + theme = 'dark', + buttonText = 'Share', + customClass = '', onShare = null, onCopy = null, - buttonStyle = "default", - modalPosition = "center", + buttonStyle = 'default', + modalPosition = 'center', + // Content auto-detection — set to false when all props are always provided. + autoDetect = true, // Analytics props — the library itself never collects data. // Provide any combination to connect your own analytics tools. - analytics = true, // set to false to disable all event emission - onAnalytics = null, // (payload) => void — direct callback hook - analyticsPlugins = [], // array of adapter instances (see social-share-analytics.js) - componentId = null, // optional string identifier for this instance - debug = false, // log events to console during development + analytics = true, // set to false to disable all event emission + onAnalytics = null, // (payload) => void — direct callback hook + analyticsPlugins = [], // array of adapter instances (see social-share-analytics.js) + componentId = null, // optional string identifier for this instance + debug = false, // log events to console during development }) => { const containerRef = useRef(null); const shareButtonRef = useRef(null); - // Auto-detect current URL and title if not provided - const currentUrl = - url || (typeof window !== "undefined" ? window.location.href : ""); - const currentTitle = - title || (typeof document !== "undefined" ? document.title : ""); + // Resolve URL — fall back to current page URL but leave title/description + // undefined when not provided so the core SocialShareButton auto-detection + // priority chain (og:title → twitter:title → h1 → document.title etc.) runs. + const currentUrl = url || (typeof window !== 'undefined' ? window.location.href : ''); useEffect(() => { if (containerRef.current && !shareButtonRef.current) { - if (typeof window !== "undefined" && window.SocialShareButton) { - shareButtonRef.current = new window.SocialShareButton({ + // Use imported class for ESM bundles, fall back to window for CDN script tags + const ShareButtonConstructor = + SocialShareButtonCore || (typeof window !== 'undefined' ? window.SocialShareButton : null); + + if (ShareButtonConstructor) { + // Only include title/description in the options object when explicitly + // provided — omitting them lets the core detector's priority chain run. + const initOptions = { container: containerRef.current, url: currentUrl, - title: currentTitle, - description, hashtags, via, platforms, @@ -56,12 +59,16 @@ export const SocialShareButton = ({ onCopy, buttonStyle, modalPosition, + autoDetect, analytics, onAnalytics, analyticsPlugins, componentId, debug, - }); + }; + if (title !== undefined) initOptions.title = title; + if (description !== undefined) initOptions.description = description; + shareButtonRef.current = new ShareButtonConstructor(initOptions); } } @@ -73,13 +80,29 @@ export const SocialShareButton = ({ }; }, []); - // Update options when props change (including URL from route changes) + // Update options when props change (including URL from route changes). + // Also bust the content-detection cache so the new page's metadata is used. useEffect(() => { if (shareButtonRef.current) { - shareButtonRef.current.updateOptions({ + // Invalidate detection cache on every route/prop change so the new + // page content is picked up when autoDetect is enabled. + if (autoDetect) { + const ShareButtonConstructor = + SocialShareButtonCore || + (typeof window !== 'undefined' ? window.SocialShareButton : null); + + if ( + ShareButtonConstructor && + typeof ShareButtonConstructor.clearContentCache === 'function' + ) { + // Pass the current URL so only this page's cache entry is cleared, + // rather than wiping the entire global cache shared across instances. + ShareButtonConstructor.clearContentCache(currentUrl); + } + } + + const updateOpts = { url: currentUrl, - title: currentTitle, - description, hashtags, via, platforms, @@ -90,16 +113,20 @@ export const SocialShareButton = ({ onCopy, buttonStyle, modalPosition, + autoDetect, analytics, onAnalytics, analyticsPlugins, componentId, debug, - }); + }; + if (title !== undefined) updateOpts.title = title; + if (description !== undefined) updateOpts.description = description; + shareButtonRef.current.updateOptions(updateOpts); } }, [ currentUrl, - currentTitle, + title, description, hashtags, via, @@ -111,6 +138,7 @@ export const SocialShareButton = ({ onCopy, buttonStyle, modalPosition, + autoDetect, analytics, onAnalytics, analyticsPlugins, diff --git a/src/social-share-button.css b/src/social-share-button.css index 0c90b36..3bde1aa 100644 --- a/src/social-share-button.css +++ b/src/social-share-button.css @@ -19,8 +19,7 @@ cursor: pointer; transition: all 0.3s ease; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; outline: none; } @@ -262,8 +261,7 @@ text-align: center; max-width: 80px; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } .social-share-modal-overlay.light .social-share-platform-btn span { @@ -299,8 +297,7 @@ font-size: 14px; outline: none; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } .social-share-modal-overlay.light .social-share-link-input input { @@ -324,8 +321,7 @@ transition: all 0.2s ease; min-width: 80px; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; outline: none; } diff --git a/src/social-share-button.js b/src/social-share-button.js index 5f05397..0ccf9e0 100644 --- a/src/social-share-button.js +++ b/src/social-share-button.js @@ -4,40 +4,293 @@ * @license GPL-3.0 */ +/** + * ⚠️ IMPORTANT: This _ContentDetector IIFE is INTENTIONALLY DUPLICATED from + * src/utils/extractContent.js to keep the CDN build zero-dependency. + * + * Any changes to detection priorities, selectors, or excerpt logic MUST be + * mirrored in both places. The source of truth is src/utils/extractContent.js. + * + * This module-level detector: + * - Shares the same detection logic and selectors + * - Uses a URL-keyed Map cache (keyed by doc.URL) to prevent SSR cache leaks + * - Is used only by the vanilla JS SocialShareButton class + * + * For SSR/URL-keyed caching, use src/utils/extractContent.js instead. + */ + +/** + * Lightweight content auto-detection module (inlined to preserve zero-dependency CDN build). + * + * Detection priority for title: + * og:title → twitter:title → semantic h1 (article/main/landmark) → document.title + * + * Detection priority for description / excerpt: + * og:description → twitter:description → meta[name="description"] → body text excerpt + * + * Results are cached for 30 s per instance so repeated calls within the same + * page lifecycle are negligible in cost. + */ +const _ContentDetector = (() => { + const _cache = new Map(); // keyed by doc.URL to prevent SSR cache leaks + const CACHE_TTL_MS = 30_000; + + /** + * Read a tag's `content` attribute using the given CSS selector. + * Returns an empty string when the element or its content attribute is absent. + */ + function _getMeta(doc, selector) { + const el = doc.querySelector(selector); + return el && el.getAttribute('content') ? el.getAttribute('content').trim() : ''; + } + + /** + * Detect the page title using a priority chain: + * 1. og:title meta tag + * 2. twitter:title meta tag (name or property variant) + * 3. First non-empty h1 inside a landmark element (article/main/[role=main]) + * or a common CMS title class, then bare h1 as last-resort selector + * 4. document.title with the trailing "| SiteName" suffix stripped via regex + */ + function _detectTitle(doc) { + const og = _getMeta(doc, 'meta[property="og:title"]'); + if (og) return og; + + const tw = + _getMeta(doc, 'meta[name="twitter:title"]') || + _getMeta(doc, 'meta[property="twitter:title"]'); + if (tw) return tw; + + const landmarkSelectors = [ + 'article h1', + '[role="main"] h1', + 'main h1', + '.post-title', + '.entry-title', + '.article-title', + '.page-title', + '.hero-title', + 'h1', + ]; + for (const sel of landmarkSelectors) { + const el = doc.querySelector(sel); + if (el) { + const text = el.textContent.trim(); + if (text) return text; + } + } + + // Strip trailing " | SiteName" suffix from document.title + const raw = (doc.title || '').trim(); + return raw.replace(/\s*[|\-\u2013\u2014]\s*.{1,60}$/, '').trim() || raw; + } + + /** + * Detect the page description / excerpt using a priority chain: + * 1. og:description meta tag + * 2. twitter:description meta tag (name or property variant) + * 3. meta[name="description"] + * Returns an empty string when none of the above are present (caller should + * fall back to body text extraction via _findContentRoot + _toPlainText). + */ + function _detectMetaDesc(doc) { + return ( + _getMeta(doc, 'meta[property="og:description"]') || + _getMeta(doc, 'meta[name="twitter:description"]') || + _getMeta(doc, 'meta[property="twitter:description"]') || + _getMeta(doc, 'meta[name="description"]') + ); + } + + /** + * Find the most specific semantic content root for body-text extraction. + * Tries landmark selectors in order of specificity (article → [role=main] → + * main → common CMS content classes → id-based selectors). A candidate is + * accepted only when it contains at least 50 characters of text so that empty + * or stub elements are skipped. Falls back to document.body when nothing + * qualifies. + */ + function _findContentRoot(doc) { + const candidates = [ + 'article', + '[role="main"]', + 'main', + '.post-content', + '.entry-content', + '.article-content', + '.article-body', + '.blog-content', + '.page-content', + '.content-body', + '#content', + '#main-content', + ]; + for (const sel of candidates) { + const el = doc.querySelector(sel); + if (el && el.textContent.trim().length > 50) return el; + } + return doc.body || doc.documentElement; + } + + /** + * Produce a clean plain-text version of a DOM subtree for excerpt generation. + * Clones the root so the live DOM is never mutated, then strips scripts, + * styles, navigation, ads, ARIA-hidden nodes, and modal overlays — elements + * that carry no article content and would pollute shareable text. + * Normalizes collapsed whitespace in the resulting textContent. + */ + function _toPlainText(root) { + const clone = root.cloneNode(true); + clone + .querySelectorAll( + 'script,style,noscript,nav,header,footer,aside,form,' + + "[aria-hidden='true'],.nav,.navigation,.menu,.sidebar," + + '.advertisement,.ad,.cookie-banner,.social-share-modal-overlay' + ) + .forEach(function (el) { + el.remove(); + }); + return clone.textContent.replace(/\s+/g, ' ').trim(); + } + + function _excerpt(text, minLen = 140, maxLen = 200) { + if (!text) return ''; + if (text.length <= maxLen) return text; + // Take a window up to maxLen chars and try to end at a sentence boundary + // (sentEnd) — but only when the boundary is beyond minLen to avoid + // too-short snippets. If no good sentence boundary exists, fall back to + // the last word boundary (wordEnd) and append an ellipsis so the result is + // always a complete word rather than a mid-word cut. + const win = text.slice(0, maxLen); + const sentEnd = Math.max(win.lastIndexOf('. '), win.lastIndexOf('! '), win.lastIndexOf('? ')); + if (sentEnd >= minLen) return win.slice(0, sentEnd + 1).trim(); + const wordEnd = win.lastIndexOf(' '); + return wordEnd > minLen ? win.slice(0, wordEnd).trim() + '\u2026' : win.trim() + '\u2026'; + } + + return { + /** + * Extract sharing metadata from the document. + * Returns { title, excerpt, textContent }. + * Never throws — falls back gracefully on any DOM error. + * + * @param {Document} doc + * @param {boolean} [bustCache=false] + * @returns {{ title: string, excerpt: string, textContent: string }} + */ + extract(doc, bustCache = false) { + const cacheKey = doc && doc.URL ? doc.URL : 'default'; + const cached = _cache.get(cacheKey); + if (!bustCache && cached && Date.now() - cached.ts < CACHE_TTL_MS) { + return cached.result; + } + let result; + try { + const title = _detectTitle(doc); + let excerpt = _detectMetaDesc(doc); + let textContent = ''; + if (!excerpt) { + const root = _findContentRoot(doc); + textContent = _toPlainText(root); + excerpt = _excerpt(textContent); + } else { + try { + textContent = _toPlainText(_findContentRoot(doc)); + } catch (_) { + textContent = excerpt; + } + } + result = { title, excerpt, textContent }; + } catch (_) { + result = { + title: typeof doc !== 'undefined' && doc.title ? doc.title.trim() : '', + excerpt: '', + textContent: '', + }; + } + _cache.set(cacheKey, { result, ts: Date.now() }); + return result; + }, + + /** Clear extraction cache (call on SPA navigation). + * @param {string} [url] — if provided, only that entry is cleared; otherwise the whole cache is cleared. + */ + clearCache(url) { + if (url) { + _cache.delete(url); + } else { + _cache.clear(); + } + }, + }; +})(); + /** Analytics event schema version. Increment when the payload shape changes. */ -const ANALYTICS_SCHEMA_VERSION = "1.0"; +const ANALYTICS_SCHEMA_VERSION = '1.0'; class SocialShareButton { constructor(options = {}) { + // ------------------------------------------------------------------------- + // Intelligent content auto-detection (Issue #26) + // + // When title or description are not provided by the caller, attempt to + // detect them from the page's metadata and semantic HTML. Manual props + // always win — auto-detection only fills in what's missing. + // + // Set autoDetect: false to disable entirely. + // ------------------------------------------------------------------------- + let _autoTitle = ''; + let _autoDescription = ''; + + const _autoDetectEnabled = options.autoDetect !== false; + + if (_autoDetectEnabled && typeof document !== 'undefined') { + // Use == null (covers both undefined and null) so an explicit empty string + // provided by the caller is honoured and does not trigger auto-detection. + const _needsTitle = options.title == null; + const _needsDesc = options.description == null; + + if (_needsTitle || _needsDesc) { + try { + const detected = _ContentDetector.extract(document); + if (_needsTitle) _autoTitle = detected.title || ''; + if (_needsDesc) _autoDescription = detected.excerpt || ''; + } catch (_err) { + // Never let detection errors break the constructor + if (options.debug) { + // eslint-disable-next-line no-console + console.warn('[SocialShareButton] Content detection failed', _err); + } + } + } + } + this.options = { - url: - options.url || - (typeof window !== "undefined" ? window.location.href : ""), + url: options.url || (typeof window !== 'undefined' ? window.location.href : ''), title: - options.title || - (typeof document !== "undefined" ? document.title : ""), - description: options.description || "", + options.title ?? (_autoTitle || (typeof document !== 'undefined' ? document.title : '')), + description: options.description ?? (_autoDescription || ''), hashtags: options.hashtags || [], - via: options.via || "", + via: options.via || '', platforms: options.platforms || [ - "whatsapp", - "facebook", - "twitter", - "linkedin", - "telegram", - "reddit", + 'whatsapp', + 'facebook', + 'twitter', + 'linkedin', + 'telegram', + 'reddit', ], - theme: options.theme || "dark", - buttonText: options.buttonText || "Share", - customClass: options.customClass || "", - buttonColor: options.buttonColor || "", - buttonHoverColor: options.buttonHoverColor || "", + theme: options.theme || 'dark', + buttonText: options.buttonText || 'Share', + customClass: options.customClass || '', + buttonColor: options.buttonColor || '', + buttonHoverColor: options.buttonHoverColor || '', onShare: options.onShare || null, onCopy: options.onCopy || null, container: options.container || null, showButton: options.showButton !== false, - buttonStyle: options.buttonStyle || "default", - modalPosition: options.modalPosition || "center", + buttonStyle: options.buttonStyle || 'default', + modalPosition: options.modalPosition || 'center', // Analytics — the library emits events but never collects or sends data itself. // Website owners wire up their own analytics tools via these options. analytics: options.analytics !== false, // set to false to disable all event emission @@ -45,6 +298,8 @@ class SocialShareButton { analyticsPlugins: options.analyticsPlugins || [], // array of { track(payload) } adapters componentId: options.componentId || null, // optional identifier for this instance debug: options.debug || false, // log emitted events to console in development + // Content auto-detection — set to false to disable (e.g. when all props are always provided) + autoDetect: _autoDetectEnabled, }; this.isModalOpen = false; @@ -55,14 +310,13 @@ class SocialShareButton { this.handleKeydown = null; this.listeners = []; // Central registry for all event listeners - this.openTimeout = null; // Track setTimeout for openModal animation + this.openTimeout = null; // Track setTimeout for openModal animation this.closeTimeout = null; // Track setTimeout for closeModal animation this.feedbackTimeout = null; // Track setTimeout for copy feedback reset this.ownsBodyLock = false; // Track if this instance owns the body overflow lock this.eventsAttached = false; // Guard against multiple attachEvents() calls this.isDestroyed = false; // Track if instance has been destroyed (prevents async callbacks) - if (this.options.container) { this.init(); } @@ -78,9 +332,9 @@ class SocialShareButton { } createButton() { - const button = document.createElement("button"); + const button = document.createElement('button'); button.className = `social-share-btn ${this.options.buttonStyle} ${this.options.customClass}`; - button.setAttribute("aria-label", "Share"); + button.setAttribute('aria-label', 'Share'); button.innerHTML = `