@jwiedeman/gtm-kit-remix 1.1.6 → 1.2.0

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 CHANGED
@@ -5,26 +5,240 @@ 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 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 `
8
+ // src/provider.tsx
9
+ var GtmContext = react.createContext(null);
10
+ var warnOnNestedProvider = () => {
11
+ if (process.env.NODE_ENV !== "production") {
12
+ console.warn(
13
+ "[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. The nested provider will be ignored."
14
+ );
15
+ }
16
+ };
17
+ function GtmProvider({ config, children, onBeforeInit, onAfterInit }) {
18
+ const existingContext = react.useContext(GtmContext);
19
+ react.useEffect(() => {
20
+ if (existingContext) {
21
+ warnOnNestedProvider();
22
+ }
23
+ }, [existingContext]);
24
+ if (existingContext) {
25
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
26
+ }
27
+ return /* @__PURE__ */ jsxRuntime.jsx(GtmProviderInner, { config, onBeforeInit, onAfterInit, children });
28
+ }
29
+ function GtmProviderInner({ config, children, onBeforeInit, onAfterInit }) {
30
+ const clientRef = react.useRef(null);
31
+ const initializedRef = react.useRef(false);
32
+ const teardownTimerRef = react.useRef(null);
33
+ if (!clientRef.current) {
34
+ clientRef.current = gtmKit.createGtmClient(config);
35
+ }
36
+ const client = clientRef.current;
37
+ react.useEffect(() => {
38
+ if (teardownTimerRef.current) {
39
+ clearTimeout(teardownTimerRef.current);
40
+ teardownTimerRef.current = null;
41
+ }
42
+ if (initializedRef.current) {
43
+ return;
44
+ }
45
+ if (onBeforeInit) {
46
+ onBeforeInit(client);
47
+ }
48
+ client.init();
49
+ initializedRef.current = true;
50
+ if (onAfterInit) {
51
+ onAfterInit(client);
52
+ }
53
+ return () => {
54
+ teardownTimerRef.current = setTimeout(() => {
55
+ if (!document.querySelector("[data-gtm-kit-provider]")) {
56
+ client.teardown();
57
+ clientRef.current = null;
58
+ initializedRef.current = false;
59
+ }
60
+ teardownTimerRef.current = null;
61
+ }, 100);
62
+ };
63
+ }, [client, onBeforeInit, onAfterInit]);
64
+ const contextValue = react.useMemo(
65
+ () => ({
66
+ client,
67
+ push: (value) => client.push(value),
68
+ setConsentDefaults: (state, options) => client.setConsentDefaults(state, options),
69
+ updateConsent: (state, options) => client.updateConsent(state, options),
70
+ isReady: () => client.isReady(),
71
+ whenReady: () => client.whenReady()
72
+ }),
73
+ [client]
74
+ );
75
+ return /* @__PURE__ */ jsxRuntime.jsx(GtmContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx("div", { "data-gtm-kit-provider": "", style: { display: "contents" }, children }) });
76
+ }
77
+ var useGtmContext = () => {
78
+ const context = react.useContext(GtmContext);
79
+ if (!context) {
80
+ throw new Error(
81
+ '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.'
82
+ );
83
+ }
84
+ return context;
85
+ };
86
+ var useGtm = () => {
87
+ return useGtmContext();
88
+ };
89
+ var useGtmPush = () => {
90
+ return useGtmContext().push;
91
+ };
92
+ var useGtmConsent = () => {
93
+ const { setConsentDefaults, updateConsent } = useGtmContext();
94
+ return { setConsentDefaults, updateConsent };
95
+ };
96
+ var useGtmClient = () => {
97
+ return useGtmContext().client;
98
+ };
99
+ var useGtmReady = () => {
100
+ return useGtmContext().whenReady;
101
+ };
102
+ var useIsGtmReady = () => {
103
+ return useGtmContext().isReady;
104
+ };
105
+ var GtmErrorBoundary = class extends react.Component {
106
+ constructor(props) {
107
+ super(props);
108
+ this.reset = () => {
109
+ this.setState({ hasError: false, error: null });
110
+ };
111
+ this.state = { hasError: false, error: null };
112
+ }
113
+ static getDerivedStateFromError(error) {
114
+ return { hasError: true, error };
115
+ }
116
+ componentDidCatch(error, errorInfo) {
117
+ const { onError, logErrors = process.env.NODE_ENV !== "production" } = this.props;
118
+ if (logErrors) {
119
+ console.error("[gtm-kit/remix] Error caught by GtmErrorBoundary:", error);
120
+ console.error("[gtm-kit/remix] Component stack:", errorInfo.componentStack);
121
+ }
122
+ if (onError) {
123
+ try {
124
+ onError(error, errorInfo);
125
+ } catch (e) {
126
+ }
127
+ }
128
+ }
129
+ render() {
130
+ const { hasError, error } = this.state;
131
+ const { children, fallback } = this.props;
132
+ if (hasError && error) {
133
+ if (fallback === void 0) {
134
+ return children;
135
+ }
136
+ if (typeof fallback === "function") {
137
+ return fallback(error, this.reset);
138
+ }
139
+ return fallback;
140
+ }
141
+ return children;
142
+ }
143
+ };
144
+ function useTrackPageViews(options = {}) {
145
+ const { eventName = "page_view", trackInitialPageView = true, customData = {}, transformEvent } = options;
146
+ const location = react$1.useLocation();
147
+ const push = useGtmPush();
148
+ const lastPathRef = react.useRef(null);
149
+ const isFirstRenderRef = react.useRef(true);
150
+ react.useEffect(() => {
151
+ const currentPath = location.pathname + location.search + location.hash;
152
+ if (currentPath === lastPathRef.current) {
153
+ return;
154
+ }
155
+ if (isFirstRenderRef.current && !trackInitialPageView) {
156
+ isFirstRenderRef.current = false;
157
+ lastPathRef.current = currentPath;
158
+ return;
159
+ }
160
+ isFirstRenderRef.current = false;
161
+ lastPathRef.current = currentPath;
162
+ const pageViewData = {
163
+ event: eventName,
164
+ page_path: location.pathname,
165
+ page_search: location.search,
166
+ page_hash: location.hash,
167
+ page_url: typeof window !== "undefined" ? window.location.href : currentPath,
168
+ ...customData
169
+ };
170
+ const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;
171
+ push(eventData);
172
+ }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);
173
+ }
174
+ function escapeJsString(value) {
175
+ return value.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");
176
+ }
177
+ function normalizeContainers(containers) {
178
+ if (typeof containers === "string") {
179
+ return [{ id: containers }];
180
+ }
181
+ if (!Array.isArray(containers)) {
182
+ return [containers];
183
+ }
184
+ return containers.map((c) => typeof c === "string" ? { id: c } : c);
185
+ }
186
+ function buildGtmScriptUrl(containerId, host, dataLayerName, queryParams) {
187
+ const normalizedHost = host.endsWith("/") ? host.slice(0, -1) : host;
188
+ const params = new URLSearchParams();
189
+ params.set("id", containerId);
190
+ if (dataLayerName !== "dataLayer") {
191
+ params.set("l", dataLayerName);
192
+ }
193
+ if (queryParams) {
194
+ for (const [key, value] of Object.entries(queryParams)) {
195
+ if (key !== "id" && key !== "l") {
196
+ params.set(key, String(value));
197
+ }
198
+ }
199
+ }
200
+ return `${normalizedHost}/gtm.js?${params.toString()}`;
201
+ }
202
+ function GtmScripts({
203
+ containers,
204
+ host = "https://www.googletagmanager.com",
205
+ dataLayerName = "dataLayer",
206
+ scriptAttributes = {}
207
+ }) {
208
+ const containerConfigs = normalizeContainers(containers);
209
+ const safeDataLayerName = escapeJsString(dataLayerName);
210
+ const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : "";
211
+ const inlineScript = `
212
+ window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];
213
+ ${containerConfigs.map((config) => {
214
+ const safeContainerId = escapeJsString(config.id);
215
+ const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));
216
+ return `
11
217
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
12
218
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
13
219
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
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
- `}).join(`
17
- `)}
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}})]})}
220
+ '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ""}f.parentNode.insertBefore(j,f);
221
+ })(window,document,'script','${safeDataLayerName}','${safeContainerId}');
222
+ `;
223
+ }).join("\n")}
224
+ `.trim();
225
+ const noscriptHtml = gtmKit.createNoscriptMarkup(containerConfigs, { host });
226
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
227
+ /* @__PURE__ */ jsxRuntime.jsx("script", { ...scriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
228
+ /* @__PURE__ */ jsxRuntime.jsx("noscript", { dangerouslySetInnerHTML: { __html: noscriptHtml } })
229
+ ] });
230
+ }
19
231
 
20
- exports.GtmContext = d;
21
- exports.GtmProvider = S;
22
- exports.GtmScripts = N;
23
- exports.useGtm = V;
24
- exports.useGtmClient = k;
25
- exports.useGtmConsent = D;
26
- exports.useGtmPush = f;
27
- exports.useGtmReady = T;
28
- exports.useTrackPageViews = _;
232
+ exports.GtmContext = GtmContext;
233
+ exports.GtmErrorBoundary = GtmErrorBoundary;
234
+ exports.GtmProvider = GtmProvider;
235
+ exports.GtmScripts = GtmScripts;
236
+ exports.useGtm = useGtm;
237
+ exports.useGtmClient = useGtmClient;
238
+ exports.useGtmConsent = useGtmConsent;
239
+ exports.useGtmPush = useGtmPush;
240
+ exports.useGtmReady = useGtmReady;
241
+ exports.useIsGtmReady = useIsGtmReady;
242
+ exports.useTrackPageViews = useTrackPageViews;
29
243
  //# sourceMappingURL=out.js.map
30
244
  //# sourceMappingURL=index.cjs.map
@@ -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","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"]}
1
+ {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":"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","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} 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 /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\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\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\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 // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ 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 isReady: () => client.isReady(),\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/remix] 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\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\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.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import React, { ReactNode } from 'react';
1
+ import React, { ReactNode, ErrorInfo, Component } from 'react';
2
2
  import { CreateGtmClientOptions, GtmClient, DataLayerValue, ConsentState, ConsentRegionOptions, ScriptLoadState, ContainerConfigInput } from '@jwiedeman/gtm-kit';
3
3
 
4
4
  /**
@@ -31,6 +31,8 @@ interface GtmContextValue {
31
31
  setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
32
32
  /** Update consent state */
33
33
  updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
34
+ /** Synchronously check if all GTM scripts have finished loading */
35
+ isReady: () => boolean;
34
36
  /** Returns a promise that resolves when all GTM scripts are loaded */
35
37
  whenReady: () => Promise<ScriptLoadState[]>;
36
38
  }
@@ -160,6 +162,63 @@ declare const useGtmClient: () => GtmClient;
160
162
  * ```
161
163
  */
162
164
  declare const useGtmReady: () => (() => Promise<ScriptLoadState[]>);
165
+ /**
166
+ * Hook to check if GTM scripts have finished loading synchronously.
167
+ *
168
+ * @example
169
+ * ```tsx
170
+ * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';
171
+ *
172
+ * function MyComponent() {
173
+ * const isReady = useIsGtmReady();
174
+ * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;
175
+ * }
176
+ * ```
177
+ */
178
+ declare const useIsGtmReady: () => (() => boolean);
179
+ /**
180
+ * Props for GtmErrorBoundary component.
181
+ */
182
+ interface GtmErrorBoundaryProps {
183
+ children: ReactNode;
184
+ /** Fallback UI to render when an error occurs */
185
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
186
+ /** Callback invoked when an error is caught */
187
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
188
+ /** Whether to log errors to console (default: true in development) */
189
+ logErrors?: boolean;
190
+ }
191
+ interface GtmErrorBoundaryState {
192
+ hasError: boolean;
193
+ error: Error | null;
194
+ }
195
+ /**
196
+ * Error boundary component for GTM provider in Remix apps.
197
+ * Catches errors during GTM initialization and renders a fallback UI.
198
+ * Analytics and tracking will be disabled when an error occurs.
199
+ *
200
+ * @example
201
+ * ```tsx
202
+ * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';
203
+ *
204
+ * export default function App() {
205
+ * return (
206
+ * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>
207
+ * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
208
+ * <Outlet />
209
+ * </GtmProvider>
210
+ * </GtmErrorBoundary>
211
+ * );
212
+ * }
213
+ * ```
214
+ */
215
+ declare class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {
216
+ constructor(props: GtmErrorBoundaryProps);
217
+ static getDerivedStateFromError(error: Error): GtmErrorBoundaryState;
218
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
219
+ reset: () => void;
220
+ render(): ReactNode;
221
+ }
163
222
 
164
223
  /**
165
224
  * Options for the useTrackPageViews hook.
@@ -259,4 +318,4 @@ interface GtmScriptsProps {
259
318
  }
260
319
  declare function GtmScripts({ containers, host, dataLayerName, scriptAttributes }: GtmScriptsProps): React.ReactElement;
261
320
 
262
- export { GtmConsentApi, GtmContext, GtmContextValue, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useTrackPageViews };
321
+ export { GtmConsentApi, GtmContext, GtmContextValue, GtmErrorBoundary, GtmErrorBoundaryProps, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useIsGtmReady, useTrackPageViews };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import React, { ReactNode } from 'react';
1
+ import React, { ReactNode, ErrorInfo, Component } from 'react';
2
2
  import { CreateGtmClientOptions, GtmClient, DataLayerValue, ConsentState, ConsentRegionOptions, ScriptLoadState, ContainerConfigInput } from '@jwiedeman/gtm-kit';
3
3
 
4
4
  /**
@@ -31,6 +31,8 @@ interface GtmContextValue {
31
31
  setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
32
32
  /** Update consent state */
33
33
  updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
34
+ /** Synchronously check if all GTM scripts have finished loading */
35
+ isReady: () => boolean;
34
36
  /** Returns a promise that resolves when all GTM scripts are loaded */
35
37
  whenReady: () => Promise<ScriptLoadState[]>;
36
38
  }
@@ -160,6 +162,63 @@ declare const useGtmClient: () => GtmClient;
160
162
  * ```
161
163
  */
162
164
  declare const useGtmReady: () => (() => Promise<ScriptLoadState[]>);
165
+ /**
166
+ * Hook to check if GTM scripts have finished loading synchronously.
167
+ *
168
+ * @example
169
+ * ```tsx
170
+ * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';
171
+ *
172
+ * function MyComponent() {
173
+ * const isReady = useIsGtmReady();
174
+ * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;
175
+ * }
176
+ * ```
177
+ */
178
+ declare const useIsGtmReady: () => (() => boolean);
179
+ /**
180
+ * Props for GtmErrorBoundary component.
181
+ */
182
+ interface GtmErrorBoundaryProps {
183
+ children: ReactNode;
184
+ /** Fallback UI to render when an error occurs */
185
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
186
+ /** Callback invoked when an error is caught */
187
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
188
+ /** Whether to log errors to console (default: true in development) */
189
+ logErrors?: boolean;
190
+ }
191
+ interface GtmErrorBoundaryState {
192
+ hasError: boolean;
193
+ error: Error | null;
194
+ }
195
+ /**
196
+ * Error boundary component for GTM provider in Remix apps.
197
+ * Catches errors during GTM initialization and renders a fallback UI.
198
+ * Analytics and tracking will be disabled when an error occurs.
199
+ *
200
+ * @example
201
+ * ```tsx
202
+ * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';
203
+ *
204
+ * export default function App() {
205
+ * return (
206
+ * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>
207
+ * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
208
+ * <Outlet />
209
+ * </GtmProvider>
210
+ * </GtmErrorBoundary>
211
+ * );
212
+ * }
213
+ * ```
214
+ */
215
+ declare class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {
216
+ constructor(props: GtmErrorBoundaryProps);
217
+ static getDerivedStateFromError(error: Error): GtmErrorBoundaryState;
218
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
219
+ reset: () => void;
220
+ render(): ReactNode;
221
+ }
163
222
 
164
223
  /**
165
224
  * Options for the useTrackPageViews hook.
@@ -259,4 +318,4 @@ interface GtmScriptsProps {
259
318
  }
260
319
  declare function GtmScripts({ containers, host, dataLayerName, scriptAttributes }: GtmScriptsProps): React.ReactElement;
261
320
 
262
- export { GtmConsentApi, GtmContext, GtmContextValue, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useTrackPageViews };
321
+ export { GtmConsentApi, GtmContext, GtmContextValue, GtmErrorBoundary, GtmErrorBoundaryProps, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useIsGtmReady, useTrackPageViews };
package/dist/index.js CHANGED
@@ -1,20 +1,232 @@
1
- import { createContext, useRef, useEffect, useMemo, useContext } from 'react';
1
+ import { createContext, useContext, useEffect, useRef, useMemo, Component } from 'react';
2
2
  import { createGtmClient, createNoscriptMarkup } from '@jwiedeman/gtm-kit';
3
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
4
4
  import { useLocation } from '@remix-run/react';
5
5
 
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 `
6
+ // src/provider.tsx
7
+ var GtmContext = createContext(null);
8
+ var warnOnNestedProvider = () => {
9
+ if (process.env.NODE_ENV !== "production") {
10
+ console.warn(
11
+ "[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. The nested provider will be ignored."
12
+ );
13
+ }
14
+ };
15
+ function GtmProvider({ config, children, onBeforeInit, onAfterInit }) {
16
+ const existingContext = useContext(GtmContext);
17
+ useEffect(() => {
18
+ if (existingContext) {
19
+ warnOnNestedProvider();
20
+ }
21
+ }, [existingContext]);
22
+ if (existingContext) {
23
+ return /* @__PURE__ */ jsx(Fragment, { children });
24
+ }
25
+ return /* @__PURE__ */ jsx(GtmProviderInner, { config, onBeforeInit, onAfterInit, children });
26
+ }
27
+ function GtmProviderInner({ config, children, onBeforeInit, onAfterInit }) {
28
+ const clientRef = useRef(null);
29
+ const initializedRef = useRef(false);
30
+ const teardownTimerRef = useRef(null);
31
+ if (!clientRef.current) {
32
+ clientRef.current = createGtmClient(config);
33
+ }
34
+ const client = clientRef.current;
35
+ useEffect(() => {
36
+ if (teardownTimerRef.current) {
37
+ clearTimeout(teardownTimerRef.current);
38
+ teardownTimerRef.current = null;
39
+ }
40
+ if (initializedRef.current) {
41
+ return;
42
+ }
43
+ if (onBeforeInit) {
44
+ onBeforeInit(client);
45
+ }
46
+ client.init();
47
+ initializedRef.current = true;
48
+ if (onAfterInit) {
49
+ onAfterInit(client);
50
+ }
51
+ return () => {
52
+ teardownTimerRef.current = setTimeout(() => {
53
+ if (!document.querySelector("[data-gtm-kit-provider]")) {
54
+ client.teardown();
55
+ clientRef.current = null;
56
+ initializedRef.current = false;
57
+ }
58
+ teardownTimerRef.current = null;
59
+ }, 100);
60
+ };
61
+ }, [client, onBeforeInit, onAfterInit]);
62
+ const contextValue = useMemo(
63
+ () => ({
64
+ client,
65
+ push: (value) => client.push(value),
66
+ setConsentDefaults: (state, options) => client.setConsentDefaults(state, options),
67
+ updateConsent: (state, options) => client.updateConsent(state, options),
68
+ isReady: () => client.isReady(),
69
+ whenReady: () => client.whenReady()
70
+ }),
71
+ [client]
72
+ );
73
+ return /* @__PURE__ */ jsx(GtmContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { "data-gtm-kit-provider": "", style: { display: "contents" }, children }) });
74
+ }
75
+ var useGtmContext = () => {
76
+ const context = useContext(GtmContext);
77
+ if (!context) {
78
+ throw new Error(
79
+ '[gtm-kit/remix] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.'
80
+ );
81
+ }
82
+ return context;
83
+ };
84
+ var useGtm = () => {
85
+ return useGtmContext();
86
+ };
87
+ var useGtmPush = () => {
88
+ return useGtmContext().push;
89
+ };
90
+ var useGtmConsent = () => {
91
+ const { setConsentDefaults, updateConsent } = useGtmContext();
92
+ return { setConsentDefaults, updateConsent };
93
+ };
94
+ var useGtmClient = () => {
95
+ return useGtmContext().client;
96
+ };
97
+ var useGtmReady = () => {
98
+ return useGtmContext().whenReady;
99
+ };
100
+ var useIsGtmReady = () => {
101
+ return useGtmContext().isReady;
102
+ };
103
+ var GtmErrorBoundary = class extends Component {
104
+ constructor(props) {
105
+ super(props);
106
+ this.reset = () => {
107
+ this.setState({ hasError: false, error: null });
108
+ };
109
+ this.state = { hasError: false, error: null };
110
+ }
111
+ static getDerivedStateFromError(error) {
112
+ return { hasError: true, error };
113
+ }
114
+ componentDidCatch(error, errorInfo) {
115
+ const { onError, logErrors = process.env.NODE_ENV !== "production" } = this.props;
116
+ if (logErrors) {
117
+ console.error("[gtm-kit/remix] Error caught by GtmErrorBoundary:", error);
118
+ console.error("[gtm-kit/remix] Component stack:", errorInfo.componentStack);
119
+ }
120
+ if (onError) {
121
+ try {
122
+ onError(error, errorInfo);
123
+ } catch (e) {
124
+ }
125
+ }
126
+ }
127
+ render() {
128
+ const { hasError, error } = this.state;
129
+ const { children, fallback } = this.props;
130
+ if (hasError && error) {
131
+ if (fallback === void 0) {
132
+ return children;
133
+ }
134
+ if (typeof fallback === "function") {
135
+ return fallback(error, this.reset);
136
+ }
137
+ return fallback;
138
+ }
139
+ return children;
140
+ }
141
+ };
142
+ function useTrackPageViews(options = {}) {
143
+ const { eventName = "page_view", trackInitialPageView = true, customData = {}, transformEvent } = options;
144
+ const location = useLocation();
145
+ const push = useGtmPush();
146
+ const lastPathRef = useRef(null);
147
+ const isFirstRenderRef = useRef(true);
148
+ useEffect(() => {
149
+ const currentPath = location.pathname + location.search + location.hash;
150
+ if (currentPath === lastPathRef.current) {
151
+ return;
152
+ }
153
+ if (isFirstRenderRef.current && !trackInitialPageView) {
154
+ isFirstRenderRef.current = false;
155
+ lastPathRef.current = currentPath;
156
+ return;
157
+ }
158
+ isFirstRenderRef.current = false;
159
+ lastPathRef.current = currentPath;
160
+ const pageViewData = {
161
+ event: eventName,
162
+ page_path: location.pathname,
163
+ page_search: location.search,
164
+ page_hash: location.hash,
165
+ page_url: typeof window !== "undefined" ? window.location.href : currentPath,
166
+ ...customData
167
+ };
168
+ const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;
169
+ push(eventData);
170
+ }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);
171
+ }
172
+ function escapeJsString(value) {
173
+ return value.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");
174
+ }
175
+ function normalizeContainers(containers) {
176
+ if (typeof containers === "string") {
177
+ return [{ id: containers }];
178
+ }
179
+ if (!Array.isArray(containers)) {
180
+ return [containers];
181
+ }
182
+ return containers.map((c) => typeof c === "string" ? { id: c } : c);
183
+ }
184
+ function buildGtmScriptUrl(containerId, host, dataLayerName, queryParams) {
185
+ const normalizedHost = host.endsWith("/") ? host.slice(0, -1) : host;
186
+ const params = new URLSearchParams();
187
+ params.set("id", containerId);
188
+ if (dataLayerName !== "dataLayer") {
189
+ params.set("l", dataLayerName);
190
+ }
191
+ if (queryParams) {
192
+ for (const [key, value] of Object.entries(queryParams)) {
193
+ if (key !== "id" && key !== "l") {
194
+ params.set(key, String(value));
195
+ }
196
+ }
197
+ }
198
+ return `${normalizedHost}/gtm.js?${params.toString()}`;
199
+ }
200
+ function GtmScripts({
201
+ containers,
202
+ host = "https://www.googletagmanager.com",
203
+ dataLayerName = "dataLayer",
204
+ scriptAttributes = {}
205
+ }) {
206
+ const containerConfigs = normalizeContainers(containers);
207
+ const safeDataLayerName = escapeJsString(dataLayerName);
208
+ const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : "";
209
+ const inlineScript = `
210
+ window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];
211
+ ${containerConfigs.map((config) => {
212
+ const safeContainerId = escapeJsString(config.id);
213
+ const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));
214
+ return `
9
215
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
10
216
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
11
217
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
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
- `}).join(`
15
- `)}
16
- `.trim(),p=createNoscriptMarkup(i,{host:r});return jsxs(Fragment,{children:[jsx("script",{...a,dangerouslySetInnerHTML:{__html:n}}),jsx("noscript",{dangerouslySetInnerHTML:{__html:p}})]})}
218
+ '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ""}f.parentNode.insertBefore(j,f);
219
+ })(window,document,'script','${safeDataLayerName}','${safeContainerId}');
220
+ `;
221
+ }).join("\n")}
222
+ `.trim();
223
+ const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });
224
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
225
+ /* @__PURE__ */ jsx("script", { ...scriptAttributes, dangerouslySetInnerHTML: { __html: inlineScript } }),
226
+ /* @__PURE__ */ jsx("noscript", { dangerouslySetInnerHTML: { __html: noscriptHtml } })
227
+ ] });
228
+ }
17
229
 
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 };
230
+ export { GtmContext, GtmErrorBoundary, GtmProvider, GtmScripts, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useIsGtmReady, useTrackPageViews };
19
231
  //# sourceMappingURL=out.js.map
20
232
  //# 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","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"]}
1
+ {"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["useEffect","useRef","Fragment","jsx"],"mappings":"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","sourcesContent":["import React, {\n Component,\n createContext,\n useContext,\n useEffect,\n useRef,\n useMemo,\n type ErrorInfo,\n type ReactNode\n} 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 /** Synchronously check if all GTM scripts have finished loading */\n isReady: () => boolean;\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\nconst warnOnNestedProvider = (): void => {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[gtm-kit/remix] Nested GtmProvider detected. You should only have one GtmProvider at the root of your app. ' +\n 'The nested provider will be ignored.'\n );\n }\n};\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 // Check for nested provider\n const existingContext = useContext(GtmContext);\n\n // Warn if we're inside another GtmProvider (nested providers)\n useEffect(() => {\n if (existingContext) {\n warnOnNestedProvider();\n }\n }, [existingContext]);\n\n // If nested, just pass through children without creating a new context\n if (existingContext) {\n return <>{children}</>;\n }\n\n return (\n <GtmProviderInner config={config} onBeforeInit={onBeforeInit} onAfterInit={onAfterInit}>\n {children}\n </GtmProviderInner>\n );\n}\n\nfunction GtmProviderInner({ 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 isReady: () => client.isReady(),\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/remix] 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\n/**\n * Hook to check if GTM scripts have finished loading synchronously.\n *\n * @example\n * ```tsx\n * import { useIsGtmReady } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const isReady = useIsGtmReady();\n * return <div>{isReady() ? 'GTM Ready' : 'Loading...'}</div>;\n * }\n * ```\n */\nexport const useIsGtmReady = (): (() => boolean) => {\n return useGtmContext().isReady;\n};\n\n/**\n * Props for GtmErrorBoundary component.\n */\nexport interface GtmErrorBoundaryProps {\n children: ReactNode;\n /** Fallback UI to render when an error occurs */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);\n /** Callback invoked when an error is caught */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n /** Whether to log errors to console (default: true in development) */\n logErrors?: boolean;\n}\n\ninterface GtmErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n/**\n * Error boundary component for GTM provider in Remix apps.\n * Catches errors during GTM initialization and renders a fallback UI.\n * Analytics and tracking will be disabled when an error occurs.\n *\n * @example\n * ```tsx\n * import { GtmProvider, GtmErrorBoundary } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <GtmErrorBoundary fallback={<div>GTM failed to load</div>}>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </GtmErrorBoundary>\n * );\n * }\n * ```\n */\nexport class GtmErrorBoundary extends Component<GtmErrorBoundaryProps, GtmErrorBoundaryState> {\n constructor(props: GtmErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): GtmErrorBoundaryState {\n return { hasError: true, error };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n const { onError, logErrors = process.env.NODE_ENV !== 'production' } = this.props;\n\n if (logErrors) {\n console.error('[gtm-kit/remix] Error caught by GtmErrorBoundary:', error);\n console.error('[gtm-kit/remix] Component stack:', errorInfo.componentStack);\n }\n\n if (onError) {\n try {\n onError(error, errorInfo);\n } catch {\n // Ignore callback errors\n }\n }\n }\n\n reset = (): void => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n const { hasError, error } = this.state;\n const { children, fallback } = this.props;\n\n if (hasError && error) {\n if (fallback === undefined) {\n // Default: render children without GTM (silent fallback)\n return children;\n }\n\n if (typeof fallback === 'function') {\n return fallback(error, this.reset);\n }\n\n return fallback;\n }\n\n return children;\n }\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.6",
3
+ "version": "1.2.0",
4
4
  "description": "Remix adapter for GTM Kit - Google Tag Manager integration with route tracking and streaming SSR.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,7 +49,7 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@jwiedeman/gtm-kit": "^1.0.0"
52
+ "@jwiedeman/gtm-kit": "^1.2.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@remix-run/react": "^2.0.0",