A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bang";
2import "./global.css";
3
4function addToSearchHistory(
5 query: string,
6 bang: { bang: string; name: string; url: string },
7) {
8 const history = getSearchHistory();
9 history.unshift({
10 query,
11 bang: bang.bang,
12 name: bang.name,
13 timestamp: Date.now(),
14 });
15 // Keep only last 500 searches
16 history.splice(500);
17 localStorage.setItem("search-history", JSON.stringify(history));
18}
19
20function getSearchHistory(): Array<{
21 query: string;
22 bang: string;
23 name: string;
24 timestamp: number;
25}> {
26 try {
27 return JSON.parse(localStorage.getItem("search-history") || "[]");
28 } catch {
29 return [];
30 }
31}
32
33function clearSearchHistory() {
34 localStorage.setItem("search-history", "[]");
35}
36
37function getFocusableElements(
38 root: HTMLElement = document.body,
39): HTMLElement[] {
40 return Array.from(
41 root.querySelectorAll<HTMLElement>(
42 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
43 ),
44 );
45}
46
47function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) {
48 const modalElements = getFocusableElements(modal);
49 const allElements = getFocusableElements();
50
51 for (const element of allElements) {
52 if (!modalElements.includes(element)) {
53 element.setAttribute("tabindex", tabindex.toString());
54 }
55 }
56}
57
58function noSearchDefaultPageRender() {
59 const searchCount = localStorage.getItem("search-count") || "0";
60 const historyEnabled = localStorage.getItem("history-enabled") === "true";
61 const searchHistory = getSearchHistory();
62 const app = document.querySelector<HTMLDivElement>("#app");
63 if (!app) throw new Error("App element not found");
64 app.innerHTML = `
65 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
66 <header style="position: absolute; top: 1rem; width: 100%;">
67 <div style="display: flex; justify-content: space-between; padding: 0 1rem;">
68 <span>${searchCount} ${searchCount === "1" ? "search" : "searches"}</span>
69 <button class="settings-button">
70 <img src="/gear.svg" alt="Settings" class="settings" />
71 </button>
72 </div>
73 </header>
74 <div class="content-container">
75 <h1>┐( ˘_˘ )┌</h1>
76 <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>
77 <div class="url-container">
78 <input
79 type="text"
80 class="url-input"
81 value="https://unduck.link?q=%s"
82 readonly
83 />
84 <button class="copy-button">
85 <img src="/clipboard.svg" alt="Copy" />
86 </button>
87 </div>
88 ${
89 historyEnabled
90 ? `
91 <h2 style="margin-top: 24px;">Recent Searches</h2>
92 <div style="max-height: 200px; overflow-y: auto; text-align: left;">
93 ${
94 searchHistory.length === 0
95 ? `<div style="padding: 8px; text-align: center;">No search history</div>`
96 : searchHistory
97 .map(
98 (search) => `
99 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);">
100 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a>
101 <span style="float: right; color: var(--text-color-secondary);">
102 ${new Date(search.timestamp).toLocaleString()}
103 </span>
104 </div>
105 `,
106 )
107 .join("")
108 }
109 </div>
110 `
111 : ""
112 }
113 </div>
114 <footer class="footer">
115 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
116 </footer>
117 <div class="modal" id="settings-modal">
118 <div class="modal-content">
119 <button class="close-modal">×</button>
120 <h2>Settings</h2>
121 <div class="settings-section">
122 <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === LS_DEFAULT_BANG)?.s || "Unknown bang"}</label>
123 <div class="bang-select-container">
124 <input type="text" id="default-bang" class="bang-select" value="${LS_DEFAULT_BANG}">
125 </div>
126 </div>
127 <div class="settings-section">
128 <h3>Search History (${searchHistory.length}/500)</h3>
129 <div style="display: flex; justify-content: space-between; align-items: center;">
130 <label class="switch">
131 <label for="history-toggle">Enable Search History</label>
132 <input type="checkbox" id="history-toggle" ${historyEnabled ? "checked" : ""}>
133 <span class="slider round"></span>
134 </label>
135 <button class="clear-history">Clear History</button>
136 </div>
137 </div>
138 </div>
139 </div>
140 </div>
141 </div>
142 `;
143
144 const copyInput = app.querySelector<HTMLInputElement>(".url-input");
145 if (!copyInput) throw new Error("Copy input not found");
146 const copyButton = app.querySelector<HTMLButtonElement>(".copy-button");
147 if (!copyButton) throw new Error("Copy button not found");
148 const copyIcon = copyButton.querySelector("img");
149 if (!copyIcon) throw new Error("Copy icon not found");
150 const urlInput = app.querySelector<HTMLInputElement>(".url-input");
151 if (!urlInput) throw new Error("URL input not found");
152 const settingsButton =
153 app.querySelector<HTMLButtonElement>(".settings-button");
154 if (!settingsButton) throw new Error("Settings button not found");
155 const modal = app.querySelector<HTMLDivElement>("#settings-modal");
156 if (!modal) throw new Error("Modal not found");
157 const closeModal = app.querySelector<HTMLSpanElement>(".close-modal");
158 if (!closeModal) throw new Error("Close modal not found");
159 const defaultBangSelect =
160 app.querySelector<HTMLSelectElement>("#default-bang");
161 if (!defaultBangSelect) throw new Error("Default bang select not found");
162 const description =
163 app.querySelector<HTMLParagraphElement>("#bang-description");
164 if (!description) throw new Error("Bang description not found");
165 const historyToggle = app.querySelector<HTMLInputElement>("#history-toggle");
166 if (!historyToggle) throw new Error("History toggle not found");
167 const clearHistory = app.querySelector<HTMLButtonElement>(".clear-history");
168 if (!clearHistory) throw new Error("Clear history button not found");
169
170 urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
171
172 const prefersReducedMotion = window.matchMedia(
173 "(prefers-reduced-motion: reduce)",
174 ).matches;
175 if (!prefersReducedMotion) {
176 const spinAudio = new Audio("/heavier-tick-sprite.mp3");
177 const toggleOffAudio = new Audio("/toggle-button-off.mp3");
178 const toggleOnAudio = new Audio("/toggle-button-on.mp3");
179 const clickAudio = new Audio("/click-button.mp3");
180 const warningAudio = new Audio("/double-button.mp3");
181 const copyAudio = new Audio("/foot-switch.mp3");
182
183 copyButton.addEventListener("click", () => {
184 copyAudio.currentTime = 0;
185 copyAudio.play();
186 });
187
188 settingsButton.addEventListener("mouseenter", () => {
189 spinAudio.play();
190 });
191
192 settingsButton.addEventListener("mouseleave", () => {
193 spinAudio.pause();
194 spinAudio.currentTime = 0;
195 });
196
197 historyToggle.addEventListener("change", () => {
198 if (historyToggle.checked) {
199 toggleOffAudio.pause();
200 toggleOffAudio.currentTime = 0;
201 toggleOnAudio.currentTime = 0;
202 toggleOnAudio.play();
203 } else {
204 toggleOnAudio.pause();
205 toggleOnAudio.currentTime = 0;
206 toggleOffAudio.currentTime = 0;
207 toggleOffAudio.play();
208 }
209 });
210
211 clearHistory.addEventListener("click", () => {
212 warningAudio.play();
213 });
214
215 defaultBangSelect.addEventListener("bangError", () => {
216 warningAudio.currentTime = 0;
217 warningAudio.play();
218 });
219
220 defaultBangSelect.addEventListener("bangSuccess", () => {
221 clickAudio.currentTime = 0;
222 clickAudio.play();
223 });
224
225 closeModal.addEventListener("closed", () => {
226 settingsButton.classList.remove("rotate");
227 spinAudio.playbackRate = 0.7;
228 spinAudio.currentTime = 0;
229 spinAudio.play();
230 spinAudio.onended = () => {
231 spinAudio.playbackRate = 1;
232 };
233 });
234 }
235
236 copyButton.addEventListener("click", async () => {
237 await navigator.clipboard.writeText(urlInput.value);
238 copyIcon.src = "/clipboard-check.svg";
239
240 if (!prefersReducedMotion) copyInput.classList.add("flash-white");
241
242 setTimeout(() => {
243 copyInput.classList.remove("flash-white");
244 copyIcon.src = "/clipboard.svg";
245 }, 375);
246 });
247
248 settingsButton.addEventListener("click", () => {
249 settingsButton.classList.add("rotate");
250 modal.style.display = "block";
251 setOutsideElementsTabindex(modal, -1);
252 });
253
254 closeModal.addEventListener("click", () => {
255 closeModal.dispatchEvent(new Event("closed"));
256 modal.style.display = "none";
257 setOutsideElementsTabindex(modal, 0);
258 });
259
260 window.addEventListener("click", (event) => {
261 if (event.target === modal) {
262 closeModal.dispatchEvent(new Event("closed"));
263 modal.style.display = "none";
264 setOutsideElementsTabindex(modal, 0);
265 }
266 });
267
268 // Save default bang
269 defaultBangSelect.addEventListener("change", (event) => {
270 const newDefaultBang = (event.target as HTMLSelectElement).value;
271 const bang = bangs.find((b) => b.t === newDefaultBang);
272
273 if (!bang) {
274 // Invalid bang entered
275 defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value
276 defaultBangSelect.classList.add("shake", "flash-red");
277
278 // Dispatch error event
279 defaultBangSelect.dispatchEvent(new CustomEvent("bangError"));
280
281 // Remove animation classes after animation completes
282 setTimeout(() => {
283 defaultBangSelect.classList.remove("shake", "flash-red");
284 }, 300);
285
286 return;
287 }
288
289 defaultBangSelect.dispatchEvent(new CustomEvent("bangSuccess"));
290
291 localStorage.setItem("default-bang", newDefaultBang);
292 description.innerText = bang.s;
293 });
294
295 // Enable/disable search history
296 historyToggle.addEventListener("change", (event) => {
297 localStorage.setItem(
298 "history-enabled",
299 (event.target as HTMLInputElement).checked.toString(),
300 );
301 });
302 clearHistory.addEventListener("click", () => {
303 clearSearchHistory();
304 if (!prefersReducedMotion)
305 setTimeout(() => {
306 window.location.reload();
307 }, 375);
308 else window.location.reload();
309 });
310}
311
312const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg";
313const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG);
314
315function getBangredirectUrl() {
316 const url = new URL(window.location.href);
317 const query = url.searchParams.get("q")?.trim() ?? "";
318 if (!query) {
319 noSearchDefaultPageRender();
320 return null;
321 }
322
323 // increment search count
324 const count = (
325 Number.parseInt(localStorage.getItem("search-count") || "0") + 1
326 ).toString();
327 localStorage.setItem("search-count", count);
328
329 const match = query.match(/!(\S+)/i);
330 const selectedBang = match
331 ? bangs.find((b) => b.t === match[1].toLowerCase())
332 : defaultBang;
333 const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query;
334
335 // Add search to history
336 if (localStorage.getItem("history-enabled") === "true") {
337 addToSearchHistory(cleanQuery, {
338 bang: selectedBang?.t || "",
339 name: selectedBang?.s || "",
340 url: selectedBang?.u || "",
341 });
342 }
343
344 return selectedBang?.u.replace(
345 "{{{s}}}",
346 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"),
347 );
348}
349
350function doRedirect() {
351 const searchUrl = getBangredirectUrl();
352 if (!searchUrl) return;
353 window.location.replace(searchUrl);
354}
355
356doRedirect();