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