-
+
+
{
+ const newValue = e.target.value;
+ if (!newValue) {
+ clearSearch();
+ return;
+ }
+ setSearchText(newValue);
+ }}
/>
+ {!searchText && (
+
+ )}
);
};
diff --git a/src/components/SnippetList.tsx b/src/components/SnippetList.tsx
index 0b9a2025..f4a9c643 100644
--- a/src/components/SnippetList.tsx
+++ b/src/components/SnippetList.tsx
@@ -1,43 +1,75 @@
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import { useAppContext } from "@contexts/AppContext";
import { useSnippets } from "@hooks/useSnippets";
import { SnippetType } from "@types";
+import { QueryParams } from "@utils/enums";
+import {
+ getLanguageDisplayLogo,
+ getLanguageDisplayName,
+} from "@utils/languageUtils";
+import { slugify } from "@utils/slugify";
import { LeftAngleArrowIcon } from "./Icons";
import SnippetModal from "./SnippetModal";
const SnippetList = () => {
- const { language, snippet, setSnippet } = useAppContext();
- const { fetchedSnippets } = useSnippets();
- const [isModalOpen, setIsModalOpen] = useState(false);
-
+ const [searchParams, setSearchParams] = useSearchParams();
const shouldReduceMotion = useReducedMotion();
- if (!fetchedSnippets)
- return (
-
-
-
- );
+ const { language, subLanguage, snippet, setSnippet } = useAppContext();
+ const { fetchedSnippets } = useSnippets();
+
+ const [isModalOpen, setIsModalOpen] = useState
(false);
- const handleOpenModal = (activeSnippet: SnippetType) => {
+ const handleOpenModal = (selected: SnippetType) => () => {
setIsModalOpen(true);
- setSnippet(activeSnippet);
+ setSnippet(selected);
+ searchParams.set(QueryParams.SNIPPET, slugify(selected.title));
+ setSearchParams(searchParams);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSnippet(null);
+ searchParams.delete(QueryParams.SNIPPET);
+ setSearchParams(searchParams);
};
+ /**
+ * open the relevant modal if the snippet is in the search params
+ */
+ useEffect(() => {
+ const snippetSlug = searchParams.get(QueryParams.SNIPPET);
+ if (!snippetSlug) {
+ return;
+ }
+
+ const selectedSnippet = (fetchedSnippets ?? []).find(
+ (item) => slugify(item.title) === snippetSlug
+ );
+ if (selectedSnippet) {
+ handleOpenModal(selectedSnippet)();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchedSnippets, searchParams]);
+
+ if (!fetchedSnippets) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
{fetchedSnippets.map((snippet, idx) => {
- const uniqueId = `${language.name}-${snippet.title}`;
+ const uniqueId = `${language.name}-${snippet.title}-${idx}`;
return (
{
opacity: 1,
y: 0,
transition: {
- delay: shouldReduceMotion ? 0 : 0.09 + idx * 0.05,
duration: shouldReduceMotion ? 0 : 0.2,
},
}}
@@ -55,7 +86,6 @@ const SnippetList = () => {
opacity: 0,
y: -20,
transition: {
- delay: idx * 0.01,
duration: shouldReduceMotion ? 0 : 0.09,
},
}}
@@ -67,12 +97,15 @@ const SnippetList = () => {
handleOpenModal(snippet)}
+ onClick={handleOpenModal(snippet)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
>
-

+
{snippet.title}
diff --git a/src/components/SubLanguageSelector.tsx b/src/components/SubLanguageSelector.tsx
index 0b88210e..7b0d271c 100644
--- a/src/components/SubLanguageSelector.tsx
+++ b/src/components/SubLanguageSelector.tsx
@@ -1,42 +1,68 @@
+import { useNavigate } from "react-router-dom";
+
import { useAppContext } from "@contexts/AppContext";
import { LanguageType } from "@types";
+import { configureUserSelection } from "@utils/configureUserSelection";
+import { defaultSlugifiedSubLanguageName } from "@utils/consts";
+import { slugify } from "@utils/slugify";
type SubLanguageSelectorProps = {
- mainLanguage: LanguageType;
- afterSelect: () => void;
- onDropdownToggle: (openedLang: LanguageType) => void;
opened: boolean;
+ parentLanguage: LanguageType;
+ onDropdownToggle: (_: LanguageType["name"]) => void;
+ handleParentSelect: (_: LanguageType) => void;
+ afterSelect: () => void;
};
const SubLanguageSelector = ({
- mainLanguage,
+ opened,
+ parentLanguage,
+ handleParentSelect,
afterSelect,
onDropdownToggle,
- opened,
}: SubLanguageSelectorProps) => {
- const { language, setLanguage } = useAppContext();
+ const navigate = useNavigate();
- const handleSelect = (selected: LanguageType) => {
- setLanguage(selected);
- onDropdownToggle(mainLanguage);
- afterSelect();
- };
+ const { language, subLanguage, setSearchText } = useAppContext();
+
+ const handleSubLanguageSelect =
+ (selected: LanguageType["subLanguages"][number]) => async () => {
+ const {
+ language: newLanguage,
+ subLanguage: newSubLanguage,
+ category: newCategory,
+ } = await configureUserSelection({
+ languageName: parentLanguage.name,
+ subLanguageName: selected.name,
+ });
+
+ setSearchText("");
+ navigate(
+ `/${slugify(newLanguage.name)}/${slugify(newSubLanguage)}/${slugify(newCategory)}`
+ );
+ afterSelect();
+ };
return (
<>
setLanguage(mainLanguage)}
+ aria-selected={
+ subLanguage === defaultSlugifiedSubLanguageName &&
+ language.name === parentLanguage.name
+ }
+ onClick={() => handleParentSelect(parentLanguage)}
>
- {opened && (
- <>
- {mainLanguage.subLanguages.map((subLanguage) => (
- {
- handleSelect({
- ...subLanguage,
- mainLanguage: mainLanguage,
- subLanguages: [],
- });
- }}
- >
-
-
- ))}
- >
- )}
+ {opened &&
+ parentLanguage.subLanguages.map((sl) => (
+
+
+
+ ))}
>
);
};
diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx
index ac7b51ce..4ebd3bea 100644
--- a/src/contexts/AppContext.tsx
+++ b/src/contexts/AppContext.tsx
@@ -1,43 +1,74 @@
-import { createContext, FC, useContext, useState } from "react";
+import { createContext, FC, useContext, useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useLanguages } from "@hooks/useLanguages";
import { AppState, LanguageType, SnippetType } from "@types";
-
-// tokens
-const defaultLanguage: LanguageType = {
- name: "JAVASCRIPT",
- icon: "/icons/javascript.svg",
- subLanguages: [],
-};
-
-// TODO: add custom loading and error handling
-const defaultState: AppState = {
- language: defaultLanguage,
- setLanguage: () => {},
- category: "",
- setCategory: () => {},
- snippet: null,
- setSnippet: () => {},
-};
+import { configureUserSelection } from "@utils/configureUserSelection";
+import { defaultLanguage, defaultState } from "@utils/consts";
+import { slugify } from "@utils/slugify";
const AppContext = createContext(defaultState);
export const AppProvider: FC<{ children: React.ReactNode }> = ({
children,
}) => {
- const [language, setLanguage] = useState(defaultLanguage);
- const [category, setCategory] = useState("");
+ const navigate = useNavigate();
+ const { languageName, subLanguageName, categoryName } = useParams();
+
+ const { fetchedLanguages } = useLanguages();
+
+ const [language, setLanguage] = useState(null);
+ const [subLanguage, setSubLanguage] = useState(
+ null
+ );
+ const [category, setCategory] = useState(null);
const [snippet, setSnippet] = useState(null);
+ const [searchText, setSearchText] = useState("");
+
+ const configure = async () => {
+ const { language, subLanguage, category } = await configureUserSelection({
+ languageName,
+ subLanguageName,
+ categoryName,
+ });
+
+ setLanguage(language);
+ setSubLanguage(subLanguage);
+ setCategory(category);
+ };
+
+ useEffect(() => {
+ configure();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchedLanguages, languageName, subLanguageName, categoryName]);
+
+ /**
+ * Set the default language if the language is not found in the URL.
+ */
+ useEffect(() => {
+ if (languageName === undefined) {
+ navigate(`/${slugify(defaultLanguage.name)}`, { replace: true });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (language === null || category === null) {
+ return Loading...
;
+ }
return (
{children}
diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts
index d1d4a631..65639acd 100644
--- a/src/hooks/useCategories.ts
+++ b/src/hooks/useCategories.ts
@@ -2,16 +2,20 @@ import { useMemo } from "react";
import { useAppContext } from "@contexts/AppContext";
import { CategoryType } from "@types";
-import { slugify } from "@utils/slugify";
+import { getLanguageFileName } from "@utils/languageUtils";
import { useFetch } from "./useFetch";
export const useCategories = () => {
- const { language } = useAppContext();
- const { data, loading, error } = useFetch(
- `/consolidated/${language.mainLanguage ? `${slugify(language.mainLanguage.name)}--${slugify(language.name)}` : slugify(language.name)}.json`
+ const { language, subLanguage } = useAppContext();
+
+ const fileName = useMemo(
+ () => getLanguageFileName(language.name, subLanguage),
+ [language.name, subLanguage]
);
+ const { data, loading, error } = useFetch(fileName);
+
const fetchedCategories = useMemo(() => {
return data ? data.map((item) => item.name) : [];
}, [data]);
diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts
index 401ead2b..2082b1ac 100644
--- a/src/hooks/useKeyboardNavigation.ts
+++ b/src/hooks/useKeyboardNavigation.ts
@@ -1,14 +1,11 @@
import { useState } from "react";
-import { LanguageType } from "@types";
-
interface UseKeyboardNavigationProps {
- items: LanguageType[];
+ items: { languageName: string; subLanguageName?: string }[];
isOpen: boolean;
- onSelect: (item: LanguageType) => void;
+ toggleDropdown: (languageName: string) => void;
+ onSelect: (languageName: string, subLanguageName?: string) => void;
onClose: () => void;
- toggleDropdown: (openedLang: LanguageType) => void;
- openedLanguages: LanguageType[];
}
const keyboardEventKeys = {
@@ -25,15 +22,16 @@ type KeyboardEventKeys =
export const useKeyboardNavigation = ({
items,
isOpen,
- openedLanguages,
+ toggleDropdown,
onSelect,
onClose,
- toggleDropdown,
}: UseKeyboardNavigationProps) => {
const [focusedIndex, setFocusedIndex] = useState(-1);
const handleKeyDown = (event: React.KeyboardEvent) => {
- if (!isOpen) return;
+ if (!isOpen) {
+ return;
+ }
const key = event.key as KeyboardEventKeys;
@@ -49,26 +47,14 @@ export const useKeyboardNavigation = ({
break;
case "ArrowRight":
if (focusedIndex >= 0) {
- const selectedItem = items.filter(
- (item) =>
- !item.mainLanguage ||
- openedLanguages.includes(item.mainLanguage)
- )[focusedIndex];
-
- if (selectedItem.subLanguages.length > 0) {
- toggleDropdown(selectedItem);
- }
+ const selectedItem = items[focusedIndex];
+ toggleDropdown(selectedItem.languageName);
}
break;
case "Enter":
if (focusedIndex >= 0) {
- onSelect(
- items.filter(
- (item) =>
- !item.mainLanguage ||
- openedLanguages.includes(item.mainLanguage)
- )[focusedIndex]
- );
+ const selectedItem = items[focusedIndex];
+ onSelect(selectedItem.languageName, selectedItem.subLanguageName);
}
break;
case "Escape":
diff --git a/src/hooks/useSnippets.ts b/src/hooks/useSnippets.ts
index f0d30800..5cf4498c 100644
--- a/src/hooks/useSnippets.ts
+++ b/src/hooks/useSnippets.ts
@@ -1,18 +1,53 @@
+import { useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+
import { useAppContext } from "@contexts/AppContext";
import { CategoryType } from "@types";
+import { defaultCategoryName } from "@utils/consts";
+import { QueryParams } from "@utils/enums";
+import { getLanguageFileName } from "@utils/languageUtils";
import { slugify } from "@utils/slugify";
import { useFetch } from "./useFetch";
export const useSnippets = () => {
- const { language, category } = useAppContext();
- const { data, loading, error } = useFetch(
- `/consolidated/${language.mainLanguage ? `${slugify(language.mainLanguage.name)}--${slugify(language.name)}` : slugify(language.name)}.json`
+ const [searchParams] = useSearchParams();
+
+ const { language, subLanguage, category } = useAppContext();
+
+ const fileName = useMemo(
+ () => getLanguageFileName(language.name, subLanguage),
+ [language.name, subLanguage]
);
- const fetchedSnippets = data
- ? data.find((item) => item.name === category)?.snippets
- : [];
+ const { data, loading, error } = useFetch(fileName);
+
+ const fetchedSnippets = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ // If the category is the default category, return all snippets for the given language.
+ const snippets =
+ slugify(category) === slugify(defaultCategoryName)
+ ? data.flatMap((item) => item.snippets)
+ : (data.find((item) => item.name === category)?.snippets ?? []);
+
+ if (!searchParams.has(QueryParams.SEARCH)) {
+ return snippets;
+ }
+
+ return snippets.filter((item) => {
+ const searchTerm = (
+ searchParams.get(QueryParams.SEARCH) || ""
+ ).toLowerCase();
+ return (
+ item.title.toLowerCase().includes(searchTerm) ||
+ item.description.toLowerCase().includes(searchTerm) ||
+ item.tags.some((tag) => tag.toLowerCase().includes(searchTerm))
+ );
+ });
+ }, [category, data, searchParams]);
return { fetchedSnippets, loading, error };
};
diff --git a/src/main.tsx b/src/main.tsx
index 1a01bb18..957d266f 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,15 +2,14 @@ import "@styles/main.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
-import { AppProvider } from "@contexts/AppContext";
-
-import App from "./App";
+import AppRouter from "@AppRouter";
createRoot(document.getElementById("root")!).render(
-
-
-
+
+
+
);
diff --git a/src/styles/main.css b/src/styles/main.css
index 5ffc06ef..d0f2a08f 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -76,6 +76,7 @@
--fw-normal: 400;
/* Border radius */
+ --br-sm: 0.25rem;
--br-md: 0.5rem;
--br-lg: 0.75rem;
}
@@ -301,12 +302,38 @@ abbr {
border: 1px solid var(--clr-border-primary);
border-radius: var(--br-md);
padding: 0.75em 1.125em;
+ position: relative;
&:is(:hover, :focus-within) {
border-color: var(--clr-accent);
}
}
+.search-field label {
+ position: absolute;
+ margin-left: 2.25em;
+}
+
+.search-field:hover, .search-field:hover * {
+ cursor: pointer;
+}
+
+/* hide the label when the search field input element is focused */
+.search-field input:focus + label {
+ display: none;
+}
+
+.search-field label kbd {
+ background-color: var(--clr-bg-secondary);
+ border: 1px solid var(--clr-border-primary);
+ border-radius: var(--br-sm);
+ padding: 0.25em 0.5em;
+ margin: 0 0.25em;
+ font-family: var(--ff-mono);
+ font-weight: var(--fw-bold);
+ color: var(--clr-text-primary);
+}
+
.search-field > input {
background-color: transparent;
border: none;
diff --git a/src/types/index.ts b/src/types/index.ts
index 8e489f44..a8001394 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,7 +1,6 @@
export type LanguageType = {
name: string;
icon: string;
- mainLanguage?: LanguageType;
subLanguages: {
name: string;
icon: string;
@@ -35,9 +34,10 @@ export type RawSnippetType = {
export type AppState = {
language: LanguageType;
- setLanguage: React.Dispatch>;
+ subLanguage: LanguageType["name"];
category: string;
- setCategory: React.Dispatch>;
snippet: SnippetType | null;
setSnippet: React.Dispatch>;
+ searchText: string;
+ setSearchText: React.Dispatch>;
};
diff --git a/src/utils/configureUserSelection.ts b/src/utils/configureUserSelection.ts
new file mode 100644
index 00000000..345f9376
--- /dev/null
+++ b/src/utils/configureUserSelection.ts
@@ -0,0 +1,78 @@
+import { CategoryType, LanguageType } from "@types";
+
+import {
+ defaultCategoryName,
+ defaultLanguage,
+ defaultSlugifiedSubLanguageName,
+} from "./consts";
+import { slugify } from "./slugify";
+
+export async function configureUserSelection({
+ languageName,
+ subLanguageName,
+ categoryName,
+}: {
+ languageName: string | undefined;
+ subLanguageName?: string | undefined;
+ categoryName?: string | undefined;
+}): Promise<{
+ language: LanguageType;
+ subLanguage: LanguageType["name"];
+ category: CategoryType["name"];
+}> {
+ const slugifiedLanguageName = languageName
+ ? slugify(languageName)
+ : undefined;
+ const slugifiedSubLanguageName = subLanguageName
+ ? slugify(subLanguageName)
+ : undefined;
+ const slugifiedCategoryName = categoryName
+ ? slugify(categoryName)
+ : undefined;
+
+ const fetchedLanguages: LanguageType[] = await fetch(
+ "/consolidated/_index.json"
+ ).then((res) => res.json());
+
+ const language =
+ fetchedLanguages.find(
+ (lang) => slugify(lang.name) === slugifiedLanguageName
+ ) ?? defaultLanguage;
+
+ const subLanguage = language.subLanguages.find(
+ (sl) => slugify(sl.name) === slugifiedSubLanguageName
+ );
+ const matchedSubLanguage =
+ subLanguage === undefined
+ ? defaultSlugifiedSubLanguageName
+ : slugify(subLanguage.name);
+
+ let category: CategoryType | undefined;
+ try {
+ const fetchedCategories: CategoryType[] = await fetch(
+ `/consolidated/${slugify(language.name)}.json`
+ ).then((res) => res.json());
+ category = fetchedCategories.find(
+ (item) => slugify(item.name) === slugifiedCategoryName
+ );
+
+ if (category === undefined) {
+ category = {
+ name: defaultCategoryName,
+ snippets: fetchedCategories.flatMap((item) => item.snippets),
+ };
+ }
+ } catch (_error) {
+ // This state should not be reached in the normal flow.
+ category = {
+ name: defaultCategoryName,
+ snippets: [],
+ };
+ }
+
+ return {
+ language,
+ subLanguage: matchedSubLanguage,
+ category: category.name,
+ };
+}
diff --git a/src/utils/consts.ts b/src/utils/consts.ts
new file mode 100644
index 00000000..fced7edd
--- /dev/null
+++ b/src/utils/consts.ts
@@ -0,0 +1,24 @@
+import { AppState, CategoryType, LanguageType } from "@types";
+
+import { slugify } from "./slugify";
+
+export const defaultLanguage: LanguageType = {
+ name: "JAVASCRIPT",
+ icon: "/icons/javascript.svg",
+ subLanguages: [],
+};
+
+export const defaultSlugifiedSubLanguageName = slugify("All Sub Languages");
+
+export const defaultCategoryName: CategoryType["name"] = "All Snippets";
+
+// TODO: add custom loading and error handling
+export const defaultState: AppState = {
+ language: defaultLanguage,
+ subLanguage: defaultSlugifiedSubLanguageName,
+ category: defaultCategoryName,
+ snippet: null,
+ setSnippet: () => {},
+ searchText: "",
+ setSearchText: () => {},
+};
diff --git a/src/utils/enums.ts b/src/utils/enums.ts
new file mode 100644
index 00000000..61de7575
--- /dev/null
+++ b/src/utils/enums.ts
@@ -0,0 +1,4 @@
+export enum QueryParams {
+ SEARCH = "q",
+ SNIPPET = "snippet",
+}
diff --git a/src/utils/languageUtils.ts b/src/utils/languageUtils.ts
new file mode 100644
index 00000000..f77e9822
--- /dev/null
+++ b/src/utils/languageUtils.ts
@@ -0,0 +1,31 @@
+import { LanguageType } from "@types";
+
+import { defaultSlugifiedSubLanguageName } from "./consts";
+import { reverseSlugify, slugify } from "./slugify";
+
+export function getLanguageDisplayName(
+ language: LanguageType["name"],
+ subLanguage: LanguageType["subLanguages"][number]["name"]
+) {
+ return slugify(subLanguage) !== defaultSlugifiedSubLanguageName
+ ? reverseSlugify(subLanguage).toLocaleUpperCase()
+ : language;
+}
+
+export function getLanguageDisplayLogo(
+ language: LanguageType["name"],
+ subLanguage: LanguageType["subLanguages"][number]["name"]
+) {
+ return slugify(subLanguage) !== defaultSlugifiedSubLanguageName
+ ? `/icons/${slugify(language)}--${slugify(subLanguage)}.svg`
+ : `/icons/${slugify(language)}.svg`;
+}
+
+export function getLanguageFileName(
+ language: LanguageType["name"],
+ subLanguage: LanguageType["subLanguages"][number]["name"]
+) {
+ return slugify(subLanguage) !== defaultSlugifiedSubLanguageName
+ ? `/consolidated/${slugify(language)}--${slugify(subLanguage)}.json`
+ : `/consolidated/${slugify(language)}.json`;
+}
diff --git a/tests/configureUserSelection.test.ts b/tests/configureUserSelection.test.ts
new file mode 100644
index 00000000..2829dcb9
--- /dev/null
+++ b/tests/configureUserSelection.test.ts
@@ -0,0 +1,173 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { CategoryType, LanguageType } from "../src/types";
+import { configureUserSelection } from "../src/utils/configureUserSelection";
+import { defaultCategoryName, defaultLanguage } from "../src/utils/consts";
+import { slugify } from "../src/utils/slugify";
+
+vi.mock("../src/utils/slugify");
+
+describe("configureUserSelection", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const mockFetch = (urlResponses: Record) => {
+ global.fetch = vi.fn(async (url) => {
+ const response = urlResponses[url as string];
+ if (response instanceof Error) {
+ throw response;
+ }
+ return {
+ json: async () => response,
+ };
+ }) as unknown as typeof fetch;
+ };
+
+ it("should return default language and category if no arguments are provided", async () => {
+ mockFetch({
+ "/consolidated/_index.json": [],
+ });
+
+ const result = await configureUserSelection({
+ languageName: undefined,
+ categoryName: undefined,
+ });
+
+ expect(result).toEqual({
+ language: defaultLanguage,
+ category: defaultCategoryName,
+ });
+
+ expect(fetch).toHaveBeenCalledWith("/consolidated/_index.json");
+ });
+
+ it("should match the language and default to the first category if categoryName is undefined", async () => {
+ const mockLanguages: LanguageType[] = [
+ {
+ name: "JavaScript",
+ icon: "js-icon",
+ subLanguages: [],
+ },
+ {
+ name: "Python",
+ icon: "python-icon",
+ subLanguages: [],
+ },
+ ];
+ const mockCategories: CategoryType[] = [
+ {
+ name: "Basics",
+ snippets: [],
+ },
+ {
+ name: "Advanced",
+ snippets: [],
+ },
+ ];
+
+ mockFetch({
+ "/consolidated/_index.json": mockLanguages,
+ "/consolidated/javascript.json": mockCategories,
+ });
+
+ vi.mocked(slugify).mockImplementation((str) => str.toLowerCase());
+
+ const result = await configureUserSelection({
+ languageName: "JavaScript",
+ categoryName: undefined,
+ });
+
+ expect(result).toEqual({
+ language: mockLanguages[0],
+ category: defaultCategoryName,
+ });
+
+ expect(slugify).toHaveBeenCalledWith("JavaScript");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/_index.json");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/javascript.json");
+ });
+
+ it("should match the language and specific category if both arguments are provided", async () => {
+ const mockLanguages: LanguageType[] = [
+ {
+ name: "JavaScript",
+ icon: "js-icon",
+ subLanguages: [],
+ },
+ {
+ name: "Python",
+ icon: "python-icon",
+ subLanguages: [],
+ },
+ ];
+ const mockCategories: CategoryType[] = [
+ {
+ name: "Basics",
+ snippets: [],
+ },
+ {
+ name: "Advanced",
+ snippets: [],
+ },
+ ];
+
+ mockFetch({
+ "/consolidated/_index.json": mockLanguages,
+ "/consolidated/javascript.json": mockCategories,
+ });
+
+ vi.mocked(slugify).mockImplementation((str) => str.toLowerCase());
+
+ const result = await configureUserSelection({
+ languageName: "JavaScript",
+ categoryName: "Advanced",
+ });
+
+ expect(result).toEqual({
+ language: mockLanguages[0],
+ category: mockCategories[1].name,
+ });
+
+ expect(slugify).toHaveBeenCalledWith("JavaScript");
+ expect(slugify).toHaveBeenCalledWith("Advanced");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/_index.json");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/javascript.json");
+ });
+
+ it("should return default category if category fetch fails", async () => {
+ const mockLanguages: LanguageType[] = [
+ {
+ name: "JavaScript",
+ icon: "js-icon",
+ subLanguages: [],
+ },
+ {
+ name: "Python",
+ icon: "python-icon",
+ subLanguages: [],
+ },
+ ];
+
+ mockFetch({
+ "/consolidated/_index.json": mockLanguages,
+ "/consolidated/javascript.json": new Error("Network error"),
+ });
+
+ vi.mocked(slugify).mockImplementation((str) => str.toLowerCase());
+
+ const result = await configureUserSelection({
+ languageName: "JavaScript",
+ categoryName: undefined,
+ });
+
+ expect(result).toEqual({
+ language: mockLanguages[0],
+ category: defaultCategoryName,
+ });
+
+ expect(slugify).toHaveBeenCalledWith("JavaScript");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/_index.json");
+ expect(fetch).toHaveBeenCalledWith("/consolidated/javascript.json");
+ });
+});
diff --git a/tests/languageUtils.test.ts b/tests/languageUtils.test.ts
new file mode 100644
index 00000000..d6b8f383
--- /dev/null
+++ b/tests/languageUtils.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from "vitest";
+
+import { defaultSlugifiedSubLanguageName } from "../src/utils/consts";
+import {
+ getLanguageDisplayName,
+ getLanguageDisplayLogo,
+ getLanguageFileName,
+} from "../src/utils/languageUtils";
+
+describe(getLanguageDisplayName.name, () => {
+ it("should return the upper cased subLanguage if it is not the default", () => {
+ const result = getLanguageDisplayName("JAVASCRIPT", "React");
+ expect(result).toBe("REACT");
+ });
+
+ it("should return the language name if subLanguage is the default", () => {
+ const result = getLanguageDisplayName(
+ "JAVASCRIPT",
+ defaultSlugifiedSubLanguageName
+ );
+ expect(result).toBe("JAVASCRIPT");
+ });
+});
+
+describe(getLanguageDisplayLogo.name, () => {
+ it("should return a concatenation of the language and subLanguage if subLanguage is not the default", () => {
+ const result = getLanguageDisplayLogo("JAVASCRIPT", "React");
+ expect(result).toBe("/icons/javascript--react.svg");
+ });
+
+ it("should return the language name only if subLanguage is the default", () => {
+ const result = getLanguageDisplayLogo(
+ "JAVASCRIPT",
+ defaultSlugifiedSubLanguageName
+ );
+ expect(result).toBe("/icons/javascript.svg");
+ });
+});
+
+describe(getLanguageFileName.name, () => {
+ it("should return a concatenation of the language and subLanguage if subLanguage is not the default", () => {
+ const result = getLanguageFileName("JAVASCRIPT", "React");
+ expect(result).toBe("/consolidated/javascript--react.json");
+ });
+
+ it("should return the language name only if subLanguage is the default", () => {
+ const result = getLanguageFileName(
+ "JAVASCRIPT",
+ defaultSlugifiedSubLanguageName
+ );
+ expect(result).toBe("/consolidated/javascript.json");
+ });
+});