A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bang";
2
3function getFocusableElements(
4 root: HTMLElement = document.body,
5): HTMLElement[] {
6 return Array.from(
7 root.querySelectorAll<HTMLElement>(
8 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
9 ),
10 );
11}
12
13function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) {
14 const modalElements = getFocusableElements(modal);
15 const allElements = getFocusableElements();
16
17 for (const element of allElements) {
18 if (!modalElements.includes(element)) {
19 element.setAttribute("tabindex", tabindex.toString());
20 }
21 }
22}
23
24function noSearchDefaultPageRender() {
25 const app = document.querySelector<HTMLDivElement>("#app");
26 if (!app) throw new Error("App element not found");
27 app.innerHTML = `
28 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
29 <header style="position: absolute; top: 1rem; right: 1rem;">
30 <button class="settings-button">
31 <img src="/gear.svg" alt="Settings" class="settings" />
32 </button>
33 </header>
34 <div class="content-container">
35 <h1>┐( ˘_˘ )┌</h1>
36 <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>
37 <div class="url-container">
38 <input
39 type="text"
40 class="url-input"
41 value="https://unduck.link?q=%s"
42 readonly
43 />
44 <button class="copy-button">
45 <img src="/clipboard.svg" alt="Copy" />
46 </button>
47 </div>
48 </div>
49 <footer class="footer">
50 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
51 </footer>
52 <div class="modal" id="settings-modal">
53 <div class="modal-content">
54 <button class="close-modal">×</button>
55 <h2>Settings</h2>
56 <div>
57 <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === LS_DEFAULT_BANG)?.s || "Unknown bang"}</label>
58 <div class="bang-select-container">
59 <input type="text" id="default-bang" class="bang-select" value="${LS_DEFAULT_BANG}">
60 </div>
61 </div>
62 </div>
63 </div>
64 </div>
65 </div>
66 `;
67
68 const copyButton = app.querySelector<HTMLButtonElement>(".copy-button");
69 if (!copyButton) throw new Error("Copy button not found");
70 const copyIcon = copyButton.querySelector("img");
71 if (!copyIcon) throw new Error("Copy icon not found");
72 const urlInput = app.querySelector<HTMLInputElement>(".url-input");
73 if (!urlInput) throw new Error("URL input not found");
74 const settingsButton =
75 app.querySelector<HTMLButtonElement>(".settings-button");
76 if (!settingsButton) throw new Error("Settings button not found");
77 const modal = app.querySelector<HTMLDivElement>("#settings-modal");
78 if (!modal) throw new Error("Modal not found");
79 const closeModal = app.querySelector<HTMLSpanElement>(".close-modal");
80 if (!closeModal) throw new Error("Close modal not found");
81 const defaultBangSelect =
82 app.querySelector<HTMLSelectElement>("#default-bang");
83 if (!defaultBangSelect) throw new Error("Default bang select not found");
84 const description =
85 app.querySelector<HTMLParagraphElement>("#bang-description");
86 if (!description) throw new Error("Bang description not found");
87
88 urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
89
90 copyButton.addEventListener("click", async () => {
91 await navigator.clipboard.writeText(urlInput.value);
92 copyIcon.src = "/clipboard-check.svg";
93
94 setTimeout(() => {
95 copyIcon.src = "/clipboard.svg";
96 }, 2000);
97 });
98
99 const prefersReducedMotion = window.matchMedia(
100 "(prefers-reduced-motion: reduce)",
101 ).matches;
102 if (!prefersReducedMotion) {
103 const audio = new Audio("/heavier-tick-sprite.mp3");
104
105 settingsButton.addEventListener("mouseenter", () => {
106 audio.play();
107 });
108
109 settingsButton.addEventListener("mouseleave", () => {
110 audio.pause();
111 audio.currentTime = 0;
112 });
113 }
114
115 settingsButton.addEventListener("click", () => {
116 modal.style.display = "block";
117 setOutsideElementsTabindex(modal, -1);
118 });
119
120 closeModal.addEventListener("click", () => {
121 modal.style.display = "none";
122 setOutsideElementsTabindex(modal, 0);
123 });
124
125 window.addEventListener("click", (event) => {
126 if (event.target === modal) {
127 modal.style.display = "none";
128 setOutsideElementsTabindex(modal, 0);
129 }
130 });
131
132 // Save default bang
133 defaultBangSelect.addEventListener("change", (event) => {
134 const newDefaultBang = (event.target as HTMLSelectElement).value;
135 const bang = bangs.find((b) => b.t === newDefaultBang);
136
137 if (!bang) {
138 // Invalid bang entered
139 defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value
140 defaultBangSelect.classList.add("shake", "flash-red");
141
142 // Remove animation classes after animation completes
143 setTimeout(() => {
144 defaultBangSelect.classList.remove("shake", "flash-red");
145 }, 300);
146
147 return;
148 }
149
150 localStorage.setItem("default-bang", newDefaultBang);
151 description.innerText = bang.s;
152 });
153}
154
155const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg";
156const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG);
157
158function getBangredirectUrl() {
159 const url = new URL(window.location.href);
160 const query = url.searchParams.get("q")?.trim() ?? "";
161 if (!query) {
162 noSearchDefaultPageRender();
163 return null;
164 }
165
166 const match = query.match(/!(\S+)/i);
167 const selectedBang = match
168 ? bangs.find((b) => b.t === match[1].toLowerCase())
169 : defaultBang;
170 const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query;
171
172 return selectedBang?.u.replace(
173 "{{{s}}}",
174 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"),
175 );
176}
177
178function doRedirect() {
179 const searchUrl = getBangredirectUrl();
180 if (!searchUrl) return;
181 window.location.replace(searchUrl);
182}
183
184doRedirect();