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