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