A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bang"; 2import "./global.css"; 3 4const CONSTANTS = { 5 MAX_HISTORY: 500, 6 ANIMATION_DURATION: 375, 7 LOCAL_STORAGE_KEYS: { 8 SEARCH_HISTORY: "search-history", 9 SEARCH_COUNT: "search-count", 10 HISTORY_ENABLED: "history-enabled", 11 DEFAULT_BANG: "default-bang", 12 }, 13}; 14 15const storage = { 16 get: (key: string) => localStorage.getItem(key), 17 set: (key: string, value: string) => localStorage.setItem(key, value), 18 remove: (key: string) => localStorage.removeItem(key), 19}; 20 21const memoizedGetSearchHistory = (() => { 22 let cache: Array<{ 23 query: string; 24 bang: string; 25 name: string; 26 timestamp: number; 27 }> | null = null; 28 return () => { 29 if (!cache) { 30 cache = JSON.parse( 31 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]", 32 ); 33 } 34 return cache; 35 }; 36})(); 37 38function addToSearchHistory( 39 query: string, 40 bang: { bang: string; name: string; url: string }, 41) { 42 const history = memoizedGetSearchHistory(); 43 if (!history) return; 44 45 history.unshift({ 46 query, 47 bang: bang.bang, 48 name: bang.name, 49 timestamp: Date.now(), 50 }); 51 history.splice(CONSTANTS.MAX_HISTORY); 52 storage.set( 53 CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY, 54 JSON.stringify(history), 55 ); 56} 57 58function getSearchHistory(): Array<{ 59 query: string; 60 bang: string; 61 name: string; 62 timestamp: number; 63}> { 64 try { 65 return JSON.parse( 66 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]", 67 ); 68 } catch { 69 return []; 70 } 71} 72 73function clearSearchHistory() { 74 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY, "[]"); 75} 76 77function getFocusableElements( 78 root: HTMLElement = document.body, 79): HTMLElement[] { 80 return Array.from( 81 root.querySelectorAll<HTMLElement>( 82 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', 83 ), 84 ); 85} 86 87function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) { 88 const modalElements = getFocusableElements(modal); 89 const allElements = getFocusableElements(); 90 91 for (const element of allElements) { 92 if (!modalElements.includes(element)) { 93 element.setAttribute("tabindex", tabindex.toString()); 94 } 95 } 96} 97 98const createTemplate = (data: { 99 searchCount: string; 100 historyEnabled: boolean; 101 searchHistory: Array<{ 102 bang: string; 103 query: string; 104 name: string; 105 timestamp: number; 106 }>; 107 LS_DEFAULT_BANG: string; 108}) => ` 109 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 110 <header style="position: absolute; top: 1rem; width: 100%;"> 111 <div style="display: flex; justify-content: space-between; padding: 0 1rem;"> 112 <span>${data.searchCount} ${data.searchCount === "1" ? "search" : "searches"}</span> 113 <button class="settings-button"> 114 <img src="/gear.svg" alt="Settings" class="settings" /> 115 </button> 116 </div> 117 </header> 118 <div class="content-container"> 119 <h1 id="cutie">┐( ˘_˘ )┌</h1> 120 <p>DuckDuckGo's bang redirects are too slow. Add the following URL as a custom search engine to your browser. Enables <a href="https://duckduckgo.com/bang.html" target="_blank">all of DuckDuckGo's bangs.</a></p> 121 <div class="url-container"> 122 <input 123 type="text" 124 class="url-input" 125 value="https://unduck.link?q=%s" 126 readonly 127 /> 128 <button class="copy-button"> 129 <img src="/clipboard.svg" alt="Copy" /> 130 </button> 131 </div> 132 ${ 133 data.historyEnabled 134 ? ` 135 <h2 style="margin-top: 24px;">Recent Searches</h2> 136 <div style="max-height: 200px; overflow-y: auto; text-align: left;"> 137 ${ 138 data.searchHistory.length === 0 139 ? `<div style="padding: 8px; text-align: center;">No search history</div>` 140 : data.searchHistory 141 .map( 142 (search) => ` 143 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);"> 144 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a> 145 <span style="float: right; color: var(--text-color-secondary);"> 146 ${new Date(search.timestamp).toLocaleString()} 147 </span> 148 </div> 149 `, 150 ) 151 .join("") 152 } 153 </div> 154 ` 155 : "" 156 } 157 </div> 158 <footer class="footer"> 159 made with ♥ by <a href="https://github.com/taciturnaxolotl" target="_blank">Kieran Klukas</a> as <a href="https://github.com/taciturnaxolotl/unduck" target="_blank">open source</a> software 160 </footer> 161 <div class="modal" id="settings-modal"> 162 <div class="modal-content"> 163 <button class="close-modal">&times;</button> 164 <h2>Settings</h2> 165 <div class="settings-section"> 166 <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === data.LS_DEFAULT_BANG)?.s || "Unknown bang"}</label> 167 <div class="bang-select-container"> 168 <input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}"> 169 </div> 170 </div> 171 <div class="settings-section"> 172 <h3>Search History (${data.searchHistory.length}/500)</h3> 173 <div style="display: flex; justify-content: space-between; align-items: center;"> 174 <label class="switch"> 175 <label for="history-toggle">Enable Search History</label> 176 <input type="checkbox" id="history-toggle" ${data.historyEnabled ? "checked" : ""}> 177 <span class="slider round"></span> 178 </label> 179 <button class="clear-history">Clear History</button> 180 </div> 181 </div> 182 </div> 183 </div> 184 </div> 185 </div> 186`; 187 188const createAudio = (src: string) => { 189 const audio = new Audio(); 190 audio.src = src; 191 return audio; 192}; 193 194function noSearchDefaultPageRender() { 195 const searchCount = 196 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0"; 197 const historyEnabled = 198 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true"; 199 const searchHistory = getSearchHistory(); 200 const app = document.querySelector<HTMLDivElement>("#app"); 201 if (!app) throw new Error("App element not found"); 202 203 app.innerHTML = createTemplate({ 204 searchCount, 205 historyEnabled, 206 searchHistory, 207 LS_DEFAULT_BANG, 208 }); 209 210 const elements = { 211 app, 212 cutie: app.querySelector<HTMLHeadingElement>("#cutie"), 213 copyInput: app.querySelector<HTMLInputElement>(".url-input"), 214 copyButton: app.querySelector<HTMLButtonElement>(".copy-button"), 215 copyIcon: app.querySelector<HTMLImageElement>(".copy-button img"), 216 urlInput: app.querySelector<HTMLInputElement>(".url-input"), 217 settingsButton: app.querySelector<HTMLButtonElement>(".settings-button"), 218 modal: app.querySelector<HTMLDivElement>("#settings-modal"), 219 closeModal: app.querySelector<HTMLSpanElement>(".close-modal"), 220 defaultBangSelect: app.querySelector<HTMLSelectElement>("#default-bang"), 221 description: app.querySelector<HTMLParagraphElement>("#bang-description"), 222 historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"), 223 clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"), 224 } as const; 225 226 // Validate all elements exist 227 for (const [key, element] of Object.entries(elements)) { 228 if (!element) throw new Error(`${key} not found`); 229 } 230 231 // After validation, we can assert elements are non-null 232 const validatedElements = elements as { 233 [K in keyof typeof elements]: NonNullable<(typeof elements)[K]>; 234 }; 235 236 validatedElements.urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`; 237 238 const prefersReducedMotion = window.matchMedia( 239 "(prefers-reduced-motion: reduce)", 240 ).matches; 241 242 if (!prefersReducedMotion) { 243 // Add mouse tracking behavior 244 document.addEventListener("click", (e) => { 245 const x = e.clientX; 246 const y = e.clientY; 247 const centerX = window.innerWidth / 2; 248 const centerY = window.innerHeight / 2; 249 const differenceX = x - centerX; 250 const differenceY = y - centerY; 251 252 // Left-facing cuties 253 const leftCuties = ["╰(°□°╰)", "(◕‿◕´)", "(・ω・´)"]; 254 255 // Right-facing cuties 256 const rightCuties = ["(╯°□°)╯", "(`◕‿◕)", "(`・ω・)"]; 257 258 // Up-facing cuties 259 const upCuties = ["(↑°□°)↑", "(´◕‿◕)↑", "↑(´・ω・)↑"]; 260 261 // Down-facing cuties 262 const downCuties = ["(↓°□°)↓", "(´◕‿◕)↓", "↓(´・ω・)↓"]; 263 264 if ( 265 Math.abs(differenceX) > Math.abs(differenceY) && 266 Math.abs(differenceX) > 100 267 ) { 268 validatedElements.cutie.textContent = 269 differenceX < 0 270 ? leftCuties[Math.floor(Math.random() * leftCuties.length)] 271 : rightCuties[Math.floor(Math.random() * rightCuties.length)]; 272 } else if (Math.abs(differenceY) > 100) { 273 validatedElements.cutie.textContent = 274 differenceY < 0 275 ? upCuties[Math.floor(Math.random() * upCuties.length)] 276 : downCuties[Math.floor(Math.random() * downCuties.length)]; 277 } 278 }); 279 280 const audio = { 281 spin: createAudio("/heavier-tick-sprite.mp3"), 282 toggleOff: createAudio("/toggle-button-off.mp3"), 283 toggleOn: createAudio("/toggle-button-on.mp3"), 284 click: createAudio("/click-button.mp3"), 285 warning: createAudio("/double-button.mp3"), 286 copy: createAudio("/foot-switch.mp3"), 287 }; 288 289 validatedElements.copyButton.addEventListener("click", () => { 290 audio.copy.currentTime = 0; 291 audio.copy.play(); 292 }); 293 294 validatedElements.settingsButton.addEventListener("mouseenter", () => { 295 audio.spin.play(); 296 }); 297 298 validatedElements.settingsButton.addEventListener("mouseleave", () => { 299 audio.spin.pause(); 300 audio.spin.currentTime = 0; 301 }); 302 303 validatedElements.historyToggle.addEventListener("change", () => { 304 if (validatedElements.historyToggle.checked) { 305 audio.toggleOff.pause(); 306 audio.toggleOff.currentTime = 0; 307 audio.toggleOn.currentTime = 0; 308 audio.toggleOn.play(); 309 } else { 310 audio.toggleOn.pause(); 311 audio.toggleOn.currentTime = 0; 312 audio.toggleOff.currentTime = 0; 313 audio.toggleOff.play(); 314 } 315 }); 316 317 validatedElements.clearHistory.addEventListener("click", () => { 318 audio.warning.play(); 319 }); 320 321 validatedElements.defaultBangSelect.addEventListener("bangError", () => { 322 audio.warning.currentTime = 0; 323 audio.warning.play(); 324 }); 325 326 validatedElements.defaultBangSelect.addEventListener("bangSuccess", () => { 327 audio.click.currentTime = 0; 328 audio.click.play(); 329 }); 330 331 validatedElements.closeModal.addEventListener("closed", () => { 332 validatedElements.settingsButton.classList.remove("rotate"); 333 audio.spin.playbackRate = 0.7; 334 audio.spin.currentTime = 0; 335 audio.spin.play(); 336 audio.spin.onended = () => { 337 audio.spin.playbackRate = 1; 338 }; 339 }); 340 } 341 342 validatedElements.copyButton.addEventListener("click", async () => { 343 await navigator.clipboard.writeText(validatedElements.urlInput.value); 344 validatedElements.copyIcon.src = "/clipboard-check.svg"; 345 346 if (!prefersReducedMotion) 347 validatedElements.copyInput.classList.add("flash-white"); 348 349 setTimeout(() => { 350 validatedElements.copyInput.classList.remove("flash-white"); 351 validatedElements.copyIcon.src = "/clipboard.svg"; 352 }, 375); 353 }); 354 355 validatedElements.settingsButton.addEventListener("click", () => { 356 validatedElements.settingsButton.classList.add("rotate"); 357 validatedElements.modal.style.display = "block"; 358 setOutsideElementsTabindex(validatedElements.modal, -1); 359 }); 360 361 validatedElements.closeModal.addEventListener("click", () => { 362 validatedElements.closeModal.dispatchEvent(new Event("closed")); 363 }); 364 365 window.addEventListener("click", (event) => { 366 if (event.target === validatedElements.modal) { 367 validatedElements.closeModal.dispatchEvent(new Event("closed")); 368 } 369 }); 370 371 validatedElements.closeModal.addEventListener("closed", () => { 372 validatedElements.modal.style.display = "none"; 373 setOutsideElementsTabindex(validatedElements.modal, 0); 374 375 if (validatedElements.historyToggle.checked !== historyEnabled) 376 if (!prefersReducedMotion) 377 setTimeout(() => { 378 window.location.reload(); 379 }, 300); 380 else window.location.reload(); 381 }); 382 383 validatedElements.defaultBangSelect.addEventListener("change", (event) => { 384 const newDefaultBang = (event.target as HTMLSelectElement).value; 385 const bang = bangs.find((b) => b.t === newDefaultBang); 386 387 if (!bang) { 388 validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG; 389 validatedElements.defaultBangSelect.classList.add("shake", "flash-red"); 390 validatedElements.defaultBangSelect.dispatchEvent( 391 new CustomEvent("bangError"), 392 ); 393 setTimeout(() => { 394 validatedElements.defaultBangSelect.classList.remove( 395 "shake", 396 "flash-red", 397 ); 398 }, 300); 399 return; 400 } 401 402 validatedElements.defaultBangSelect.dispatchEvent( 403 new CustomEvent("bangSuccess"), 404 ); 405 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang); 406 validatedElements.description.innerText = bang.s; 407 }); 408 409 validatedElements.historyToggle.addEventListener("change", (event) => { 410 storage.set( 411 CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED, 412 (event.target as HTMLInputElement).checked.toString(), 413 ); 414 }); 415 416 validatedElements.clearHistory.addEventListener("click", () => { 417 clearSearchHistory(); 418 if (!prefersReducedMotion) 419 setTimeout(() => { 420 window.location.reload(); 421 }, 375); 422 else window.location.reload(); 423 }); 424} 425 426const LS_DEFAULT_BANG = 427 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG) ?? "ddg"; 428const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG); 429 430function getBangredirectUrl() { 431 const url = new URL(window.location.href); 432 const query = url.searchParams.get("q")?.trim() ?? ""; 433 if (!query) { 434 noSearchDefaultPageRender(); 435 return null; 436 } 437 438 const count = ( 439 Number.parseInt( 440 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0", 441 ) + 1 442 ).toString(); 443 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count); 444 445 const match = query.match(/^!(\S+)|!(\S+)$/i); 446 const selectedBang = match 447 ? bangs.find((b) => b.t === match[1].toLowerCase()) 448 : defaultBang; 449 const cleanQuery = match 450 ? query.replace(/!\S+\s*|^(\S+!|!\S+)$/i, "").trim() 451 : query; 452 453 if (storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true") { 454 addToSearchHistory(cleanQuery, { 455 bang: selectedBang?.t || "", 456 name: selectedBang?.s || "", 457 url: selectedBang?.u || "", 458 }); 459 } 460 461 return selectedBang?.u.replace( 462 "{{{s}}}", 463 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"), 464 ); 465} 466 467function doRedirect() { 468 const searchUrl = getBangredirectUrl(); 469 if (!searchUrl) return; 470 window.location.replace(searchUrl); 471} 472 473doRedirect();