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