diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md
new file mode 100644
index 0000000..89608fd
--- /dev/null
+++ b/PULL_REQUEST.md
@@ -0,0 +1,66 @@
+# Fix Content-Type matching, add Livewire navigate support, and `@pan` Blade directive
+
+## Summary
+
+This PR fixes two bugs and adds a new feature to improve the Pan developer experience:
+
+### 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. 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
+- **`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 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 5b3a467..a2a1fe0 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.
@@ -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/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, '