1+ // BACKGROUND
2+ // This file implements helper functions that make it very straigtforward to track Matomo events in code.
3+ // These functions use matomo's _paq.push() API to send events directly to Matomo.
4+ // This does NOT use Tag Manager, but works in core matomo or Tag Manager environments.
5+ //
6+ // CLICK TRACKING
7+ // Add `data-matomo-click="Category, Action, Name"` to any element to track
8+ // clicks as Matomo events. The Name segment is optional. We have a convention
9+ // of using semicolons inside Name to divide multiple values.
10+ //
11+ // This tracking can be placed directly on an interactive element.
12+ // Tracking also works if placed on a container. The script will look
13+ // for any interactive elements inside the container.
14+ //
15+ // Interactive elements are defined as elements: a, button, input, select, textarea
16+ //
17+ // Examples:
18+ // <a href="/file.pdf" data-matomo-click="Downloads, PDF Click, My Paper">Download</a>
19+ // <button data-matomo-click="Search, Boolean Toggle">AND/OR</button>
20+ //
21+ // Event delegation on `document` means this works for elements loaded
22+ // asynchronously (Turbo frames, content-loader, etc.) without re-binding.
23+ //
24+ // SEEN TRACKING
25+ // Add `data-matomo-seen="Category, Action, Name"` to any element to fire a
26+ // Matomo event when that element becomes visible in the viewport. The Name
27+ // segment is optional. We have a convention of using semicolons inside Name
28+ // to divide multiple values. Each element fires at most once per page load.
29+ // Works for elements present on initial page load and for elements injected
30+ // later by Turbo frames or async content loaders.
31+ //
32+ // Examples:
33+ // <div data-matomo-seen="Impressions, Result Card, Alma">...</div>
34+ // <a data-matomo-seen="Promotions, Banner Shown">...</a>
35+ //
36+ // DYNAMIC VALUES
37+ // Wrap a helper name in double curly braces anywhere inside a segment to have
38+ // it replaced with the return value of that function at tracking time. Helpers
39+ // must be registered on `window.MatomoHelpers` (see bottom of this file).
40+ // Multiple tokens in one segment are supported.
41+ //
42+ // Convention is to only use these in the "Name" segment to provide more context.
43+ // Avoid using inside Category or Action to improve the hierarchy of Matomo dashboards.
44+ //
45+ // Examples:
46+ // <h2 data-matomo-seen="Search, Results Found, Tab: {{getActiveTabName}}">...</h2>
47+ // <a data-matomo-click="Nav, Link Click, Link: {{getElementText}}">...</a>
48+
49+ // ---------------------------------------------------------------------------
50+ // Shared helper
51+ // ---------------------------------------------------------------------------
52+
53+ // Parse a "Category, Action, Name" attribute string and push a trackEvent call
54+ // to the Matomo queue. Name is optional; returns early if fewer than 2 parts.
55+ // `context` is the DOM element that triggered the event; it is forwarded to
56+ // every helper so functions like getElementText can reference it.
57+ function pushMatomoEvent ( raw , context ) {
58+
59+ // Split on commas, trim whitespace from each part, drop any empty strings.
60+ const parts = ( raw || "" ) . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
61+ // Matomo requires at least a Category and an Action.
62+ if ( parts . length < 2 ) return ;
63+
64+ // Resolve any {{functionName}} tokens by calling the matching helper.
65+ // Each token is replaced in-place, so it can appear anywhere in a segment.
66+ // The context element is passed as the first argument so helpers can
67+ // inspect the element that triggered the event (e.g. getElementText).
68+ const helpers = window . MatomoHelpers || { } ;
69+ const resolved = parts . map ( ( part ) =>
70+ part . replace ( / \{ \{ ( \w + ) \} \} / g, ( _ , fnName ) => {
71+ const fn = helpers [ fnName ] ;
72+ // Call the function if it exists; otherwise leave the token as-is.
73+ return ( typeof fn === "function" ) ? fn ( context ) : `{{${ fnName } }}` ;
74+ } )
75+ ) ;
76+
77+ // Destructure into named variables; `name` will be undefined if not provided.
78+ const [ category , action , name ] = resolved ;
79+
80+ // Ensure _paq exists even if the Matomo snippet hasn't loaded yet
81+ // (e.g. in development). Matomo will replay queued calls once it initialises.
82+ window . _paq = window . _paq || [ ] ;
83+ const payload = [ "trackEvent" , category , action ] ;
84+ if ( name ) payload . push ( name ) ;
85+ window . _paq . push ( payload ) ;
86+ }
87+
88+ // ---------------------------------------------------------------------------
89+ // Click tracking
90+ // ---------------------------------------------------------------------------
91+
92+ // Attach a single click listener to the entire document using the capture
93+ // phase (third argument { capture: true }). Capture phase fires top-down
94+ // before any bubble-phase listeners, which guarantees helpers like
95+ // getActiveTabName() read pre-click DOM state before other listeners
96+ // (e.g. loading_spinner.js's swapTabs) synchronously update it.
97+ document . addEventListener ( "click" , ( event ) => {
98+ // Walk up the DOM from the clicked element to find the nearest ancestor
99+ // (or the element itself) that has a data-matomo-click attribute.
100+ const el = event . target . closest ( "[data-matomo-click]" ) ;
101+ // If no such element exists in the ancestor chain, ignore this click.
102+ if ( ! el ) return ;
103+
104+ // Only fire when the click originated from an interactive element (link,
105+ // button, or form control). This allows data-matomo-click to be placed on
106+ // a container and track only meaningful interactions within it, ignoring
107+ // clicks on surrounding text, padding, or decorative children.
108+ const interactive = event . target . closest ( "a, button, input, select, textarea" ) ;
109+ if ( ! interactive ) return ;
110+
111+ // Confirm the interactive element is actually inside the tracked container
112+ // (guards against the unlikely case where closest() finds an ancestor of el).
113+ if ( ! el . contains ( interactive ) && el !== interactive ) return ;
114+
115+ // Pass the interactive element as context so helpers like getElementText
116+ // can read the text of the specific link or button that was clicked.
117+ pushMatomoEvent ( el . dataset . matomoClick , interactive ) ;
118+ } , { capture : true } ) ;
119+
120+ // ---------------------------------------------------------------------------
121+ // Seen tracking
122+ // ---------------------------------------------------------------------------
123+
124+ // Track elements already registered with the viewport observer to avoid
125+ // double-registration if the same node is added to the DOM more than once.
126+ const seenRegistered = new WeakSet ( ) ;
127+
128+ // Fire a Matomo event when an observed element intersects the viewport.
129+ // Unobserve immediately so the event fires at most once per element.
130+ const viewportObserver = new IntersectionObserver ( ( entries ) => {
131+ entries . forEach ( ( entry ) => {
132+ if ( ! entry . isIntersecting ) return ;
133+ // Stop watching — we only want to fire once per element.
134+ viewportObserver . unobserve ( entry . target ) ;
135+ pushMatomoEvent ( entry . target . dataset . matomoSeen , entry . target ) ;
136+ } ) ;
137+ } ) ;
138+
139+ // Register a single element with the viewport observer if it carries
140+ // data-matomo-seen and hasn't been registered yet.
141+ function registerIfSeen ( el ) {
142+ // Only process element nodes (not text nodes, comments, etc.).
143+ if ( el . nodeType !== Node . ELEMENT_NODE ) return ;
144+ // Skip if already registered.
145+ if ( seenRegistered . has ( el ) ) return ;
146+
147+ // Register the element itself if it has the attribute.
148+ if ( el . dataset . matomoSeen ) {
149+ seenRegistered . add ( el ) ;
150+ viewportObserver . observe ( el ) ;
151+ }
152+
153+ // Also register any descendants — content loaders often inject a whole
154+ // subtree at once, so walking deep ensures every marked element is caught.
155+ el . querySelectorAll ( "[data-matomo-seen]" ) . forEach ( ( child ) => {
156+ if ( seenRegistered . has ( child ) ) return ;
157+ seenRegistered . add ( child ) ;
158+ viewportObserver . observe ( child ) ;
159+ } ) ;
160+ }
161+
162+ // Register all elements already present in the DOM on initial page load.
163+ document . querySelectorAll ( "[data-matomo-seen]" ) . forEach ( ( el ) => {
164+ seenRegistered . add ( el ) ;
165+ viewportObserver . observe ( el ) ;
166+ } ) ;
167+
168+ // ---------------------------------------------------------------------------
169+ // Matomo native content tracking
170+ // ---------------------------------------------------------------------------
171+
172+ // Core Matomo includes "Content Tracking"
173+ // You can use these attributes to track content impressions and interactions.
174+ // See documentation for details (https://matomo.org/faq/how-to/how-do-i-markup-content-for-content-tracking/)
175+ // This file only ensures that these attributes are processed for asynchronously-inserted content.
176+ //
177+ // Attributes for content tracking (native to Matomo):
178+ // * data-track-content - Defines a content block to track
179+ // * data-content-name - Gives a name for the content block (appears in Matomo Dashboard)
180+ // * data-content-piece - Names a piece of content to track interactions on (appears in Matomo Dashboard)
181+ // * data-content-target - Specifies the target of the content interaction (appears in Matomo Dashboard)
182+
183+ // Matomo's built-in content tracking (data-track-content / data-content-name /
184+ // data-content-piece) only scans the DOM at page load. For content injected
185+ // asynchronously (e.g. by the content-loader Stimulus controller), we must
186+ // manually notify Matomo by calling trackContentImpressionsWithinNode on the
187+ // newly-added node.
188+ function trackContentImpressionsIfPresent ( el ) {
189+ if ( el . nodeType !== Node . ELEMENT_NODE ) return ;
190+ // Check the element itself or any descendant for data-track-content.
191+ const hasContent =
192+ el . hasAttribute ( "data-track-content" ) ||
193+ el . querySelector ( "[data-track-content]" ) !== null ;
194+ if ( ! hasContent ) return ;
195+
196+ window . _paq = window . _paq || [ ] ;
197+ // Ask Matomo to scan the subtree for content impressions.
198+ window . _paq . push ( [ "trackContentImpressionsWithinNode" , el ] ) ;
199+ }
200+
201+ // Watch for any new nodes added to the DOM after initial load.
202+ // MutationObserver fires synchronously after each DOM mutation, so it catches
203+ // both Turbo frame renders and content-loader replacements immediately.
204+ const observer = new MutationObserver ( ( mutations ) => {
205+ mutations . forEach ( ( mutation ) => {
206+ // Each mutation record lists the nodes that were added in this batch.
207+ mutation . addedNodes . forEach ( ( node ) => {
208+ registerIfSeen ( node ) ;
209+ trackContentImpressionsIfPresent ( node ) ;
210+ } ) ;
211+ } ) ;
212+ } ) ;
213+
214+ // Observe the entire document subtree so no async insertion is missed.
215+ observer . observe ( document . body , { childList : true , subtree : true } ) ;
216+
217+ // Turbo Drive navigation replaces document.body with a brand new element,
218+ // which detaches the MutationObserver from the old body. Re-scan and
219+ // re-attach on every turbo:load so full-page navigations are handled.
220+ // (Turbo frame and content-loader updates are covered by the observer above
221+ // because they mutate within the existing body rather than replacing it.)
222+ document . addEventListener ( "turbo:load" , ( ) => {
223+ // Register any seen elements that arrived with the navigation.
224+ document . querySelectorAll ( "[data-matomo-seen]" ) . forEach ( ( el ) => {
225+ if ( seenRegistered . has ( el ) ) return ;
226+ seenRegistered . add ( el ) ;
227+ viewportObserver . observe ( el ) ;
228+ } ) ;
229+
230+ // Re-attach the MutationObserver to the new document.body instance.
231+ observer . observe ( document . body , { childList : true , subtree : true } ) ;
232+ } ) ;
233+
234+
235+ // ===========================================================================
236+ // HELPER FUNCTIONS
237+ // Custom JS to enhance the payload information we provide to Matomo.
238+ // ===========================================================================
239+
240+ // ---------------------------------------------------------------------------
241+ // Get the name of the active search results tab, if any.
242+ // ---------------------------------------------------------------------------
243+ function getActiveTabName ( ) {
244+ var tabs = document . querySelector ( '#tabs' ) ;
245+ if ( ! tabs ) {
246+ return "None" ; // #tabs not found
247+ }
248+
249+ var activeAnchor = tabs . querySelector ( 'a.active' ) ;
250+ if ( ! activeAnchor ) {
251+ return "None" ; // no active tab
252+ }
253+
254+ return activeAnchor . textContent . trim ( ) ;
255+ }
256+
257+ // ---------------------------------------------------------------------------
258+ // Get the visible text of the element that triggered the event.
259+ // For click tracking this is the interactive element (link, button, etc.).
260+ // For seen tracking this is the element carrying data-matomo-seen.
261+ // Returns an empty string if no context element is available.
262+ // ---------------------------------------------------------------------------
263+ function getElementText ( el ) {
264+ if ( ! el ) return "" ;
265+ return el . textContent . trim ( ) ;
266+ }
267+
268+ // ---------------------------------------------------------------------------
269+ // Get the current results page number from the `page` URL parameter.
270+ // Returns "1" when the parameter is absent (the first page has no page param).
271+ // ---------------------------------------------------------------------------
272+ function getCurrentResultsPage ( ) {
273+ const params = new URLSearchParams ( window . location . search ) ;
274+ return params . get ( "page" ) || "1" ;
275+ }
276+
277+ // ---------------------------------------------------------------------------
278+ // Register helpers on window.MatomoHelpers so they can be referenced with the
279+ // {{functionName}} syntax in data-matomo-seen and data-matomo-click attributes.
280+ // Add new helpers here as needed.
281+ // ---------------------------------------------------------------------------
282+ window . MatomoHelpers = {
283+ getActiveTabName,
284+ getElementText,
285+ getCurrentResultsPage,
286+ } ;
0 commit comments