From a4d5670650d1969a82c8ed424cd7fd413aff668c Mon Sep 17 00:00:00 2001 From: Kwame Boateng Date: Tue, 17 Feb 2026 21:53:51 -0500 Subject: [PATCH 1/2] update the listeners --- PULL_REQUEST.md | 44 ++++++ README.md | 2 +- package-lock.json | 22 +-- resources/js/dist/pan.iife.js | 2 +- resources/js/src/main.js | 145 ++++++++---------- resources/js/src/main.ts | 23 +++ resources/js/src/types.ts | 1 + .../Middleware/InjectJavascriptLibrary.php | 2 +- .../InjectJavascriptLibraryTest.php | 22 +++ vite.config.js | 18 +++ 10 files changed, 184 insertions(+), 97 deletions(-) create mode 100644 PULL_REQUEST.md create mode 100644 vite.config.js diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 0000000..da9ad91 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,44 @@ +# Fix Content-Type case sensitivity and add Livewire `wire:navigate` support + +## Summary + +This PR fixes two issues that prevent Pan from working correctly in certain Laravel environments: + +### 1. Content-Type header case sensitivity (Bug Fix) + +The `InjectJavascriptLibrary` middleware performed a strict equality check against `text/html; charset=UTF-8` (uppercase). However, Laravel's response objects return `text/html; charset=utf-8` (lowercase) in many scenarios — particularly when using Blade views and Livewire full-page components. This caused the Pan JavaScript to never be injected into the page, silently breaking all analytics tracking. + +**Before:** +```php +if ($response->headers->get('Content-Type') === 'text/html; charset=UTF-8') { +``` + +**After:** +```php +if (str_starts_with((string) $response->headers->get('Content-Type'), 'text/html')) { +``` + +The fix uses `str_starts_with` to match any `text/html` Content-Type regardless of charset casing or additional parameters, which is consistent with how browsers interpret the Content-Type header. + +### 2. Livewire `wire:navigate` support (Enhancement) + +Pan's client-side JavaScript listened for Inertia's `inertia:start` event to reset impression tracking on page navigation, but had no equivalent listener for Livewire's `wire:navigate` SPA-style transitions. This meant that when using `wire:navigate`, navigating between pages would not re-track impressions for `data-pan` elements on the new page. + +The fix adds a `livewire:navigated` event listener that resets the impression, hover, and click tracking arrays and re-scans for visible `data-pan` elements — matching the existing behavior for Inertia navigation. + +## Changes + +- **`src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php`** — Use `str_starts_with` for case-insensitive Content-Type matching +- **`resources/js/src/main.ts`** — Add `livewire:navigated` event listener for SPA navigation support +- **`resources/js/src/types.ts`** — Add `livewireNavigatedListener` to `GlobalState` type +- **`resources/js/dist/pan.iife.js`** — Rebuilt compiled JavaScript +- **`tests/.../InjectJavascriptLibraryTest.php`** — Added test for lowercase charset Content-Type +- **`README.md`** — Updated Livewire compatibility note + +## Test Plan + +- [x] All 45 existing tests pass +- [x] New test verifies JS injection with lowercase `charset=utf-8` +- [x] Existing tests confirm JS injection with uppercase charset (returned by plain string routes) +- [x] Existing test confirms no injection for `text/plain` Content-Type +- [x] Verified in a real Laravel 12 + Livewire 4 application with `wire:navigate` diff --git a/README.md b/README.md index 5b3a467..b661be4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ At the time of writing, Pan tracks only the following events: impressions, hover - you have different "help" pop-hovers in your application, and you want to know **which one is the most hovered**. By adding the `data-pan` attribute to your pop-hovers, you can track this information. - and so on... -It works out-of-the-box with your favorite Laravel stack; updating a button color in your "react" won't trigger a new impression, but seeing that same button in a different [Inertia](https://inertiajs.com) page will. Using [Livewire](https://livewire.laravel.com)? No problem, Pan works seamlessly with it too. +It works out-of-the-box with your favorite Laravel stack; updating a button color in your "react" won't trigger a new impression, but seeing that same button in a different [Inertia](https://inertiajs.com) page will. Using [Livewire](https://livewire.laravel.com)? No problem, Pan works seamlessly with it too — including full support for Livewire's `wire:navigate` SPA-style page transitions. Visualize your analytics is as simple as typing `php artisan pan` in your terminal. This command will show you a table with the different analytics you've been tracking, and hopefully, you can use this information to improve your application. diff --git a/package-lock.json b/package-lock.json index de1699a..02a7e1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "pan", + "name": "pan-package", "lockfileVersion": 3, "requires": true, "packages": { "": { "devDependencies": { - "prettier": "^2.4.1", + "prettier": "^3.3.3", "typescript": "^5.6.3", - "vite": "^5.4.8" + "vite": "^5.4.10" } }, "node_modules/@esbuild/aix-ppc64": { @@ -742,16 +742,16 @@ } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -818,9 +818,9 @@ } }, "node_modules/vite": { - "version": "5.4.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", - "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/resources/js/dist/pan.iife.js b/resources/js/dist/pan.iife.js index 8096fdf..c0d4afc 100644 --- a/resources/js/dist/pan.iife.js +++ b/resources/js/dist/pan.iife.js @@ -1 +1 @@ -(function(){"use strict";window.__pan=window.__pan||{csrfToken:"%_PAN_CSRF_TOKEN_%",routePrefix:"%_PAN_ROUTE_PREFIX_%",observer:null,clickListener:null,mouseoverListener:null,inertiaStartListener:null},window.__pan.observer&&(window.__pan.observer.disconnect(),window.__pan.observer=null),window.__pan.clickListener&&(document.removeEventListener("click",window.__pan.clickListener),window.__pan.clickListener=null),window.__pan.mouseoverListener&&(document.removeEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.mouseoverListener=null),window.__pan.inertiaStartListener&&(document.removeEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.inertiaStartListener=null),function(){const p=e=>{const n=new MutationObserver(e);n.observe(document.body,{childList:!0,subtree:!0,attributes:!0}),window.__pan.observer=n};let i=[],a=null,r=[],s=[],u=[];const c=()=>{if(i.length===0)return;const e=i.slice();i=[],navigator.sendBeacon(`/${window.__pan.routePrefix}/events`,new Blob([JSON.stringify({events:e,_token:window.__pan.csrfToken})],{type:"application/json"}))},l=function(){a&&clearTimeout(a),a=setTimeout(c,1e3)},d=function(e,n){const w=e.target.closest("[data-pan]");if(w===null)return;const o=w.getAttribute("data-pan");if(o!==null){if(n==="hover"){if(s.includes(o))return;s.push(o)}if(n==="click"){if(u.includes(o))return;u.push(o)}i.push({type:n,name:o}),l()}},_=function(){document.querySelectorAll("[data-pan]").forEach(n=>{if(n.checkVisibility!==void 0&&!n.checkVisibility())return;const t=n.getAttribute("data-pan");t!==null&&(r.includes(t)||(r.push(t),i.push({type:"impression",name:t})))}),l()};p(function(){r.forEach(e=>{document.querySelector(`[data-pan='${e}']`)===null&&(r=r.filter(t=>t!==e),s=s.filter(t=>t!==e),u=u.filter(t=>t!==e))}),_()}),window.__pan.clickListener=e=>d(e,"click"),document.addEventListener("click",window.__pan.clickListener),window.__pan.mouseoverListener=e=>d(e,"hover"),document.addEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.inertiaStartListener=e=>{r=[],s=[],u=[],_()},document.addEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.beforeUnloadListener=function(e){i.length!==0&&c()},window.addEventListener("beforeunload",window.__pan.beforeUnloadListener)}()})(); +(function(){"use strict";window.__pan=window.__pan||{csrfToken:"%_PAN_CSRF_TOKEN_%",routePrefix:"%_PAN_ROUTE_PREFIX_%",observer:null,clickListener:null,mouseoverListener:null,inertiaStartListener:null,livewireNavigatedListener:null},window.__pan.observer&&(window.__pan.observer.disconnect(),window.__pan.observer=null),window.__pan.clickListener&&(document.removeEventListener("click",window.__pan.clickListener),window.__pan.clickListener=null),window.__pan.mouseoverListener&&(document.removeEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.mouseoverListener=null),window.__pan.inertiaStartListener&&(document.removeEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.inertiaStartListener=null),window.__pan.livewireNavigatedListener&&(document.removeEventListener("livewire:navigated",window.__pan.livewireNavigatedListener),window.__pan.livewireNavigatedListener=null),function(){const v=e=>{const n=new MutationObserver(e);n.observe(document.body,{childList:!0,subtree:!0,attributes:!0}),window.__pan.observer=n};let r=[],d=null,i=[],o=[],s=[];const l=()=>{if(r.length===0)return;const e=r.slice();r=[],navigator.sendBeacon(`/${window.__pan.routePrefix}/events`,new Blob([JSON.stringify({events:e,_token:window.__pan.csrfToken})],{type:"application/json"}))},c=function(){d&&clearTimeout(d),d=setTimeout(l,1e3)},w=function(e,n){const _=e.target.closest("[data-pan]");if(_===null)return;const a=_.getAttribute("data-pan");if(a!==null){if(n==="hover"){if(o.includes(a))return;o.push(a)}if(n==="click"){if(s.includes(a))return;s.push(a)}r.push({type:n,name:a}),c()}},u=function(){document.querySelectorAll("[data-pan]").forEach(n=>{if(n.checkVisibility!==void 0&&!n.checkVisibility())return;const t=n.getAttribute("data-pan");t!==null&&(i.includes(t)||(i.push(t),r.push({type:"impression",name:t})))}),c()};v(function(){i.forEach(e=>{document.querySelector(`[data-pan='${e}']`)===null&&(i=i.filter(t=>t!==e),o=o.filter(t=>t!==e),s=s.filter(t=>t!==e))}),u()}),window.__pan.clickListener=e=>w(e,"click"),document.addEventListener("click",window.__pan.clickListener),window.__pan.mouseoverListener=e=>w(e,"hover"),document.addEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.inertiaStartListener=e=>{i=[],o=[],s=[],u()},document.addEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.livewireNavigatedListener=()=>{i=[],o=[],s=[],u()},document.addEventListener("livewire:navigated",window.__pan.livewireNavigatedListener),window.__pan.beforeUnloadListener=function(e){r.length!==0&&l()},window.addEventListener("beforeunload",window.__pan.beforeUnloadListener)}()})(); diff --git a/resources/js/src/main.js b/resources/js/src/main.js index 424a3f7..dc506b6 100644 --- a/resources/js/src/main.js +++ b/resources/js/src/main.js @@ -1,13 +1,16 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.window.__pan = exports.window.__pan || { - csrfToken: "%_PAN_CSRF_TOKEN_%", - routePrefix: "%_PAN_ROUTE_PREFIX_%", - observer: null, - clickListener: null, - mouseoverListener: null, - inertiaStartListener: null, -}; +exports.window.__pan = + exports.window.__pan || + { + csrfToken: "%_PAN_CSRF_TOKEN_%", + routePrefix: "%_PAN_ROUTE_PREFIX_%", + observer: null, + clickListener: null, + mouseoverListener: null, + inertiaStartListener: null, + livewireNavigatedListener: null, + }; if (exports.window.__pan.observer) { exports.window.__pan.observer.disconnect(); exports.window.__pan.observer = null; @@ -17,22 +20,20 @@ if (exports.window.__pan.clickListener) { exports.window.__pan.clickListener = null; } if (exports.window.__pan.mouseoverListener) { - document.removeEventListener( - "mouseover", - exports.window.__pan.mouseoverListener - ); + document.removeEventListener("mouseover", exports.window.__pan.mouseoverListener); exports.window.__pan.mouseoverListener = null; } if (exports.window.__pan.inertiaStartListener) { - document.removeEventListener( - "inertia:start", - exports.window.__pan.inertiaStartListener - ); + document.removeEventListener("inertia:start", exports.window.__pan.inertiaStartListener); exports.window.__pan.inertiaStartListener = null; } +if (exports.window.__pan.livewireNavigatedListener) { + document.removeEventListener("livewire:navigated", exports.window.__pan.livewireNavigatedListener); + exports.window.__pan.livewireNavigatedListener = null; +} (function () { - var domObserver = function (callback) { - var observer = new MutationObserver(callback); + const domObserver = (callback) => { + const observer = new MutationObserver(callback); observer.observe(document.body, { childList: true, subtree: true, @@ -40,44 +41,38 @@ if (exports.window.__pan.inertiaStartListener) { }); exports.window.__pan.observer = observer; }; - var queue = []; - var queueTimeout = null; - var impressed = []; - var hovered = []; - var clicked = []; - var commit = function () { + let queue = []; + let queueTimeout = null; + let impressed = []; + let hovered = []; + let clicked = []; + const commit = () => { if (queue.length === 0) { return; } - var onGoingQueue = queue.slice(); + const onGoingQueue = queue.slice(); queue = []; - navigator.sendBeacon( - "/".concat(exports.window.__pan.routePrefix, "/events"), - new Blob( - [ - JSON.stringify({ - events: onGoingQueue, - _token: exports.window.__pan.csrfToken, - }), - ], - { - type: "application/json", - } - ) - ); + navigator.sendBeacon(`/${exports.window.__pan.routePrefix}/events`, new Blob([ + JSON.stringify({ + events: onGoingQueue, + _token: exports.window.__pan.csrfToken, + }), + ], { + type: "application/json", + })); }; - var queueCommit = function () { + const queueCommit = function () { queueTimeout && clearTimeout(queueTimeout); // @ts-ignore queueTimeout = setTimeout(commit, 1000); }; - var send = function (el, event) { - var target = el.target; - var element = target.closest("[data-pan]"); + const send = function (el, event) { + const target = el.target; + const element = target.closest("[data-pan]"); if (element === null) { return; } - var name = element.getAttribute("data-pan"); + const name = element.getAttribute("data-pan"); if (name === null) { return; } @@ -99,16 +94,14 @@ if (exports.window.__pan.inertiaStartListener) { }); queueCommit(); }; - var detectImpressions = function () { - var elementsBeingImpressed = document.querySelectorAll("[data-pan]"); - elementsBeingImpressed.forEach(function (element) { - if ( - element.checkVisibility !== undefined && - !element.checkVisibility() - ) { + const detectImpressions = function () { + const elementsBeingImpressed = document.querySelectorAll("[data-pan]"); + elementsBeingImpressed.forEach((element) => { + if (element.checkVisibility !== undefined && + !element.checkVisibility()) { return; } - var name = element.getAttribute("data-pan"); + const name = element.getAttribute("data-pan"); if (name === null) { return; } @@ -124,53 +117,39 @@ if (exports.window.__pan.inertiaStartListener) { queueCommit(); }; domObserver(function () { - impressed.forEach(function (name) { - var element = document.querySelector( - "[data-pan='".concat(name, "']") - ); + impressed.forEach((name) => { + const element = document.querySelector(`[data-pan='${name}']`); if (element === null) { - impressed = impressed.filter(function (n) { - return n !== name; - }); - hovered = hovered.filter(function (n) { - return n !== name; - }); - clicked = clicked.filter(function (n) { - return n !== name; - }); + impressed = impressed.filter((n) => n !== name); + hovered = hovered.filter((n) => n !== name); + clicked = clicked.filter((n) => n !== name); } }); detectImpressions(); }); - exports.window.__pan.clickListener = function (event) { - return send(event, "click"); - }; + exports.window.__pan.clickListener = (event) => send(event, "click"); document.addEventListener("click", exports.window.__pan.clickListener); - exports.window.__pan.mouseoverListener = function (event) { - return send(event, "hover"); + exports.window.__pan.mouseoverListener = (event) => send(event, "hover"); + document.addEventListener("mouseover", exports.window.__pan.mouseoverListener); + exports.window.__pan.inertiaStartListener = (event) => { + impressed = []; + hovered = []; + clicked = []; + detectImpressions(); }; - document.addEventListener( - "mouseover", - exports.window.__pan.mouseoverListener - ); - exports.window.__pan.inertiaStartListener = function (event) { + document.addEventListener("inertia:start", exports.window.__pan.inertiaStartListener); + exports.window.__pan.livewireNavigatedListener = () => { impressed = []; hovered = []; clicked = []; detectImpressions(); }; - document.addEventListener( - "inertia:start", - exports.window.__pan.inertiaStartListener - ); + document.addEventListener("livewire:navigated", exports.window.__pan.livewireNavigatedListener); exports.window.__pan.beforeUnloadListener = function (event) { if (queue.length === 0) { return; } commit(); }; - exports.window.addEventListener( - "beforeunload", - exports.window.__pan.beforeUnloadListener - ); + exports.window.addEventListener("beforeunload", exports.window.__pan.beforeUnloadListener); })(); diff --git a/resources/js/src/main.ts b/resources/js/src/main.ts index cb9aa68..e6a9a82 100644 --- a/resources/js/src/main.ts +++ b/resources/js/src/main.ts @@ -13,6 +13,7 @@ window.__pan = clickListener: null, mouseoverListener: null, inertiaStartListener: null, + livewireNavigatedListener: null, } as GlobalState); if (window.__pan.observer) { @@ -42,6 +43,15 @@ if (window.__pan.inertiaStartListener) { window.__pan.inertiaStartListener = null; } +if (window.__pan.livewireNavigatedListener) { + document.removeEventListener( + "livewire:navigated", + window.__pan.livewireNavigatedListener + ); + + window.__pan.livewireNavigatedListener = null; +} + (function (): void { const domObserver = (callback: MutationCallback): void => { const observer = new MutationObserver(callback); @@ -199,6 +209,19 @@ if (window.__pan.inertiaStartListener) { window.__pan.inertiaStartListener ); + window.__pan.livewireNavigatedListener = (): void => { + impressed = []; + hovered = []; + clicked = []; + + detectImpressions(); + }; + + document.addEventListener( + "livewire:navigated", + window.__pan.livewireNavigatedListener + ); + window.__pan.beforeUnloadListener = function (event: Event): void { if (queue.length === 0) { return; diff --git a/resources/js/src/types.ts b/resources/js/src/types.ts index 97c469d..2ca1caa 100644 --- a/resources/js/src/types.ts +++ b/resources/js/src/types.ts @@ -7,5 +7,6 @@ export type GlobalState = { clickListener: EventListener | null; mouseoverListener: EventListener | null; inertiaStartListener: EventListener | null; + livewireNavigatedListener: EventListener | null; beforeUnloadListener: EventListener | null; }; diff --git a/src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php b/src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php index a3e8f93..362f34e 100644 --- a/src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php +++ b/src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php @@ -33,7 +33,7 @@ public function handle(Request $request, Closure $next): Response /** @var Response $response */ $response = $next($request); - if ($response->headers->get('Content-Type') === 'text/html; charset=UTF-8') { + if (str_starts_with((string) $response->headers->get('Content-Type'), 'text/html')) { $content = (string) $response->getContent(); if (! str_contains($content, '') || ! str_contains($content, '')) { diff --git a/tests/Unit/Adapters/Laravel/Http/Middleware/InjectJavascriptLibraryTest.php b/tests/Unit/Adapters/Laravel/Http/Middleware/InjectJavascriptLibraryTest.php index e6952e8..a49c650 100644 --- a/tests/Unit/Adapters/Laravel/Http/Middleware/InjectJavascriptLibraryTest.php +++ b/tests/Unit/Adapters/Laravel/Http/Middleware/InjectJavascriptLibraryTest.php @@ -24,6 +24,28 @@ ->assertSee('_TEST_CSRF_TOKEN_'); }); +it('does inject the javascript library with lowercase charset', function (): void { + Route::get('/', fn () => response(<<<'HTML' + + + My App + + +

Welcome to my app

+ + + HTML + )->header('Content-Type', 'text/html; charset=utf-8')); + + session()->put('_token', '_TEST_CSRF_TOKEN_'); + + $response = $this->get('/'); + + $response->assertOk() + ->assertSee('script') + ->assertSee('_TEST_CSRF_TOKEN_'); +}); + it('does not inject the javascript library if the content type is not text/html', function (): void { Route::get('/', fn () => response('Hello, World!')->header('Content-Type', 'text/plain')); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..b568a65 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const path_1 = require("path"); +const vite_1 = require("vite"); +/** + * @type {import('vite').UserConfig} + */ +exports.default = (0, vite_1.defineConfig)({ + build: { + outDir: 'resources/js/dist', + lib: { + entry: (0, path_1.resolve)(__dirname, 'resources/js/src/main.ts'), + name: 'pan', + fileName: 'pan', + formats: ['iife'], + } + }, +}); From eaee3e0f1dc6d82e90c7946f45beae50e544166c Mon Sep 17 00:00:00 2001 From: Kwame Boateng Date: Tue, 17 Feb 2026 22:07:41 -0500 Subject: [PATCH 2/2] include blade directive --- PULL_REQUEST.md | 32 ++++++++++++++++--- README.md | 18 ++++++++++- .../Laravel/Providers/PanServiceProvider.php | 10 ++++++ .../Adapters/Laravel/BladeDirectiveTest.php | 21 ++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Adapters/Laravel/BladeDirectiveTest.php diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md index da9ad91..89608fd 100644 --- a/PULL_REQUEST.md +++ b/PULL_REQUEST.md @@ -1,8 +1,8 @@ -# Fix Content-Type case sensitivity and add Livewire `wire:navigate` support +# Fix Content-Type matching, add Livewire navigate support, and `@pan` Blade directive ## Summary -This PR fixes two issues that prevent Pan from working correctly in certain Laravel environments: +This PR fixes two bugs and adds a new feature to improve the Pan developer experience: ### 1. Content-Type header case sensitivity (Bug Fix) @@ -24,21 +24,43 @@ The fix uses `str_starts_with` to match any `text/html` Content-Type regardless Pan's client-side JavaScript listened for Inertia's `inertia:start` event to reset impression tracking on page navigation, but had no equivalent listener for Livewire's `wire:navigate` SPA-style transitions. This meant that when using `wire:navigate`, navigating between pages would not re-track impressions for `data-pan` elements on the new page. -The fix adds a `livewire:navigated` event listener that resets the impression, hover, and click tracking arrays and re-scans for visible `data-pan` elements — matching the existing behavior for Inertia navigation. +The fix adds a `livewire:navigated` event listener that resets the impression, hover, and click tracking arrays and re-scans for visible `data-pan` elements — matching the existing behavior for Inertia navigation. If Livewire is not installed, the listener simply never fires (same pattern as the existing Inertia listener). + +### 3. `@pan` Blade directive (Feature) + +Adds a `@pan` Blade directive as a cleaner alternative to writing `data-pan="..."` manually: + +```blade +{{-- Before --}} + + +{{-- After --}} + +``` + +Supports variables and expressions: + +```blade + + +``` ## Changes +- **`src/Adapters/Laravel/Providers/PanServiceProvider.php`** — Register `@pan` Blade directive - **`src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php`** — Use `str_starts_with` for case-insensitive Content-Type matching - **`resources/js/src/main.ts`** — Add `livewire:navigated` event listener for SPA navigation support - **`resources/js/src/types.ts`** — Add `livewireNavigatedListener` to `GlobalState` type - **`resources/js/dist/pan.iife.js`** — Rebuilt compiled JavaScript - **`tests/.../InjectJavascriptLibraryTest.php`** — Added test for lowercase charset Content-Type -- **`README.md`** — Updated Livewire compatibility note +- **`tests/.../BladeDirectiveTest.php`** — Added tests for `@pan` directive (static string, variable, expression) +- **`README.md`** — Added `@pan` directive documentation and updated Livewire compatibility note ## Test Plan -- [x] All 45 existing tests pass +- [x] All existing tests pass - [x] New test verifies JS injection with lowercase `charset=utf-8` - [x] Existing tests confirm JS injection with uppercase charset (returned by plain string routes) - [x] Existing test confirms no injection for `text/plain` Content-Type +- [x] `@pan` directive compiles to correct `data-pan` attribute with static strings, variables, and expressions - [x] Verified in a real Laravel 12 + Livewire 4 application with `wire:navigate` diff --git a/README.md b/README.md index b661be4..a2a1fe0 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,25 @@ Finally, you may start tracking your pages or components adding the `data-pan` a ``` -> [!IMPORTANT] +> [!IMPORTANT] > Event names must only contain letters, numbers, dashes, and underscores. +### Using the `@pan` Blade directive + +For a cleaner syntax in your Blade templates, you may use the `@pan` directive instead of writing the `data-pan` attribute manually: + +```blade + + +``` + +The directive also supports variables and expressions: + +```blade + + +``` + ## Visualize your product analytics To visualize your product analytics, you may use the `pan` Artisan command: diff --git a/src/Adapters/Laravel/Providers/PanServiceProvider.php b/src/Adapters/Laravel/Providers/PanServiceProvider.php index 81bdd31..e675e15 100644 --- a/src/Adapters/Laravel/Providers/PanServiceProvider.php +++ b/src/Adapters/Laravel/Providers/PanServiceProvider.php @@ -5,6 +5,7 @@ namespace Pan\Adapters\Laravel\Providers; use Illuminate\Contracts\Http\Kernel as HttpContract; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Pan\Adapters\Laravel\Console\Commands\InstallPanCommand; @@ -35,6 +36,7 @@ public function register(): void */ public function boot(): void { + $this->registerBladeDirectives(); $this->registerCommands(); $this->registerRoutes(); $this->registerPublishing(); @@ -74,6 +76,14 @@ private function registerRoutes(): void }); } + /** + * Register the package's Blade directives. + */ + private function registerBladeDirectives(): void + { + Blade::directive('pan', fn (string $expression): string => "data-pan=\"\""); + } + /** * Register the package's publishable resources. */ diff --git a/tests/Unit/Adapters/Laravel/BladeDirectiveTest.php b/tests/Unit/Adapters/Laravel/BladeDirectiveTest.php new file mode 100644 index 0000000..e5c02a9 --- /dev/null +++ b/tests/Unit/Adapters/Laravel/BladeDirectiveTest.php @@ -0,0 +1,21 @@ +Tab 1'); + + expect($compiled)->toContain('data-pan=""'); +}); + +it('renders the @pan directive with a variable', function (): void { + $compiled = Blade::compileString(''); + + expect($compiled)->toContain('data-pan=""'); +}); + +it('renders the @pan directive with a concatenated expression', function (): void { + $compiled = Blade::compileString(''); + + expect($compiled)->toContain('data-pan=""'); +});