PageSourceSearch

https://entrypolicies.com/static/js/search.js

js entrypolicies.com collected 2026-10-02 00:48:47 UTC 13,578 bytes, 314 lines download raw bytes

1// search.js — Zero-latency client-side search for countries + route queries ("A to B")
2(function () {
3    'use strict';
4
5    var searchIndex = null;
6    var byCode = {};       // ISO3 -> index item
7    var currentRoute = null; // last detected origin→destination route
8
9    // Common short names / demonyms → ISO3 codes (as present in the index)
10    var ALIASES = {
11        'us': 'USA', 'usa': 'USA', "u.s.": 'USA', 'u s': 'USA',
12        'america': 'USA', 'american': 'USA', 'americans': 'USA',
13        'uk': 'GBR', 'u.k.': 'GBR', 'britain': 'GBR', 'british': 'GBR',
14        'england': 'GBR', 'scotland': 'GBR', 'wales': 'GBR',
15        'uae': 'ARE', 'emirates': 'ARE', 'emirati': 'ARE',
16        'korea': 'KOR',
17        'canadian': 'CAN', 'canadians': 'CAN',
18        'japanese': 'JPN', 'chinese': 'CHN', 'mexican': 'MEX', 'mexicans': 'MEX',
19        'brazilian': 'BRA', 'brazilians': 'BRA', 'indian': 'IND', 'indians': 'IND',
20        'german': 'DEU', 'germans': 'DEU', 'french': 'FRA', 'italian': 'ITA', 'italians': 'ITA',
21        'spanish': 'ESP', 'australian': 'AUS', 'australians': 'AUS', 'irish': 'IRL',
22        'dutch': 'NLD', 'belgian': 'BEL', 'belgians': 'BEL', 'swiss': 'CHE',
23        'swedish': 'SWE', 'norwegian': 'NOR', 'danish': 'DNK', 'finnish': 'FIN',
24        'polish': 'POL', 'portuguese': 'PRT', 'greek': 'GRC', 'turkish': 'TUR',
25        'russian': 'RUS', 'russians': 'RUS', 'ukrainian': 'UKR', 'ukrainians': 'UKR',
26        'argentinian': 'ARG', 'argentine': 'ARG', 'chilean': 'CHL', 'colombian': 'COL',
27        'peruvian': 'PER', 'thai': 'THA', 'vietnamese': 'VNM', 'filipino': 'PHL',
28        'filipinos': 'PHL', 'indonesian': 'IDN', 'malaysian': 'MYS', 'singaporean': 'SGP',
29        'south african': 'ZAF', 'egyptian': 'EGY', 'egyptians': 'EGY', 'nigerian': 'NGA',
30        'nigerians': 'NGA', 'kenyan': 'KEN', 'israeli': 'ISR', 'israelis': 'ISR',
31        'saudi': 'SAU', 'taiwanese': 'TWN', 'hong kong': 'HKG', 'philippine': 'PHL'
32    };
33
34    async function loadIndex() {
35        if (searchIndex) return searchIndex;
36        try {
37            var resp = await fetch('/search-index.json');
38            if (!resp.ok) throw new Error('Failed to load search index');
39            searchIndex = await resp.json();
40            byCode = {};
41            for (var i = 0; i < searchIndex.length; i++) {
42                byCode[searchIndex[i].code] = searchIndex[i];
43            }
44            return searchIndex;
45        } catch (_) {
46            return [];
47        }
48    }
49
50    function search(query, limit) {
51        limit = limit || 12;
52        currentRoute = null;
53        if (!searchIndex || !query || query.trim().length < 2) return [];
54        var q = query.toLowerCase().trim();
55
56        // Try to interpret the query as an origin → destination route
57        var route = detectRoute(q);
58        if (route && route.origin.code !== route.dest.code) {
59            currentRoute = route;
60        }
61
62        var results = [];
63        for (var i = 0; i < searchIndex.length; i++) {
64            var item = searchIndex[i];
65            var score = 0;
66            // Exact country name match (highest priority)
67            if (item.name && item.name.toLowerCase().indexOf(q) === 0) score += 25;
68            if (item.name && item.name.toLowerCase().indexOf(q) !== -1) score 
68+= 20;
69            // Word boundary match in name
70            if (item.name) {
71                var words = item.name.toLowerCase().split(/\s+/);
72                for (var w = 0; w < words.length; w++) {
73                    if (words[w].indexOf(q) === 0) score += 10;
74                    if (words[w].indexOf(q) !== -1) score += 5;
75                }
76            }
77            // ISO code match
78            if (item.code && item.code.toLowerCase().indexOf(q) !== -1) score += 8;
79            // Alias / demonym match (e.g. "us" → United States, "japanese" → Japan)
80            if (ALIASES[q] === item.code) score += 30;
81            // Description match
82            if (item.description && item.description.toLowerCase().indexOf(q) !== -1) score += 3;
83            if (score > 0) {
84                results.push({ item: item, score: score });
85            }
86        }
87        results.sort(function (a, b) { return b.score - a.score; });
88        return results.slice(0, limit);
89    }
90
91    // Strip noise words from an origin/destination fragment before resolving it
92    var NOISE = ['visa', 'evisa', 'need', 'needs', 'require', 'requires', 'do', 'i', 'a', 'an',
93        'the', 'my', 'for', 'to', 'from', 'travel', 'travelling', 'traveling', 'travelers',
94        'travellers', 'going', 'fly', 'flight', 'flights', 'citizen', 'citizens', 'passport',
95        'passports', 'holder', 'holders', 'national', 'nationals', 'people', 'want', 'wanna',
96        'can', 'go', 'get', 'getting', 'apply', 'application', 'check', 'requirements', 'entry',
97        'visit', 'visiting', 'destination', 'originating', 'flying', 'entering'];
98
99    function findCountryInText(part) {
100        if (!part) return null;
101        var text = part.toLowerCase().replace(/[.,;:!?]/g, ' ').trim();
102
103        // 1) Longest full country-name substring (handles "united states", "south korea", multi-word)
104        var best = null, bestLen = 0;
105        for (var i = 0; i < searchIndex.length; i++) {
106            var n = searchIndex[i].name.toLowerCase();
107            if (text.indexOf(n) !== -1 && n.length > bestLen) {
108                best = searchIndex[i];
109                bestLen = n.length;
110            }
111        }
112        if (best) return best;
113
114        // 2) Single-token alias / demonym match
115        var tokens = text.split(/\s+/);
116        for (var t = 0; t < tokens.length; t++) {
117            var code = ALIASES[tokens[t]];
118            if (code && byCode[code]) return byCode[code];
119        }
120
121        // 3) Single token that starts a country name
122        for (var t2 = 0; t2 < tokens.length; t2++) {
123            var tok = tokens[t2];
124            if (tok.length < 3) continue;
125            for (var i2 = 0; i2 < searchIndex.length; i2++) {
126                var nm = searchIndex[i2].name.toLowerCase();
127                if (nm === tok || nm.indexOf(tok) === 0) return searchIndex[i2];
128            }
129        }
130        return null;
131    }
132
133    function cleanPart(part) {
134        if (!part) return '';
135        var words = part.toLowerCase().replace(/[.,;:!?]/g, ' ').trim().split(/\s+/);
136        var kept = [];
137        for (var i = 0; i < words.length; i++) {
138            if (NOISE.indexOf(words[i]) === -1) kept.push(words[i]);
139        }
140        return kept.join(' ');
141    }
142
143    function detectRoute(q) {
144        if (q.length < 5) return null;
145        var m, originText, destText, origin, dest;
146
147        // Pattern 1: "X to Y" (also "X citizens to Y")
148        m = q.match(/^(.+?)\s+to\s+(.+)$/);
149        if (m) {
150            originText = cleanPart(m[1]);
151            destText = cleanPart(m[2]);
152            origin = findCountryInText(originText);
153            dest = findCountryInText(destText);
154            if (origin && dest && origin.code !== dest.code) return { origin: origin, dest: dest };
155        }
156
157        // Pattern 2: "Y from X" (e.g. "japan from us", "visa for japan from canada")
158        m = q.match(/^(.+?)\s+from\s+(.+)$/);
159        if (m) {
160            destText = cleanPart(m[1]);
161            originText = cleanPart(m[2]);
162            origin = findCountryInText(originText);
163            dest = findCountryInText(destText);
164            if (origin && dest && origin.code !== dest.code) return { origin: origin, dest: dest };
165        }
166
167        // Pattern 3: "do X citizens need a visa for Y"
168        m = q.match(/^do\s+(.+?)\s+need\s+(?:a\s+)?visa\s+for\s+(.+)$/);
169        if (m) {
170            originText = cleanPart(m[1]);
171            destText = cleanPart(m[2]);
172            origin = findCountryInText(originText);
173            dest = findCountryInText(destText);
174            if (origin && dest && origin.code !== dest.code) return { origin: origin, dest: dest };
175        }
176
177        // Pattern 4: "X need/require visa for Y"
178        m = q.match(/^(.+?)\s+(?:need|needs|require|requires)\s+(?:a\s+)?(?:visa|evisa)\s+for\s+(.+)$/);
179        if (m) {
180            originText = cleanPart(m[1]);
181            destText = cleanPart(m[2]);
182            origin = findCountryInText(originText);
183            dest = findCountryInText(destText);
184            if (origin && dest && origin.code !== dest.code) return { origin: origin, dest: dest };
185        }
186
187        // Pattern 5: "X visa for Y" (X = destination, Y = nationality)
188        m = q.match(/^(.+?)\s+visa\s+for\s+(.+)$/);
189        if (m) {
190            destText = cleanPart(m[1]);
191            originText = cleanPart(m[2]);
192            origin = findCountryInText(originText);
193            dest = findCountryInText(destText);
194            if (origin && dest && origin.code !== dest.code) return { origin: origin, dest: dest };
195        }
196
197        return null;
198    }
199
200    function buildHtml(results) {
201        var html = '';
202
203        // Route "A → B" card first, so users land directly on the right visa row
204        if (currentRoute) {
205            var r = currentRoute;
206            html += '<a class="search-route" href="' + r.dest.entry_url + '#passport-' + r.origin.slug + '">';
207            html += '<div class="result-title">' + escapeHtml(r.origin.name) + ' \u2192 ' + escapeHtml(r.dest.name) + ' — Visa Check</div>';
208            html += '<div class="result-meta">' + escapeHtml(r.origin.name) + ' passport to ' + escapeHtml(r.dest.name) + ' · Jump to the matching visa row</div>';
209            html += '</a>';
210        }
211
212        if (results.length === 0) {
213            return html + '<div class="search-no-results">No matching countries found. Try a country name or a route like "US to Japan".</div>';
214        }
215
216        for (var i = 0; i < results.length; i++) {
217            var item = results[i].item;
218            html += '<a href="' + item.entry_url + '">';
219            html += '<div class="result-title">' + escapeHtml(item.name) + ' — Entry Requirements</div>';
220            html += '<div class="result-meta">View visa requirements for all passports · ' + (item.passport_count || 0) + ' passports indexed</div>';
221            html += '</a>';
222            html += '<a href="' + item.passport_url + '">';
223            html += '<div class="result-title">' + escapeHtml(item.name) + ' — Passport Power</div>';
224            html += '<div class="result-meta">' + (item.visa_free_count || 0) + ' visa-free destinations · Passport mobility index</div>';
225            html += '</a>';
226        }
227        return html;
228    }
229
230    function escapeHtml(str) {
231        var div = document.createElement('div');
232        div.textContent = str || '';
233        return div.innerHTML;
234    }
235
236    // When arriving on a destination page with #passport-{slug}, reveal + highlight that row
237    function handlePassportHash() {
238        var hash = window.location.hash;
239        if (!hash || hash.indexOf('#passport-') !== 0) return;
240        var slug = hash.substring('#passport-'.length);
241        var tbody = document.getElementById('entry-tbody');
242        if (!tbody) return;
243        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.hts-row'));
244        var idx = -1;
245        for (var i = 0; i < rows.length; i++) {
246            if (rows[i].id === 'passport-' + slug) { idx = i; break; }
247        }
248        if (idx === -1) return;
249
250        // Table is paginated (>20 rows): switch to the page containing this row
251        if (rows.length > 20 && typeof window._epPage === 'function') {
252            window._epPage(Math.floor(idx / 20) + 1);
253        }
254
255        var row = document.getElementById('passport-' + slug);
256        if (!row) return;
257        row.classList.add('ep-highlight');
258        setTimeout(function () {
259            try { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
260            catch (_) { row.scrollIntoView(); }
261        }, 80);
262        setTimeout(function () { row.classList.remove('ep-highlight'); }, 3200);
263    }
264
265    function setupSearch(inputId, resultsId) {
266        var input = document.getElementById(inputId);
267        var resultsEl = document.getElementById(resultsId);
268        if (!input || !resultsEl) return;
269
270        var timer;
271        input.addEventListener('input', function () {
272            clearTimeout(timer);
273            var val = this.value.trim();
274            if (val.length < 2) {
275                resultsEl.classList.remove('active');
276                resultsEl.innerHTML = '';
277                return;
278            }
279            timer = setTimeout(function () {
280                var results = search(val);
281                resultsEl.innerHTML = buildHtml(results);
282                resultsEl.classList.add('active');
283            }, 100);
284        });
285
286        document.addEventListener('click', function (e) {
287            if (!input.contains(e.target) && !resultsEl.contains(e.target)) {
288                resultsEl.classList.remove('active');
289            }
290        });
291
292        input.addEventListener('keydown', function (e) {
293            if (e.key === 'Escape') {
294                resultsEl.classList.remove('active');
295                input.blur();
296            } else if (e.key === 'Enter') {
297                var link = resultsEl.querySelector('a');
298                if (link) window.location.href = link.getAttribute('href');
299            }
300        });
301    }
302
303    async function init() {
304        await loadIndex();
305        setupSearch('site-search', 'search-results');
306        handlePassportHash();
307    }
308
309    if (document.readyState === 'loading') {
310        document.addEventListener('DOMContentLoaded', init);
311    } else {
312        init();
313    }
314})();

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.