1/** 2 * Google Analytics event tracking for Kontekst.io. 3 * 4 * The legacy app duplicated this logic across three Angular controllers 5 * (CtxController, CroController, SrbController), each hardcoding its own 6 * language into every Analytics.trackEvent call. Here the language is read from 7 * the page, so one implementation covers Slovenian, Croatian and Serbian. 8 * 9 * Listeners are delegated from `document` in the capture phase: delegation 10 * keeps them alive after an HTMX swap replaces the results list, and capture 11 * runs them before a click or Enter starts navigating away. 12 * 13 * Every call is a no-op when the tag is absent, which is the case in 14 * development (see config.GA_MEASUREMENT_ID). 15 */ 16(function () { 17 'use strict'; 18 19 /** 20 * Language of the page as a whole. The search field is authoritative 21 * because it is what the search and autocomplete calls are sent with; the 22 * root page offers all three languages but searches Slovenian. 23 */ 24 function pageLanguage() { 25 const field = document.querySelector('input[name="q"]'); 26 return (field && field.dataset.lang) || document.documentElement.lang || 'sl'; 27 } 28 29 /** 30 * Send an event. `site_language` rather than `language`, which GA4 already 31 * collects automatically for the browser's locale. 32 */ 33 function track(name, params) { 34 if (typeof window.gtag !== 'function') return; 35 window.gtag('event', name, Object.assign({site_language: pageLanguage()}, params)); 36 } 37 38 /** 39 * Map of URL prefix to language, from the data attribute the server 40 * renders out of config.URL_PATHS ({"sl": "/kontekst", ...}). 41 */ 42 function languageByPath() { 43 try { 44 const paths = JSON.parse(document.body.dataset.urlPaths || '{}'); 45 return Object.fromEntries( 46 Object.entries(paths).map(([language, path]) => [path, language]) 47 ); 48 } catch (err) { 49 return {}; 50 } 51 } 52 53 /** 54 * Split an internal href into its language and term. The root page links to 55 * all three languages, so neither can be assumed from the current page. 56 */ 57 function hrefTarget(href) { 58 const path = new URL(href, window.location.origin).pathname.replace(/\/$/, ''); 59 const languages = languageByPath(); 60 const prefix = Object.keys(languages).find( 61 (p) => path === p || path.startsWith(p + '/') 62 ); 63 if (!prefix) return {}; 64 return { 65 language: languages[prefix], 66 term: decodeURIComponent(path.slice(prefix.length + 1)).replace(/_/g, ' '), 67 }; 68 } 69 70 /** 71 * The term of the page currently open, from its URL. 72 * 73 * `displayedTerm()` reads the rendered headword, which is correct for the 74 * other events and wrong for anything that can fire while the Serbian page 75 * is showing Cyrillic. The path is the key, always Latin, and `hrefTarget` 76 * already knows how to split it. 77 */ 78 function urlTerm() { 79 return hrefTarget(window.location.href).term || undefined; 80 } 81 82 /** The term whose results are on screen; main.js keeps this in sync. */ 83 function displayedTerm() { 84 const title = document.querySelector('.first_title'); 85 return title ? title.textContent.trim() : undefined; 86 } 87 88 /** Similarity percentage shown on a result row, as a number. */ 89 function rowSimilarity(row) { 90 const cell = row && row.querySelector('.variable_freq'); 91 const value = cell ? parseFloat(cell.textContent) : NaN; 92 return isNaN(value) ? undefined : value; 93 } 94 95 /** One-based rank of a result row within the list it belongs to. */ 96 function rowPosition(row) { 97 const list = row && row.closest('#similar-terms-list'); 98 if (!list) return undefined; 99 return Array.prototype.indexOf.call(list.querySelectorAll('.simresult'), row) + 1; 100 } 101 102 /** 103 * Clicking a similar term. Rank and similarity are recorded alongside the 104 * term because they are the question a similarity engine actually asks: 105 * whether people take the top suggestion or read down the list. 106 */ 107 function handleSimilarTermClick(link) {
108 const row = link.closest('.simresult'); 109 track('select_similar_term', { 110 term: link.textContent.trim(), 111 from_term: displayedTerm(), 112 position: rowPosition(row), 113 similarity: rowSimilarity(row), 114 /* Which of the two lists the row came from. The recovered ones sit 115 below the shipped forty and score under the fortieth by 116 construction, so they are the least likely rows on the page to be 117 clicked â and whether they are clicked at all is the only evidence 118 that reverse search was worth doing. `list` rather than a separate 119 event so the two are comparable in one report. */ 120 list: row && row.closest('.rsims-list') ? 'extended' : 'primary', 121 /* Whether the row carries the `ne-` mark. It fires on about one page 122 in a hundred, so this will be a rounding error in the totals â but 123 it is the only way to find out whether a marked row is clicked 124 *less*, which is what the mark is for. */ 125 flagged_antonym: !!(row && row.querySelector('[data-antonym-of]')), 126 }); 127 } 128 129 /** 130 * Clicking one of the suggested terms on a start page, or one of the 131 * language headings on the root page. Both use .nohitlink; a link with no 132 * term behind the prefix is a language choice. 133 */ 134 function handleStartLinkClick(link) { 135 const target = hrefTarget(link.href); 136 if (!target.language) return; 137 if (target.term) { 138 track('select_start_term', { 139 term: target.term, 140 site_language: target.language, 141 }); 142 } else { 143 track('select_language', {site_language: target.language}); 144 } 145 } 146 147 /** 148 * Opening the paradigm under the headword. 149 * 150 * The one number that says whether the inflection block was worth building: 151 * the grid ships closed on 1.18M pages across the three languages, so this 152 * event is the only evidence that anybody wants it. 153 * 154 * Fires on expansion and not on collapse â the question is whether a reader 155 * wanted the paradigm, and closing it again is the same answer as opening it. 156 * So the count is openings, not toggles. 157 * 158 * Three dimensions, each of which could change what ships: 159 * 160 * * `kind` â a declension and a conjugation reach different terms and may 161 * deserve different defaults. 162 * * `headword` â whether the term heads its own grid. Two keys in three are 163 * *forms*, where the block answers "what is this a form of" rather than 164 * "how does this decline", and those are different needs that would want 165 * different presentations. 166 * * `site_language`, added by `track` â the grid is 6x3 in Slovenian and 7x2 167 * in Croatian and Serbian, so open rates are not comparable across the 168 * three without it. 169 * 170 * **There is no denominator in this event**, and that is worth knowing before 171 * reading the number: GA4 counts page views but not "page views of a page 172 * that had a paradigm", and roughly half of all keys have none. An open rate 173 * has to be computed against the per-language coverage in 174 * docs/ENRICHMENT.md §8 rather than against total page views. 175 */ 176 function handleParadigmToggle(button) { 177 if (button.getAttribute('aria-expanded') === 'true') return; 178 track('view_paradigm', { 179 kind: button.dataset.inflectionKind, 180 headword: button.dataset.inflectionHeadword, 181 from_term: displayedTerm(), 182 }); 183 } 184 185 /** 186 * Clicking an inflected form, or the lemma the headword resolves to. 187 * 188 * Kept off `select_similar_term` deliberately. These links carry 189 * `a.dictentry` too â the class is what gives this site its dotted 190 * underline â but they are not result rows: they have no position and no 191 * similarity, so counting them there would dilute the one metric that 192 * measures the results list with clicks that mean something else. 193 */ 194 function handleInflectionLinkClick(link) { 195 track('select_inflected_form', { 196 term: hrefTarget(link.href).term, 197 from_term: displayedTerm(), 198 relation: link.hasAttribute('data-inflection-lemma') ? 'lemma' : 'form', 199 }); 200 } 201 202 /** 203 * Flipping the Serbian Latin/Cyrillic display switch. 204 * 205 * Serbian only: base.html includes the control behind `lang == "sr"`, so 206 * this cannot fire on the other two verticals and needs no language guard. 207 * 208 * **`to` is the inverse of what the DOM says, and that is not a bug.** This 209 * handler is delegated from `document` in the CAPTURE phase, while 210 * sr_script.js listens on the button itself and therefore runs at the 211 * target phase -- after this one. So when this reads the control, nothing 212 * has flipped yet: `aria-checked` is still the OLD state, and the script 213 * the visitor is asking for is the other one. Reading it un-inverted would 214 * report every switch backwards, and the totals would still look 215 * plausible, which is the reason this is pinned by a test rather than left 216 * to the comment. 217 * 218 * Both directions are recorded on purpose. A switch to Cyrillic says the 219 * Latin default is wrong for that reader; a switch back says they tried it 220 * and preferred Latin. Only the pair separates "wants Cyrillic" from "was 221 * curious once", and a count of openings alone -- the shape `view_paradigm` 222 * uses -- would conflate them. 223 * 224 * A restored preference is deliberately NOT counted. sr_script.js calls 225 * `apply()` on load for a visitor whose choice is in localStorage, which is 226 * not a click and reaches no listener here. That leaves returning Cyrillic 227 * readers invisible in this event;
227 a GA4 user property would be the way to 228 * see them, and it is not this. 229 */ 230 function handleScriptToggle(button) { 231 track('switch_script', { 232 to: button.getAttribute('aria-checked') === 'true' ? 'latin' : 'cyrillic', 233 /* From the URL, NOT displayedTerm(). sr_script.js rewrites the text 234 nodes in place, so on a Cyrillic page `.first_title` reads 235 `магнезиÑ` while the key, the URL and every row in `serbian` say 236 `magnezit`. Reporting the rendered text would file one page under 237 two terms, and only for the readers who switched -- so the split 238 would look like two genuinely different pages. The URL is never 239 transliterated, by design: the engine touches text nodes and 240 never an href. */ 241 from_term: urlTerm(), 242 }); 243 } 244 245 /** 246 * Clicking a link an empty term page offered (app/lookup.py): a spelling 247 * with diacritics restored (`spelling`), the key in another language 248 * (`language`), another form of the word (`forms`), one word of a phrase 249 * (`words`), or a phrase sharing one of its words (`phrases`). `kind` says 250 * which, so they can be judged apart; the count against `no_results` is how 251 * often a dead end stopped being one. 252 */ 253 function handleSuggestionClick(link) { 254 const target = hrefTarget(link.href); 255 track('select_no_results_suggestion', { 256 kind: link.dataset.suggestion, 257 term: target.term, 258 to_language: target.language, 259 from_term: urlTerm(), 260 }); 261 } 262 263 /** 264 * Clicking a word in the word family (partials/families.html). 265 * 266 * `pos` is the relative's word class, or `lemma` for the headword's own lemma 267 * in the heading of an inflected form's page. Whether readers want the verb of 268 * a noun more than its adjective is the question that decides how the block 269 * should be ordered. The denominator is the pages that have a block, which is 270 * the key set of `families_sl`, not the page views. 271 */ 272 function handleFamilyLinkClick(link) { 273 track('select_word_family', { 274 term: hrefTarget(link.href).term, 275 from_term: displayedTerm(), 276 pos: link.dataset.familyPos, 277 }); 278 } 279 280 function handleClick(event) { 281 const target = event.target; 282 if (!target || typeof target.closest !== 'function') return; 283 284 /* Its own branch rather than a shared one: the switch is a <button> 285 and matches none of the link selectors below, so the order here is
286 for a reader and not for correctness. */ 287 const scriptToggle = target.closest('#script-toggle'); 288 if (scriptToggle) { 289 handleScriptToggle(scriptToggle); 290 return; 291 } 292 293 const suggestionLink = target.closest('a[data-suggestion]'); 294 if (suggestionLink) { 295 handleSuggestionClick(suggestionLink); 296 return; 297 } 298 299 const paradigmToggle = target.closest('[data-inflection-toggle]'); 300 if (paradigmToggle) { 301 handleParadigmToggle(paradigmToggle); 302 return; 303 } 304 305 /* Before the `a.dictentry` branch below, which would otherwise claim 306 these as result rows. */ 307 const inflectionLink = target.closest( 308 'a[data-inflection-form], a[data-inflection-lemma]' 309 ); 310 if (inflectionLink) { 311 handleInflectionLinkClick(inflectionLink); 312 return; 313 } 314 315 /* Word-family links carry no `dictentry` and would reach no branch; 316 their own event keeps them out of the results-list metric. */ 317 const familyLink = target.closest('a[data-family-pos]'); 318 if (familyLink) { 319 handleFamilyLinkClick(familyLink); 320 return; 321 } 322 323 const suggestion = target.closest('#suggestions a'); 324 if (suggestion) { 325 track('search', { 326 search_term: suggestion.textContent.trim(), 327 method: 'autocomplete', 328 }); 329 return; 330 } 331 332 const similarTerm = target.closest('a.dictentry'); 333 if (similarTerm) { 334 handleSimilarTermClick(similarTerm); 335 return; 336 } 337 338 const blockLink = target.closest(BLOCK_LINKS); 339 if (blockLink) { 340 handleBlockLinkClick(blockLink); 341 return; 342 } 343 344 const startLink = target.closest('a.nohitlink'); 345 if (startLink) handleStartLinkClick(startLink); 346 } 347 348 /* The page's other blocks, untracked until 2026-09-29: whether anyone uses 349 the collocations, the two Wikipedia blocks, the see-also cards, the 350 external dictionary beside each row and the Graf/Entitete links decides 351 whether they keep their space on the page. None carries `dictentry`, so 352 none is counted as a result row. `from_term` is the URL's term: the 353 collocation and Wikipedia blocks are swapped out-of-band with the 354 examples (partials/examples.html), so they always belong to it. */ 355 const BLOCK_LINKS = [ 356 'a.coll-term', 'a.wiki-term', 'a.wiki-parent-link', 'a.see-also-link', 357 'a.dicttool_link', 'a.nav-graf', 358 ].join(', '); 359 360 function handleBlockLinkClick(link) { 361 const text = link.textContent.trim(); 362 if (link.matches('a.coll-term')) { 363 const all = Array.from(link.closest('#collocations') 364 ?.querySelectorAll('a.coll-term') || []); 365 track('select_collocation', { 366 term: text, from_term: urlTerm(), position: all.indexOf(link) + 1, 367 }); 368 } else if (link.matches('a.wiki-term')) { 369 track('select_wiki_term', { 370 term: text, from_term: urlTerm(), 371 block: link.closest('.wiki-taxonomy') ? 'taxonomy' : 'neighbours', 372 }); 373 } else if (link.matches('a.wiki-parent-link')) { 374 track('open_wiki_category', {target: text, from_term: urlTerm()}); 375 } else if (link.matches('a.see-also-link')) { 376 track('select_see_also', {kind: link.dataset.seeAlso, from_term: urlTerm()}); 377 } else if (link.matches('a.dicttool_link')) { 378 const row = link.closest('.simresult'); 379 const entry = row && row.querySelector('a.dictentry'); 380 track('open_dictionary_tool', { 381 tool: text, term: entry ? entry.textContent.trim() : undefined, 382 from_term: urlTerm(), 383 }); 384 } else { 385 track('select_nav', { 386 target: link.classList.contains('nav-entitete') ? 'entitete' : 'graf', 387 from_term: urlTerm(), 388 }); 389 } 390 } 391 392 function handleKeydown(event) { 393 if (event.key !== 'Enter') return; 394 const field = event.target.closest && event.target.closest('input[name="q"]'); 395 if (!field) return; 396 const term = field.value.trim(); 397 if (term) track('search', {search_term: term, method: 'keyboard'}); 398 } 399 400 /** 401 * A search that found nothing. The legacy app left this branch untracked, 402 * so the terms visitors look for and the dictionary lacks were invisible. 403 */ 404 function trackMissingResults() { 405 const empty = document.querySelector('[data-no-results]'); 406 if (empty) track('no_results', {search_term: empty.dataset.term || ''}); 407 } 408 409 /** 410 * The related-terms list came into view: its first row at least half on 411 * screen, once per page load. 412 * 413 * The denominator `select_similar_term` never had. A visitor who did not 414 * click may not have scrolled that far -- on a phone the list starts well 415 * below the headword -- and without this the two read the same. The click
416 * rate that means something is clicks over page loads that fired this. 417 * Not re-sent after an HTMX swap: the visitor who swapped has already seen 418 * a list and clicked in it. 419 */ 420 function trackListSeen() { 421 const list = document.getElementById('similar-terms-list'); 422 const first = list && list.querySelector('.simresult'); 423 if (!first || typeof IntersectionObserver !== 'function') return; 424 const observer = new IntersectionObserver((entries) => { 425 if (!entries.some((entry) => entry.isIntersecting)) return; 426 observer.disconnect(); 427 track('view_similar_terms', { 428 from_term: urlTerm(), 429 rows: list.querySelectorAll('.simresult').length, 430 seconds: Math.round(performance.now() / 1000), 431 }); 432 }, {threshold: 0.5}); 433 observer.observe(first); 434 } 435 436 function init() { 437 document.addEventListener('click', handleClick, true); 438 document.addEventListener('keydown', handleKeydown, true); 439 trackMissingResults(); 440 trackListSeen(); 441 } 442 443 if (document.readyState === 'loading') { 444 document.addEventListener('DOMContentLoaded', init); 445 } else { 446 init(); 447 } 448})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.