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.