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