@jwiedeman/gtm-kit-remix 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +10 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -5,26 +5,26 @@ var gtmKit = require('@jwiedeman/gtm-kit');
|
|
|
5
5
|
var jsxRuntime = require('react/jsx-runtime');
|
|
6
6
|
var react$1 = require('@remix-run/react');
|
|
7
7
|
|
|
8
|
-
var
|
|
9
|
-
window['${
|
|
10
|
-
${
|
|
8
|
+
var d=react.createContext(null);function S({config:t,children:r,onBeforeInit:c,onAfterInit:a}){let i=react.useRef(null),e=react.useRef(!1),o=react.useRef(null);i.current||(i.current=gtmKit.createGtmClient(t));let n=i.current;react.useEffect(()=>{if(o.current&&(clearTimeout(o.current),o.current=null),!e.current)return c&&c(n),n.init(),e.current=!0,a&&a(n),()=>{o.current=setTimeout(()=>{document.querySelector("[data-gtm-kit-provider]")||(n.teardown(),i.current=null,e.current=!1),o.current=null;},100);}},[n,c,a]);let p=react.useMemo(()=>({client:n,push:s=>n.push(s),setConsentDefaults:(s,u)=>n.setConsentDefaults(s,u),updateConsent:(s,u)=>n.updateConsent(s,u),whenReady:()=>n.whenReady()}),[n]);return jsxRuntime.jsx(d.Provider,{value:p,children:jsxRuntime.jsx("div",{"data-gtm-kit-provider":"",style:{display:"contents"},children:r})})}var l=()=>{let t=react.useContext(d);if(!t)throw new Error('[gtm-kit] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.');return t},V=()=>l(),f=()=>l().push,D=()=>{let{setConsentDefaults:t,updateConsent:r}=l();return {setConsentDefaults:t,updateConsent:r}},k=()=>l().client,T=()=>l().whenReady;function _(t={}){let{eventName:r="page_view",trackInitialPageView:c=!0,customData:a={},transformEvent:i}=t,e=react$1.useLocation(),o=f(),n=react.useRef(null),p=react.useRef(!0);react.useEffect(()=>{let s=e.pathname+e.search+e.hash;if(s===n.current)return;if(p.current&&!c){p.current=!1,n.current=s;return}p.current=!1,n.current=s;let u={event:r,page_path:e.pathname,page_search:e.search,page_hash:e.hash,page_url:typeof window!="undefined"?window.location.href:s,...a},C=i?i(u):u;o(C);},[e,o,r,c,a,i]);}function m(t){return t.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"').replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/</g,"\\x3c").replace(/>/g,"\\x3e").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029")}function E(t){return typeof t=="string"?[{id:t}]:Array.isArray(t)?t.map(r=>typeof r=="string"?{id:r}:r):[t]}function I(t,r,c,a){let i=r.endsWith("/")?r.slice(0,-1):r,e=new URLSearchParams;if(e.set("id",t),c!=="dataLayer"&&e.set("l",c),a)for(let[o,n]of Object.entries(a))o!=="id"&&o!=="l"&&e.set(o,String(n));return `${i}/gtm.js?${e.toString()}`}function N({containers:t,host:r="https://www.googletagmanager.com",dataLayerName:c="dataLayer",scriptAttributes:a={}}){let i=E(t),e=m(c),o=a.nonce?m(a.nonce):"",n=`
|
|
9
|
+
window['${e}'] = window['${e}'] || [];
|
|
10
|
+
${i.map(s=>{let u=m(s.id);return `
|
|
11
11
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
12
12
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
13
13
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
14
|
-
'${m(I(
|
|
15
|
-
})(window,document,'script','${
|
|
14
|
+
'${m(I(s.id,r,c,s.queryParams))}';${o?`j.nonce='${o}';`:""}f.parentNode.insertBefore(j,f);
|
|
15
|
+
})(window,document,'script','${e}','${u}');
|
|
16
16
|
`}).join(`
|
|
17
17
|
`)}
|
|
18
|
-
`.trim(),
|
|
18
|
+
`.trim(),p=gtmKit.createNoscriptMarkup(i,{host:r});return jsxRuntime.jsxs(jsxRuntime.Fragment,{children:[jsxRuntime.jsx("script",{...a,dangerouslySetInnerHTML:{__html:n}}),jsxRuntime.jsx("noscript",{dangerouslySetInnerHTML:{__html:p}})]})}
|
|
19
19
|
|
|
20
|
-
exports.GtmContext =
|
|
20
|
+
exports.GtmContext = d;
|
|
21
21
|
exports.GtmProvider = S;
|
|
22
22
|
exports.GtmScripts = N;
|
|
23
23
|
exports.useGtm = V;
|
|
24
24
|
exports.useGtmClient = k;
|
|
25
25
|
exports.useGtmConsent = D;
|
|
26
|
-
exports.useGtmPush =
|
|
27
|
-
exports.useGtmReady =
|
|
28
|
-
exports.useTrackPageViews =
|
|
26
|
+
exports.useGtmPush = f;
|
|
27
|
+
exports.useGtmReady = T;
|
|
28
|
+
exports.useTrackPageViews = _;
|
|
29
29
|
//# sourceMappingURL=out.js.map
|
|
30
30
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","client","timer","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"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","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount\n return () => {\n // Don't teardown immediately in StrictMode\n // Only teardown if we're truly unmounting\n const timer = setTimeout(() => {\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n\n // Clear the timeout on cleanup\n clearTimeout(timer);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","teardownTimerRef","client","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"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","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n teardownTimerRef.current = null;\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -3,18 +3,18 @@ import { createGtmClient, createNoscriptMarkup } from '@jwiedeman/gtm-kit';
|
|
|
3
3
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
4
4
|
import { useLocation } from '@remix-run/react';
|
|
5
5
|
|
|
6
|
-
var
|
|
7
|
-
window['${
|
|
8
|
-
${
|
|
6
|
+
var d=createContext(null);function S({config:t,children:r,onBeforeInit:c,onAfterInit:a}){let i=useRef(null),e=useRef(!1),o=useRef(null);i.current||(i.current=createGtmClient(t));let n=i.current;useEffect(()=>{if(o.current&&(clearTimeout(o.current),o.current=null),!e.current)return c&&c(n),n.init(),e.current=!0,a&&a(n),()=>{o.current=setTimeout(()=>{document.querySelector("[data-gtm-kit-provider]")||(n.teardown(),i.current=null,e.current=!1),o.current=null;},100);}},[n,c,a]);let p=useMemo(()=>({client:n,push:s=>n.push(s),setConsentDefaults:(s,u)=>n.setConsentDefaults(s,u),updateConsent:(s,u)=>n.updateConsent(s,u),whenReady:()=>n.whenReady()}),[n]);return jsx(d.Provider,{value:p,children:jsx("div",{"data-gtm-kit-provider":"",style:{display:"contents"},children:r})})}var l=()=>{let t=useContext(d);if(!t)throw new Error('[gtm-kit] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.');return t},V=()=>l(),f=()=>l().push,D=()=>{let{setConsentDefaults:t,updateConsent:r}=l();return {setConsentDefaults:t,updateConsent:r}},k=()=>l().client,T=()=>l().whenReady;function _(t={}){let{eventName:r="page_view",trackInitialPageView:c=!0,customData:a={},transformEvent:i}=t,e=useLocation(),o=f(),n=useRef(null),p=useRef(!0);useEffect(()=>{let s=e.pathname+e.search+e.hash;if(s===n.current)return;if(p.current&&!c){p.current=!1,n.current=s;return}p.current=!1,n.current=s;let u={event:r,page_path:e.pathname,page_search:e.search,page_hash:e.hash,page_url:typeof window!="undefined"?window.location.href:s,...a},C=i?i(u):u;o(C);},[e,o,r,c,a,i]);}function m(t){return t.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"').replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/</g,"\\x3c").replace(/>/g,"\\x3e").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029")}function E(t){return typeof t=="string"?[{id:t}]:Array.isArray(t)?t.map(r=>typeof r=="string"?{id:r}:r):[t]}function I(t,r,c,a){let i=r.endsWith("/")?r.slice(0,-1):r,e=new URLSearchParams;if(e.set("id",t),c!=="dataLayer"&&e.set("l",c),a)for(let[o,n]of Object.entries(a))o!=="id"&&o!=="l"&&e.set(o,String(n));return `${i}/gtm.js?${e.toString()}`}function N({containers:t,host:r="https://www.googletagmanager.com",dataLayerName:c="dataLayer",scriptAttributes:a={}}){let i=E(t),e=m(c),o=a.nonce?m(a.nonce):"",n=`
|
|
7
|
+
window['${e}'] = window['${e}'] || [];
|
|
8
|
+
${i.map(s=>{let u=m(s.id);return `
|
|
9
9
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
10
10
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
11
11
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
12
|
-
'${m(I(
|
|
13
|
-
})(window,document,'script','${
|
|
12
|
+
'${m(I(s.id,r,c,s.queryParams))}';${o?`j.nonce='${o}';`:""}f.parentNode.insertBefore(j,f);
|
|
13
|
+
})(window,document,'script','${e}','${u}');
|
|
14
14
|
`}).join(`
|
|
15
15
|
`)}
|
|
16
|
-
`.trim(),
|
|
16
|
+
`.trim(),p=createNoscriptMarkup(i,{host:r});return jsxs(Fragment,{children:[jsx("script",{...a,dangerouslySetInnerHTML:{__html:n}}),jsx("noscript",{dangerouslySetInnerHTML:{__html:p}})]})}
|
|
17
17
|
|
|
18
|
-
export {
|
|
18
|
+
export { d as GtmContext, S as GtmProvider, N as GtmScripts, V as useGtm, k as useGtmClient, D as useGtmConsent, f as useGtmPush, T as useGtmReady, _ as useTrackPageViews };
|
|
19
19
|
//# sourceMappingURL=out.js.map
|
|
20
20
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","client","timer","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"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","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount\n return () => {\n // Don't teardown immediately in StrictMode\n // Only teardown if we're truly unmounting\n const timer = setTimeout(() => {\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n\n // Clear the timeout on cleanup\n clearTimeout(timer);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","teardownTimerRef","client","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"AAAA,OAAgB,iBAAAA,EAAe,cAAAC,EAAY,aAAAC,EAAW,UAAAC,EAAQ,WAAAC,MAA+B,QAC7F,OACE,mBAAAC,MAOK,qBAiJD,cAAAC,MAAA,oBA9FC,IAAMC,EAAaP,EAAsC,IAAI,EAyB7D,SAASQ,EAAY,CAAE,OAAAC,EAAQ,SAAAC,EAAU,aAAAC,EAAc,YAAAC,CAAY,EAAyC,CAEjH,IAAMC,EAAYV,EAAyB,IAAI,EACzCW,EAAiBX,EAAO,EAAK,EAC7BY,EAAmBZ,EAA6C,IAAI,EAGrEU,EAAU,UACbA,EAAU,QAAUR,EAAgBI,CAAM,GAG5C,IAAMO,EAASH,EAAU,QAGzBX,EAAU,IAAM,CAQd,GANIa,EAAiB,UACnB,aAAaA,EAAiB,OAAO,EACrCA,EAAiB,QAAU,MAIzB,CAAAD,EAAe,QAKnB,OAAIH,GACFA,EAAaK,CAAM,EAIrBA,EAAO,KAAK,EACZF,EAAe,QAAU,GAGrBF,GACFA,EAAYI,CAAM,EAIb,IAAM,CACXD,EAAiB,QAAU,WAAW,IAAM,CAErC,SAAS,cAAc,yBAAyB,IACnDC,EAAO,SAAS,EAChBH,EAAU,QAAU,KACpBC,EAAe,QAAU,IAE3BC,EAAiB,QAAU,IAC7B,EAAG,GAAG,CACR,CACF,EAAG,CAACC,EAAQL,EAAcC,CAAW,CAAC,EAGtC,IAAMK,EAAeb,EACnB,KAAO,CACL,OAAAY,EACA,KAAOE,GAA0BF,EAAO,KAAKE,CAAK,EAClD,mBAAoB,CAACC,EAAqBC,IACxCJ,EAAO,mBAAmBG,EAAOC,CAAO,EAC1C,cAAe,CAACD,EAAqBC,IAAmCJ,EAAO,cAAcG,EAAOC,CAAO,EAC3G,UAAW,IAAMJ,EAAO,UAAU,CACpC,GACA,CAACA,CAAM,CACT,EAEA,OACEV,EAACC,EAAW,SAAX,CAAoB,MAAOU,EAC1B,SAAAX,EAAC,OAAI,wBAAsB,GAAG,MAAO,CAAE,QAAS,UAAW,EACxD,SAAAI,EACH,EACF,CAEJ,CAKA,IAAMW,EAAgB,IAAuB,CAC3C,IAAMC,EAAUrB,EAAWM,CAAU,EACrC,GAAI,CAACe,EACH,MAAM,IAAI,MACR,8IAEF,EAEF,OAAOA,CACT,EAoBaC,EAAS,IACbF,EAAc,EAqBVG,EAAa,IACjBH,EAAc,EAAE,KAqBZI,EAAgB,IAAqB,CAChD,GAAM,CAAE,mBAAAC,EAAoB,cAAAC,CAAc,EAAIN,EAAc,EAC5D,MAAO,CAAE,mBAAAK,EAAoB,cAAAC,CAAc,CAC7C,EAeaC,EAAe,IACnBP,EAAc,EAAE,OAsBZQ,EAAc,IAClBR,EAAc,EAAE,UCvRzB,OAAS,aAAAnB,EAAW,UAAAC,MAAc,QAClC,OAAS,eAAA2B,MAAmB,mBA+ErB,SAASC,EAAkBX,EAAoC,CAAC,EAAS,CAC9E,GAAM,CAAE,UAAAY,EAAY,YAAa,qBAAAC,EAAuB,GAAM,WAAAC,EAAa,CAAC,EAAG,eAAAC,CAAe,EAAIf,EAE5FgB,EAAWN,EAAY,EACvBO,EAAOb,EAAW,EAClBc,EAAcnC,EAAsB,IAAI,EACxCoC,EAAmBpC,EAAO,EAAI,EAEpCD,EAAU,IAAM,CACd,IAAMsC,EAAcJ,EAAS,SAAWA,EAAS,OAASA,EAAS,KAGnE,GAAII,IAAgBF,EAAY,QAC9B,OAIF,GAAIC,EAAiB,SAAW,CAACN,EAAsB,CACrDM,EAAiB,QAAU,GAC3BD,EAAY,QAAUE,EACtB,MACF,CAEAD,EAAiB,QAAU,GAC3BD,EAAY,QAAUE,EAGtB,IAAMC,EAA6B,CACjC,MAAOT,EACP,UAAWI,EAAS,SACpB,YAAaA,EAAS,OACtB,UAAWA,EAAS,KACpB,SAAU,OAAO,QAAW,YAAc,OAAO,SAAS,KAAOI,EACjE,GAAGN,CACL,EAGMQ,EAAYP,EAAiBA,EAAeM,CAAY,EAAIA,EAGlEJ,EAAKK,CAAS,CAChB,EAAG,CAACN,EAAUC,EAAML,EAAWC,EAAsBC,EAAYC,CAAc,CAAC,CAClF,CCzHA,OAAS,wBAAAQ,MAAiF,qBAwJtF,mBAAAC,EACE,OAAAtC,EADF,QAAAuC,MAAA,oBAlJJ,SAASC,EAAe5B,EAAuB,CAC7C,OAAOA,EACJ,QAAQ,MAAO,MAAM,EACrB,QAAQ,KAAM,KAAK,EACnB,QAAQ,KAAM,KAAK,EACnB,QAAQ,MAAO,KAAK,EACpB,QAAQ,MAAO,KAAK,EACpB,QAAQ,KAAM,OAAO,EACrB,QAAQ,KAAM,OAAO,EACrB,QAAQ,UAAW,SAAS,EAC5B,QAAQ,UAAW,SAAS,CACjC,CAgCA,SAAS6B,EAAoBC,EAAkF,CAC7G,OAAI,OAAOA,GAAe,SACjB,CAAC,CAAE,GAAIA,CAAW,CAAC,EAEvB,MAAM,QAAQA,CAAU,EAGtBA,EAAW,IAAKC,GAAO,OAAOA,GAAM,SAAW,CAAE,GAAIA,CAAE,EAAIA,CAAE,EAF3D,CAACD,CAAU,CAGtB,CAoCA,SAASE,EACPC,EACAC,EACAC,EACAC,EACQ,CACR,IAAMC,EAAiBH,EAAK,SAAS,GAAG,EAAIA,EAAK,MAAM,EAAG,EAAE,EAAIA,EAC1DI,EAAS,IAAI,gBAOnB,GANAA,EAAO,IAAI,KAAML,CAAW,EAExBE,IAAkB,aACpBG,EAAO,IAAI,IAAKH,CAAa,EAG3BC,EACF,OAAW,CAACG,EAAKvC,CAAK,IAAK,OAAO,QAAQoC,CAAW,EAC/CG,IAAQ,MAAQA,IAAQ,KAC1BD,EAAO,IAAIC,EAAK,OAAOvC,CAAK,CAAC,EAKnC,MAAO,GAAGqC,CAAc,WAAWC,EAAO,SAAS,CAAC,EACtD,CAEO,SAASE,EAAW,CACzB,WAAAV,EACA,KAAAI,EAAO,mCACP,cAAAC,EAAgB,YAChB,iBAAAM,EAAmB,CAAC,CACtB,EAAwC,CACtC,IAAMC,EAAmBb,EAAoBC,CAAU,EAGjDa,EAAoBf,EAAeO,CAAa,EAChDS,EAAYH,EAAiB,MAAQb,EAAea,EAAiB,KAAK,EAAI,GAG9EI,EAAe;AAAA,cACTF,CAAiB,gBAAgBA,CAAiB;AAAA,MAC1DD,EACC,IAAKnD,GAAW,CACf,IAAMuD,EAAkBlB,EAAerC,EAAO,EAAE,EAEhD,MAAO;AAAA;AAAA;AAAA;AAAA,SADWqC,EAAeI,EAAkBzC,EAAO,GAAI2C,EAAMC,EAAe5C,EAAO,WAAW,CAAC,CAK5F,KAAKqD,EAAY,YAAYA,CAAS,KAAO,EAAE;AAAA,qCAC5BD,CAAiB,MAAMG,CAAe;AAAA,OAErE,CAAC,EACA,KAAK;AAAA,CAAI,CAAC;AAAA,IACb,KAAK,EAGDC,EAAetB,EAAqBiB,EAAkB,CAAE,KAAAR,CAAK,CAAC,EAEpE,OACEP,EAAAD,EAAA,CACE,UAAAtC,EAAC,UAAQ,GAAGqD,EAAkB,wBAAyB,CAAE,OAAQI,CAAa,EAAG,EACjFzD,EAAC,YAAS,wBAAyB,CAAE,OAAQ2D,CAAa,EAAG,GAC/D,CAEJ","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n const teardownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Clear any pending teardown from StrictMode unmount/remount cycle\n if (teardownTimerRef.current) {\n clearTimeout(teardownTimerRef.current);\n teardownTimerRef.current = null;\n }\n\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount - defer to allow StrictMode remount\n return () => {\n teardownTimerRef.current = setTimeout(() => {\n // Only teardown if we're truly unmounting (no provider in DOM)\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n teardownTimerRef.current = null;\n }, 100);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwiedeman/gtm-kit-remix",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Remix adapter for GTM Kit - Google Tag Manager integration with route tracking.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"typecheck": "tsc --noEmit"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@jwiedeman/gtm-kit": "^1.1.
|
|
43
|
+
"@jwiedeman/gtm-kit": "^1.1.1"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@remix-run/react": "^2.0.0",
|