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