2
\$\begingroup\$

I’m currently building myself a website using GitHub pages.

I wanted to have a theme system, and recently rebuilt my implementation.
It now manages a <style> tag with ID theme in the <head> tag.
I rebuilt it to add support for custom themes, but the in-site UI doesn’t support it yet.

The code maintains two stores of theme information, a persistent one in local storage, and a ephemeral one in session storage. The user can choose to save the theme selected persistently.

The themes are stored as JSON representations of JS objects, which can either be a custom theme or a named theme.
However, the code still tries to parse the saved persistent preference as a raw theme name, too, for backwards compatability.

My main concern with the code was safety, understandability, and reusability which is why I chose to use TypeScript.
I’m somewhat concerned, though, that the more verbose code resulting from making more readable and reusable code might impact loading times.

Here’s the TS script whose JS-compiled version gets loaded into the site:

/**
 * Known theme names.
 */
const knownThemes = ["dark", "light", "auto"] as const;

/**
 * Represents any theme object.
 */
type Theme = NamedTheme | CustomTheme;

/**
 * A theme specified by name.
 */
type NamedTheme = {type: "namedTheme", value: (typeof knownThemes)[number]};

/**
 * A theme specified by CSS code.
 */
type CustomTheme = {type: "customTheme", value: string};

/**
 * Checks if theme name is known.
 */
function isKnownTheme(name: string): name is NamedTheme["value"] {
    return knownThemes.includes(name as NamedTheme["value"]);
}

/**
 * Checks for theme object structure.
 */
function isTheme(obj: any): obj is Theme {
    return obj.type === "namedTheme" && isKnownTheme(obj.value)
        || obj.type === "customTheme" && "value" in obj;
}

/**
 * Gets NamedTheme object from name.
 */
function NamedTheme(name: NamedTheme["value"]): NamedTheme {
    return {type: "namedTheme", value: name};
}

/**
 * Gets CustomTheme object from CSS code.
 */
function CustomTheme(str: string): CustomTheme {
    return {type: "customTheme", value: str};
}

/**
 * Default/fallback theme for the website.
 */
let defaultTheme = NamedTheme("auto")

/**
 * Gets theme object from string, with compatibility for naked theme names.
 */
function parseTheme(str: string): Theme {
    let theme: Theme;
    try {
        let obj: any = JSON.parse(str)
        theme = isTheme(obj) ? obj : defaultTheme;
    } catch {
        theme = {type: "namedTheme", value: isKnownTheme(str) ? str : "auto"};
    }
    return theme;
}

/**
 * Map from theme name to CSS code for all known named themes.
 */
let themeTable: {[name: string]: string} = {
    "dark": ":root {--background-color: #141414; --text-color: white; --link-color: yellow;}",
    "light": ":root {--background-color: white; --text-color: #141414; --link-color: crimson;}",
    "auto": ":root {--background-color: #141414; --text-color: white; --link-color: yellow;} @media (prefers-color-scheme: light) {:root {--background-color: white; --text-color: #141414; --link-color: crimson;}}"
};

/**
 * Gets persistent saved theme preference as string.
 */
function getThemePreference(): string | null {
    return localStorage.getItem("theme");
}

/**
 * Gets currently active theme from session storage as string.
 */
function getCurrentTheme(): string | null {
    return sessionStorage.getItem("theme");
}

/**
 * Gets currently active theme as object, falls back to theme preference.
 */
function getTheme(): Theme {
    let themeStr: string | null = getCurrentTheme() ?? getThemePreference();
    if (themeStr === null) {
        return defaultTheme;
    }
    return parseTheme(themeStr);
}

/**
 * Applies theme to website by changing content of a style element.
 */
function applyTheme(theme: Theme): void {
    let themeCss: string = theme.type === "namedTheme" ? themeTable[theme.value] : theme.value;
    let themeNode: HTMLElement | null = document.getElementById("theme");
    if (themeNode !== null) {
        themeNode.innerHTML = themeCss;
    }
}

/**
 * Sets persistent theme preference in local storage.
 */
function setThemePreference(str: string): void {
    localStorage.setItem("theme", str);
}

/**
 * Sets currently active theme in session storage.
 */
function setCurrentTheme(str: string): void {
    sessionStorage.setItem("theme", str);
}

/**
 * Gets checkbox state, sets it to unchecked, and returns the original state.
 */
function checkboxHandler(): boolean {
    let checkbox: HTMLInputElement = document.getElementById("save-theme") as HTMLInputElement;
    let wasChecked: boolean = checkbox.checked
    checkbox.checked = false
    return wasChecked
}

/**
 * Sets current theme, theme preference if needed, and resets checkbox.
 */
function saveThemeHandler(theme: Theme): void {
    let themeStr: string = JSON.stringify(theme);
    let checked: boolean = checkboxHandler();
    if (checked) {
        setThemePreference(themeStr);
    }
    setCurrentTheme(themeStr);
}

/**
 * Applies named theme with specified name, saves it, and resets the checkbox.
 * Intended to be called by theme control buttons via onclick event.
 */
function buttonHandler(name: NamedTheme["value"]): void {
    let theme: NamedTheme = NamedTheme(name);
    applyTheme(theme);
    saveThemeHandler(theme);
}

// Initialize theme on load.
applyTheme(getTheme());

The full source code is in the GitHub repo schuelermine/schuelermine.github.io.
You can view the website live at anselmschueler.com.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.