PageSourceSearch

https://www.kontekst.io/static/js/analytics.js?v=57da6cd4

js kontekst.io collected 2026-10-02 19:52:28 UTC 19,256 bytes, 448 lines download raw bytes

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.