A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bang";
2import "./global.css";
3
4const CONSTANTS = {
5 MAX_HISTORY: 500,
6 ANIMATION_DURATION: 375,
7 LOCAL_STORAGE_KEYS: {
8 SEARCH_HISTORY: "search-history",
9 SEARCH_COUNT: "search-count",
10 HISTORY_ENABLED: "history-enabled",
11 DEFAULT_BANG: "default-bang",
12 },
13};
14
15const storage = {
16 get: (key: string) => localStorage.getItem(key),
17 set: (key: string, value: string) => localStorage.setItem(key, value),
18 remove: (key: string) => localStorage.removeItem(key),
19};
20
21const memoizedGetSearchHistory = (() => {
22 let cache: Array<{
23 query: string;
24 bang: string;
25 name: string;
26 timestamp: number;
27 }> | null = null;
28 return () => {
29 if (!cache) {
30 cache = JSON.parse(
31 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]",
32 );
33 }
34 return cache;
35 };
36})();
37
38function addToSearchHistory(
39 query: string,
40 bang: { bang: string; name: string; url: string },
41) {
42 const history = memoizedGetSearchHistory();
43 if (!history) return;
44
45 history.unshift({
46 query,
47 bang: bang.bang,
48 name: bang.name,
49 timestamp: Date.now(),
50 });
51 history.splice(CONSTANTS.MAX_HISTORY);
52 storage.set(
53 CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY,
54 JSON.stringify(history),
55 );
56}
57
58function getSearchHistory(): Array<{
59 query: string;
60 bang: string;
61 name: string;
62 timestamp: number;
63}> {
64 try {
65 return JSON.parse(
66 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]",
67 );
68 } catch {
69 return [];
70 }
71}
72
73function clearSearchHistory() {
74 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY, "[]");
75}
76
77function getFocusableElements(
78 root: HTMLElement = document.body,
79): HTMLElement[] {
80 return Array.from(
81 root.querySelectorAll<HTMLElement>(
82 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
83 ),
84 );
85}
86
87function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) {
88 const modalElements = getFocusableElements(modal);
89 const allElements = getFocusableElements();
90
91 for (const element of allElements) {
92 if (!modalElements.includes(element)) {
93 element.setAttribute("tabindex", tabindex.toString());
94 }
95 }
96}
97
98const createTemplate = (data: {
99 searchCount: string;
100 historyEnabled: boolean;
101 searchHistory: Array<{
102 bang: string;
103 query: string;
104 name: string;
105 timestamp: number;
106 }>;
107 LS_DEFAULT_BANG: string;
108}) => `
109 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
110 <header style="position: absolute; top: 1rem; width: 100%;">
111 <div style="display: flex; justify-content: space-between; padding: 0 1rem;">
112 <span>${data.searchCount} ${data.searchCount === "1" ? "search" : "searches"}</span>
113 <button class="settings-button">
114 <img src="/gear.svg" alt="Settings" class="settings" />
115 </button>
116 </div>
117 </header>
118 <div class="content-container">
119 <h1 id="cutie">┐( ˘_˘ )┌</h1>
120 <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>
121 <div class="url-container">
122 <input
123 type="text"
124 class="url-input"
125 value="https://unduck.link?q=%s"
126 readonly
127 />
128 <button class="copy-button">
129 <img src="/clipboard.svg" alt="Copy" />
130 </button>
131 </div>
132 ${
133 data.historyEnabled
134 ? `
135 <h2 style="margin-top: 24px;">Recent Searches</h2>
136 <div style="max-height: 200px; overflow-y: auto; text-align: left;">
137 ${
138 data.searchHistory.length === 0
139 ? `<div style="padding: 8px; text-align: center;">No search history</div>`
140 : data.searchHistory
141 .map(
142 (search) => `
143 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);">
144 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a>
145 <span style="float: right; color: var(--text-color-secondary);">
146 ${new Date(search.timestamp).toLocaleString()}
147 </span>
148 </div>
149 `,
150 )
151 .join("")
152 }
153 </div>
154 `
155 : ""
156 }
157 </div>
158 <footer class="footer">
159 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
160 </footer>
161 <div class="modal" id="settings-modal">
162 <div class="modal-content">
163 <button class="close-modal">×</button>
164 <h2>Settings</h2>
165 <div class="settings-section">
166 <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === data.LS_DEFAULT_BANG)?.s || "Unknown bang"}</label>
167 <div class="bang-select-container">
168 <input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}">
169 </div>
170 </div>
171 <div class="settings-section">
172 <h3>Search History (${data.searchHistory.length}/500)</h3>
173 <div style="display: flex; justify-content: space-between; align-items: center;">
174 <label class="switch">
175 <label for="history-toggle">Enable Search History</label>
176 <input type="checkbox" id="history-toggle" ${data.historyEnabled ? "checked" : ""}>
177 <span class="slider round"></span>
178 </label>
179 <button class="clear-history">Clear History</button>
180 </div>
181 </div>
182 </div>
183 </div>
184 </div>
185 </div>
186`;
187
188const createAudio = (src: string) => {
189 const audio = new Audio();
190 audio.src = src;
191 return audio;
192};
193
194function noSearchDefaultPageRender() {
195 const searchCount =
196 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0";
197 const historyEnabled =
198 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true";
199 const searchHistory = getSearchHistory();
200 const app = document.querySelector<HTMLDivElement>("#app");
201 if (!app) throw new Error("App element not found");
202
203 app.innerHTML = createTemplate({
204 searchCount,
205 historyEnabled,
206 searchHistory,
207 LS_DEFAULT_BANG,
208 });
209
210 const elements = {
211 app,
212 cutie: app.querySelector<HTMLHeadingElement>("#cutie"),
213 copyInput: app.querySelector<HTMLInputElement>(".url-input"),
214 copyButton: app.querySelector<HTMLButtonElement>(".copy-button"),
215 copyIcon: app.querySelector<HTMLImageElement>(".copy-button img"),
216 urlInput: app.querySelector<HTMLInputElement>(".url-input"),
217 settingsButton: app.querySelector<HTMLButtonElement>(".settings-button"),
218 modal: app.querySelector<HTMLDivElement>("#settings-modal"),
219 closeModal: app.querySelector<HTMLSpanElement>(".close-modal"),
220 defaultBangSelect: app.querySelector<HTMLSelectElement>("#default-bang"),
221 description: app.querySelector<HTMLParagraphElement>("#bang-description"),
222 historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"),
223 clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"),
224 } as const;
225
226 // Validate all elements exist
227 for (const [key, element] of Object.entries(elements)) {
228 if (!element) throw new Error(`${key} not found`);
229 }
230
231 // After validation, we can assert elements are non-null
232 const validatedElements = elements as {
233 [K in keyof typeof elements]: NonNullable<(typeof elements)[K]>;
234 };
235
236 validatedElements.urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
237
238 const prefersReducedMotion = window.matchMedia(
239 "(prefers-reduced-motion: reduce)",
240 ).matches;
241
242 if (!prefersReducedMotion) {
243 // Add mouse tracking behavior
244 document.addEventListener("click", (e) => {
245 const x = e.clientX;
246 const y = e.clientY;
247 const centerX = window.innerWidth / 2;
248 const centerY = window.innerHeight / 2;
249 const differenceX = x - centerX;
250 const differenceY = y - centerY;
251
252 // Left-facing cuties
253 const leftCuties = ["╰(°□°╰)", "(◕‿◕´)", "(・ω・´)"];
254
255 // Right-facing cuties
256 const rightCuties = ["(╯°□°)╯", "(`◕‿◕)", "(`・ω・)"];
257
258 // Up-facing cuties
259 const upCuties = ["(↑°□°)↑", "(´◕‿◕)↑", "↑(´・ω・)↑"];
260
261 // Down-facing cuties
262 const downCuties = ["(↓°□°)↓", "(´◕‿◕)↓", "↓(´・ω・)↓"];
263
264 if (
265 Math.abs(differenceX) > Math.abs(differenceY) &&
266 Math.abs(differenceX) > 100
267 ) {
268 validatedElements.cutie.textContent =
269 differenceX < 0
270 ? leftCuties[Math.floor(Math.random() * leftCuties.length)]
271 : rightCuties[Math.floor(Math.random() * rightCuties.length)];
272 } else if (Math.abs(differenceY) > 100) {
273 validatedElements.cutie.textContent =
274 differenceY < 0
275 ? upCuties[Math.floor(Math.random() * upCuties.length)]
276 : downCuties[Math.floor(Math.random() * downCuties.length)];
277 }
278 });
279
280 const audio = {
281 spin: createAudio("/heavier-tick-sprite.mp3"),
282 toggleOff: createAudio("/toggle-button-off.mp3"),
283 toggleOn: createAudio("/toggle-button-on.mp3"),
284 click: createAudio("/click-button.mp3"),
285 warning: createAudio("/double-button.mp3"),
286 copy: createAudio("/foot-switch.mp3"),
287 };
288
289 validatedElements.copyButton.addEventListener("click", () => {
290 audio.copy.currentTime = 0;
291 audio.copy.play();
292 });
293
294 validatedElements.settingsButton.addEventListener("mouseenter", () => {
295 audio.spin.play();
296 });
297
298 validatedElements.settingsButton.addEventListener("mouseleave", () => {
299 audio.spin.pause();
300 audio.spin.currentTime = 0;
301 });
302
303 validatedElements.historyToggle.addEventListener("change", () => {
304 if (validatedElements.historyToggle.checked) {
305 audio.toggleOff.pause();
306 audio.toggleOff.currentTime = 0;
307 audio.toggleOn.currentTime = 0;
308 audio.toggleOn.play();
309 } else {
310 audio.toggleOn.pause();
311 audio.toggleOn.currentTime = 0;
312 audio.toggleOff.currentTime = 0;
313 audio.toggleOff.play();
314 }
315 });
316
317 validatedElements.clearHistory.addEventListener("click", () => {
318 audio.warning.play();
319 });
320
321 validatedElements.defaultBangSelect.addEventListener("bangError", () => {
322 audio.warning.currentTime = 0;
323 audio.warning.play();
324 });
325
326 validatedElements.defaultBangSelect.addEventListener("bangSuccess", () => {
327 audio.click.currentTime = 0;
328 audio.click.play();
329 });
330
331 validatedElements.closeModal.addEventListener("closed", () => {
332 validatedElements.settingsButton.classList.remove("rotate");
333 audio.spin.playbackRate = 0.7;
334 audio.spin.currentTime = 0;
335 audio.spin.play();
336 audio.spin.onended = () => {
337 audio.spin.playbackRate = 1;
338 };
339 });
340 }
341
342 validatedElements.copyButton.addEventListener("click", async () => {
343 await navigator.clipboard.writeText(validatedElements.urlInput.value);
344 validatedElements.copyIcon.src = "/clipboard-check.svg";
345
346 if (!prefersReducedMotion)
347 validatedElements.copyInput.classList.add("flash-white");
348
349 setTimeout(() => {
350 validatedElements.copyInput.classList.remove("flash-white");
351 validatedElements.copyIcon.src = "/clipboard.svg";
352 }, 375);
353 });
354
355 validatedElements.settingsButton.addEventListener("click", () => {
356 validatedElements.settingsButton.classList.add("rotate");
357 validatedElements.modal.style.display = "block";
358 setOutsideElementsTabindex(validatedElements.modal, -1);
359 });
360
361 validatedElements.closeModal.addEventListener("click", () => {
362 validatedElements.closeModal.dispatchEvent(new Event("closed"));
363 });
364
365 window.addEventListener("click", (event) => {
366 if (event.target === validatedElements.modal) {
367 validatedElements.closeModal.dispatchEvent(new Event("closed"));
368 }
369 });
370
371 validatedElements.closeModal.addEventListener("closed", () => {
372 validatedElements.modal.style.display = "none";
373 setOutsideElementsTabindex(validatedElements.modal, 0);
374
375 if (validatedElements.historyToggle.checked !== historyEnabled)
376 if (!prefersReducedMotion)
377 setTimeout(() => {
378 window.location.reload();
379 }, 300);
380 else window.location.reload();
381 });
382
383 validatedElements.defaultBangSelect.addEventListener("change", (event) => {
384 const newDefaultBang = (event.target as HTMLSelectElement).value;
385 const bang = bangs.find((b) => b.t === newDefaultBang);
386
387 if (!bang) {
388 validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG;
389 validatedElements.defaultBangSelect.classList.add("shake", "flash-red");
390 validatedElements.defaultBangSelect.dispatchEvent(
391 new CustomEvent("bangError"),
392 );
393 setTimeout(() => {
394 validatedElements.defaultBangSelect.classList.remove(
395 "shake",
396 "flash-red",
397 );
398 }, 300);
399 return;
400 }
401
402 validatedElements.defaultBangSelect.dispatchEvent(
403 new CustomEvent("bangSuccess"),
404 );
405 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang);
406 validatedElements.description.innerText = bang.s;
407 });
408
409 validatedElements.historyToggle.addEventListener("change", (event) => {
410 storage.set(
411 CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED,
412 (event.target as HTMLInputElement).checked.toString(),
413 );
414 });
415
416 validatedElements.clearHistory.addEventListener("click", () => {
417 clearSearchHistory();
418 if (!prefersReducedMotion)
419 setTimeout(() => {
420 window.location.reload();
421 }, 375);
422 else window.location.reload();
423 });
424}
425
426const LS_DEFAULT_BANG =
427 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG) ?? "ddg";
428const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG);
429
430function getBangredirectUrl() {
431 const url = new URL(window.location.href);
432 const query = url.searchParams.get("q")?.trim() ?? "";
433 if (!query) {
434 noSearchDefaultPageRender();
435 return null;
436 }
437
438 const count = (
439 Number.parseInt(
440 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0",
441 ) + 1
442 ).toString();
443 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count);
444
445 const match = query.match(/^!(\S+)|!(\S+)$/i);
446 const selectedBang = match
447 ? bangs.find((b) => b.t === match[1].toLowerCase())
448 : defaultBang;
449 const cleanQuery = match
450 ? query.replace(/!\S+\s*|^(\S+!|!\S+)$/i, "").trim()
451 : query;
452
453 if (storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true") {
454 addToSearchHistory(cleanQuery, {
455 bang: selectedBang?.t || "",
456 name: selectedBang?.s || "",
457 url: selectedBang?.u || "",
458 });
459 }
460
461 return selectedBang?.u.replace(
462 "{{{s}}}",
463 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"),
464 );
465}
466
467function doRedirect() {
468 const searchUrl = getBangredirectUrl();
469 if (!searchUrl) return;
470 window.location.replace(searchUrl);
471}
472
473doRedirect();