A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bangs/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 CUSTOM_BANGS: "custom-bangs", 26 }, 27 CUTIES: { 28 NOTFOUND: [ 29 "(╯︵╰,)", 30 "(。•́︿•̀。)", 31 "(⊙_☉)", 32 "(╯°□°)╯︵ ┻━┻", 33 "(ಥ﹏ಥ)", 34 "(✿◕‿◕✿)", 35 "(╥﹏╥)", 36 "(。•́︿•̀。)", 37 "(✧ω✧)", 38 "(•́_•̀)", 39 "(╯°□°)╯︵ ┻━┻", 40 ], 41 LEFT: ["╰(°□°╰)", "(◕‿◕´)", "(・ω・´)"], 42 RIGHT: ["(╯°□°)╯", "(`◕‿◕)", "(`・ω・)"], 43 UP: ["(↑°□°)↑", "(´◕‿◕)↑", "↑(´・ω・)↑"], 44 DOWN: ["(↓°□°)↓", "(´◕‿◕)↓", "↓(´・ω・)↓"], 45 }, 46}; 47const customBangs: { 48 [key: string]: { 49 c?: string; 50 d: string; 51 r: number; 52 s: string; 53 sc?: string; 54 t: string; 55 u: string; 56 }; 57} = JSON.parse(localStorage.getItem("custom-bangs") || "{}"); 58 59function getFocusableElements( 60 root: HTMLElement = document.body, 61): HTMLElement[] { 62 return Array.from( 63 root.querySelectorAll<HTMLElement>( 64 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', 65 ), 66 ); 67} 68 69function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) { 70 const modalElements = getFocusableElements(modal); 71 const allElements = getFocusableElements(); 72 73 for (const element of allElements) { 74 if (!modalElements.includes(element)) { 75 element.setAttribute("tabindex", tabindex.toString()); 76 } 77 } 78} 79 80const createTemplate = (data: { 81 searchCount: string; 82 historyEnabled: boolean; 83 searchHistory: Array<{ 84 bang: string; 85 query: string; 86 name: string; 87 timestamp: number; 88 }>; 89 LS_DEFAULT_BANG: string; 90}) => ` 91 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 92 <header style="position: absolute; top: 1rem; width: 100%;"> 93 <div style="display: flex; justify-content: space-between; padding: 0 1rem;"> 94 <span>${data.searchCount} ${data.searchCount === "1" ? "search" : "searches"}</span> 95 <button class="settings-button"> 96 <img src="/gear.svg" alt="Settings" class="settings" /> 97 </button> 98 </div> 99 </header> 100 <div class="content-container"> 101 <h1 id="cutie">┐( ˘_˘ )┌</h1> 102 <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> 103 <div class="url-container"> 104 <input 105 type="text" 106 class="url-input" 107 value="https://unduck.link?q=%s" 108 readonly 109 /> 110 <button class="copy-button"> 111 <img src="/clipboard.svg" alt="Copy" /> 112 </button> 113 </div> 114 ${ 115 data.historyEnabled 116 ? ` 117 <h2 style="margin-top: 24px;">Recent Searches</h2> 118 <div style="max-height: 200px; overflow-y: auto; text-align: left;"> 119 ${ 120 data.searchHistory.length === 0 121 ? `<div style="padding: 8px; text-align: center;">No search history</div>` 122 : data.searchHistory 123 .map( 124 (search) => ` 125 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);"> 126 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a> 127 <span style="float: right; color: var(--text-color-secondary);"> 128 ${new Date(search.timestamp).toLocaleString()} 129 </span> 130 </div> 131 `, 132 ) 133 .join("") 134 } 135 </div> 136 ` 137 : "" 138 } 139 </div> 140 <footer class="footer"> 141 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 142 </footer> 143 <div class="modal" id="settings-modal"> 144 <div class="modal-content"> 145 <button class="close-modal">&times;</button> 146 <h2>Settings</h2> 147 <div class="settings-section"> 148 <h3>Bangs</h3> 149 <label for="default-bang" id="bang-description">Default Bang: ${bangs[data.LS_DEFAULT_BANG].s || "Unknown bang"}</label> 150 <div class="bang-select-container"> 151 <input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}"> 152 </div> 153 <p class="help-text">The best way to add new bangs is by submitting them on <a href="https://duckduckgo.com/newbang" target="_blank">DuckDuckGo</a> but you can also add them below</p> 154 <div style="margin-top: 16px;"> 155 <h4>Add Custom Bang</h4> 156 <div class="custom-bang-inputs"> 157 <input type="text" placeholder="Bang name" id="bang-name" class="bang-name"> 158 <input type="text" placeholder="Shortcut (e.g. !ddg)" id="bang-shortcut" class="bang-shortcut"> 159 <input type="text" placeholder="Search URL with {{{s}}}" id="bang-search-url" class="bang-search-url"> 160 <input type="text" placeholder="Base domain" id="bang-base-url" class="bang-base-url"> 161 <div style="text-align: right;"> 162 <button class="add-bang">Add Bang</button> 163 </div> 164 </div> 165 ${ 166 Object.keys(customBangs).length > 0 167 ? ` 168 <h4>Your Custom Bangs</h4> 169 <div class="custom-bangs-list"> 170 ${Object.entries(customBangs) 171 .map( 172 ([shortcut, bang]) => ` 173 <div class="custom-bang-item"> 174 <table class="custom-bang-info"> 175 <tr> 176 <td class="custom-bang-name">${bang.t}</td> 177 <td class="custom-bang-shortcut"><code>!${shortcut}</code></td> 178 <td class="custom-bang-base">${bang.d}</td> 179 </tr> 180 </table> 181 <div class="custom-bang-url">${bang.u}</div> 182 <button class="remove-bang" data-shortcut="${shortcut}">Remove</button> 183 </div> 184 `, 185 ) 186 .join("")} 187 </div> 188 ` 189 : "" 190 } 191 </div> 192 </div> 193 <div class="settings-section"> 194 <h3>Search History (${data.searchHistory.length}/500)</h3> 195 <div style="display: flex; justify-content: space-between; align-items: center;"> 196 <label class="switch"> 197 <label for="history-toggle">Enable Search History</label> 198 <input type="checkbox" id="history-toggle" ${data.historyEnabled ? "checked" : ""}> 199 <span class="slider round"></span> 200 </label> 201 <button class="clear-history">Clear History</button> 202 </div> 203 </div> 204 </div> 205 </div> 206 </div> 207 </div> 208`; 209 210function noSearchDefaultPageRender() { 211 const searchCount = 212 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0"; 213 const historyEnabled = 214 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true"; 215 const searchHistory = getSearchHistory(); 216 const app = document.querySelector<HTMLDivElement>("#app"); 217 if (!app) throw new Error("App element not found"); 218 219 app.innerHTML = createTemplate({ 220 searchCount, 221 historyEnabled, 222 searchHistory, 223 LS_DEFAULT_BANG, 224 }); 225 226 const elements = { 227 app, 228 cutie: app.querySelector<HTMLHeadingElement>("#cutie"), 229 copyInput: app.querySelector<HTMLInputElement>(".url-input"), 230 copyButton: app.querySelector<HTMLButtonElement>(".copy-button"), 231 copyIcon: app.querySelector<HTMLImageElement>(".copy-button img"), 232 urlInput: app.querySelector<HTMLInputElement>(".url-input"), 233 settingsButton: app.querySelector<HTMLButtonElement>(".settings-button"), 234 modal: app.querySelector<HTMLDivElement>("#settings-modal"), 235 closeModal: app.querySelector<HTMLSpanElement>(".close-modal"), 236 defaultBangSelect: app.querySelector<HTMLSelectElement>("#default-bang"), 237 description: app.querySelector<HTMLParagraphElement>("#bang-description"), 238 historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"), 239 clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"), 240 bangName: app.querySelector<HTMLInputElement>(".bang-name"), 241 bangShortcut: app.querySelector<HTMLInputElement>(".bang-shortcut"), 242 bangSearchUrl: app.querySelector<HTMLInputElement>(".bang-search-url"), 243 bangBaseUrl: app.querySelector<HTMLInputElement>(".bang-base-url"), 244 addBang: app.querySelector<HTMLButtonElement>(".add-bang"), 245 removeBangs: app.querySelectorAll<HTMLButtonElement>(".remove-bang"), 246 } as const; 247 248 // Validate all elements exist 249 for (const [key, element] of Object.entries(elements)) { 250 if (!element) throw new Error(`${key} not found`); 251 } 252 253 // After validation, we can assert elements are non-null 254 const validatedElements = elements as { 255 [K in keyof typeof elements]: NonNullable<(typeof elements)[K]>; 256 }; 257 258 validatedElements.urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`; 259 260 const prefersReducedMotion = window.matchMedia( 261 "(prefers-reduced-motion: reduce)", 262 ).matches; 263 264 if (!prefersReducedMotion) { 265 // Add mouse tracking behavior 266 document.addEventListener("click", (e) => { 267 const x = e.clientX; 268 const y = e.clientY; 269 const centerX = window.innerWidth / 2; 270 const centerY = window.innerHeight / 2; 271 const differenceX = x - centerX; 272 const differenceY = y - centerY; 273 274 if ( 275 Math.abs(differenceX) > Math.abs(differenceY) && 276 Math.abs(differenceX) > 100 277 ) { 278 validatedElements.cutie.textContent = 279 differenceX < 0 280 ? CONSTANTS.CUTIES.LEFT[ 281 Math.floor(Math.random() * CONSTANTS.CUTIES.LEFT.length) 282 ] 283 : CONSTANTS.CUTIES.RIGHT[ 284 Math.floor(Math.random() * CONSTANTS.CUTIES.RIGHT.length) 285 ]; 286 } else if (Math.abs(differenceY) > 100) { 287 validatedElements.cutie.textContent = 288 differenceY < 0 289 ? CONSTANTS.CUTIES.UP[ 290 Math.floor(Math.random() * CONSTANTS.CUTIES.UP.length) 291 ] 292 : CONSTANTS.CUTIES.DOWN[ 293 Math.floor(Math.random() * CONSTANTS.CUTIES.DOWN.length) 294 ]; 295 } 296 }); 297 298 const audio = { 299 spin: createAudio("/heavier-tick-sprite.mp3"), 300 toggleOff: createAudio("/toggle-button-off.mp3"), 301 toggleOn: createAudio("/toggle-button-on.mp3"), 302 click: createAudio("/click-button.mp3"), 303 warning: createAudio("/double-button.mp3"), 304 copy: createAudio("/foot-switch.mp3"), 305 }; 306 307 validatedElements.copyButton.addEventListener("click", () => { 308 audio.copy.currentTime = 0; 309 audio.copy.play(); 310 }); 311 312 validatedElements.settingsButton.addEventListener("mouseenter", () => { 313 audio.spin.play(); 314 }); 315 316 validatedElements.settingsButton.addEventListener("mouseleave", () => { 317 audio.spin.pause(); 318 audio.spin.currentTime = 0; 319 }); 320 321 validatedElements.historyToggle.addEventListener("change", () => { 322 if (validatedElements.historyToggle.checked) { 323 audio.toggleOff.pause(); 324 audio.toggleOff.currentTime = 0; 325 audio.toggleOn.currentTime = 0; 326 audio.toggleOn.play(); 327 } else { 328 audio.toggleOn.pause(); 329 audio.toggleOn.currentTime = 0; 330 audio.toggleOff.currentTime = 0; 331 audio.toggleOff.play(); 332 } 333 }); 334 335 validatedElements.clearHistory.addEventListener("click", () => { 336 audio.warning.play(); 337 }); 338 339 validatedElements.defaultBangSelect.addEventListener("bangError", () => { 340 audio.warning.currentTime = 0; 341 audio.warning.play(); 342 }); 343 344 validatedElements.defaultBangSelect.addEventListener("bangSuccess", () => { 345 audio.click.currentTime = 0; 346 audio.click.play(); 347 }); 348 349 validatedElements.closeModal.addEventListener("closed", () => { 350 validatedElements.settingsButton.classList.remove("rotate"); 351 audio.spin.playbackRate = 0.7; 352 audio.spin.currentTime = 0; 353 audio.spin.play(); 354 audio.spin.onended = () => { 355 audio.spin.playbackRate = 1; 356 }; 357 }); 358 359 validatedElements.addBang.addEventListener("click", () => { 360 audio.click.currentTime = 0.1; 361 audio.click.playbackRate = 2; 362 audio.click.play(); 363 }); 364 365 validatedElements.removeBangs.forEach((button) => { 366 button.addEventListener("click", () => { 367 audio.warning.currentTime = 0; 368 audio.warning.play(); 369 }); 370 }); 371 } 372 373 validatedElements.copyButton.addEventListener("click", async () => { 374 await navigator.clipboard.writeText(validatedElements.urlInput.value); 375 validatedElements.copyIcon.src = "/clipboard-check.svg"; 376 377 if (!prefersReducedMotion) 378 validatedElements.copyInput.classList.add("flash-white"); 379 380 setTimeout(() => { 381 validatedElements.copyInput.classList.remove("flash-white"); 382 validatedElements.copyIcon.src = "/clipboard.svg"; 383 }, 375); 384 }); 385 386 validatedElements.settingsButton.addEventListener("click", () => { 387 validatedElements.settingsButton.classList.add("rotate"); 388 validatedElements.modal.style.display = "block"; 389 setOutsideElementsTabindex(validatedElements.modal, -1); 390 }); 391 392 validatedElements.closeModal.addEventListener("click", () => { 393 validatedElements.closeModal.dispatchEvent(new Event("closed")); 394 }); 395 396 window.addEventListener("click", (event) => { 397 if (event.target === validatedElements.modal) { 398 validatedElements.closeModal.dispatchEvent(new Event("closed")); 399 } 400 }); 401 402 validatedElements.closeModal.addEventListener("closed", () => { 403 validatedElements.modal.style.display = "none"; 404 setOutsideElementsTabindex(validatedElements.modal, 0); 405 406 if (validatedElements.historyToggle.checked !== historyEnabled) 407 if (!prefersReducedMotion) 408 setTimeout(() => { 409 window.location.reload(); 410 }, 300); 411 else window.location.reload(); 412 }); 413 414 validatedElements.defaultBangSelect.addEventListener("change", (event) => { 415 const newDefaultBang = (event.target as HTMLSelectElement).value.replace( 416 /^!+/, 417 "", 418 ); 419 const bang = customBangs[newDefaultBang] || bangs[newDefaultBang]; 420 421 if (!bang) { 422 validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG; 423 validatedElements.defaultBangSelect.classList.add("shake", "flash-red"); 424 validatedElements.defaultBangSelect.dispatchEvent( 425 new CustomEvent("bangError"), 426 ); 427 setTimeout(() => { 428 validatedElements.defaultBangSelect.classList.remove( 429 "shake", 430 "flash-red", 431 ); 432 }, 300); 433 return; 434 } 435 436 validatedElements.defaultBangSelect.dispatchEvent( 437 new CustomEvent("bangSuccess"), 438 ); 439 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang); 440 validatedElements.description.innerText = "Default Bang: " + bang.s; 441 }); 442 443 validatedElements.historyToggle.addEventListener("change", (event) => { 444 storage.set( 445 CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED, 446 (event.target as HTMLInputElement).checked.toString(), 447 ); 448 }); 449 450 validatedElements.clearHistory.addEventListener("click", () => { 451 clearSearchHistory(); 452 if (!prefersReducedMotion) 453 setTimeout(() => { 454 window.location.reload(); 455 }, 375); 456 else window.location.reload(); 457 }); 458 459 validatedElements.addBang.addEventListener("click", () => { 460 const name = validatedElements.bangName.value.trim(); 461 const shortcut = validatedElements.bangShortcut.value 462 .trim() 463 .replace(/^!+/, ""); 464 const searchUrl = validatedElements.bangSearchUrl.value.trim(); 465 const baseUrl = validatedElements.bangBaseUrl.value.trim(); 466 467 if (!name || !searchUrl || !baseUrl) return; 468 469 customBangs[shortcut] = { 470 t: name, 471 s: shortcut, 472 u: searchUrl, 473 d: baseUrl, 474 r: 0, 475 }; 476 storage.set( 477 CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS, 478 JSON.stringify(customBangs), 479 ); 480 481 if (!prefersReducedMotion) 482 setTimeout(() => { 483 window.location.reload(); 484 }, 375); 485 else window.location.reload(); 486 }); 487 488 validatedElements.removeBangs.forEach((button) => { 489 button.addEventListener("click", (event) => { 490 const shortcut = (event.target as HTMLButtonElement).dataset 491 .shortcut as string; 492 delete customBangs[shortcut]; 493 storage.set( 494 CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS, 495 JSON.stringify(customBangs), 496 ); 497 498 if (!prefersReducedMotion) 499 setTimeout(() => { 500 window.location.reload(); 501 }, 375); 502 else window.location.reload(); 503 }); 504 }); 505} 506 507const LS_DEFAULT_BANG = 508 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG) ?? "ddg"; 509const defaultBang = bangs[LS_DEFAULT_BANG]; 510 511function ensureProtocol(url: string, defaultProtocol = "https://") { 512 try { 513 const parsedUrl = new URL(url); 514 return parsedUrl.href; // If valid, return as is 515 } catch (e) { 516 return `${defaultProtocol}${url}`; 517 } 518} 519 520function getBangredirectUrl() { 521 const url = new URL(window.location.href); 522 const query = url.searchParams.get("q")?.trim() ?? ""; 523 524 switch (url.pathname.replace(/\/$/, "")) { 525 case "": { 526 if (!query || query === "!" || query === "!settings") { 527 noSearchDefaultPageRender(); 528 return null; 529 } 530 531 const count = ( 532 Number.parseInt( 533 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0", 534 ) + 1 535 ).toString(); 536 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count); 537 538 const match = query.toLowerCase().match(/^!(\S+)|!(\S+)$/i); 539 const selectedBang = match 540 ? customBangs[match[1] || match[2]] || bangs[match[1] || match[2]] 541 : defaultBang; 542 const cleanQuery = match 543 ? query.replace(/!\S+\s*|^(\S+!|!\S+)$/i, "").trim() 544 : query; 545 546 // Redirect to base domain if cleanQuery is empty 547 if (!cleanQuery && selectedBang?.d) { 548 return ensureProtocol(selectedBang.d); 549 } 550 551 if ( 552 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true" 553 ) { 554 addToSearchHistory(cleanQuery, { 555 bang: selectedBang?.t || "", 556 name: selectedBang?.s || "", 557 url: selectedBang?.u || "", 558 }); 559 } 560 561 return selectedBang?.u.replace( 562 "{{{s}}}", 563 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"), 564 ); 565 } 566 default: 567 notFoundPageRender(); 568 return null; 569 } 570} 571 572function doRedirect() { 573 const searchUrl = getBangredirectUrl(); 574 if (!searchUrl) return; 575 window.location.replace(searchUrl); 576} 577 578doRedirect();