chore: initialize project
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 1m46s

This commit is contained in:
2026-04-03 16:32:58 +03:00
commit 014058071a
78 changed files with 12498 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from "./locale-context";

View File

@@ -0,0 +1,125 @@
import { useRouterState } from "@tanstack/react-router";
import type { ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { m } from "#/paraglide/messages";
import type { locales } from "#/paraglide/runtime";
import {
getLocale,
setLocale as setParaglideLocale,
} from "#/paraglide/runtime";
import { resolveSeoPage } from "./seo";
type Locale = (typeof locales)[number];
type LocaleContextValue = {
locale: Locale;
setLocale: (locale: Locale) => Promise<void>;
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function getCurrentLocale(): Locale {
return getLocale() as Locale;
}
export function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getCurrentLocale);
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const setLocale = useCallback(async (nextLocale: Locale) => {
await setParaglideLocale(nextLocale, { reload: false });
setLocaleState(getCurrentLocale());
}, []);
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
const seoPage = resolveSeoPage(pathname);
document.title =
seoPage === "about" ? m.seo_about_title() : m.seo_home_title();
const descriptionMeta = document.querySelector(
'meta[name="description"]',
);
if (descriptionMeta) {
descriptionMeta.setAttribute(
"content",
seoPage === "about"
? m.seo_about_description()
: m.seo_home_description(),
);
}
}
}, [locale, pathname]);
useEffect(() => {
if (typeof document === "undefined" || typeof window === "undefined") {
return;
}
const link = document.querySelector<HTMLLinkElement>("link#app-favicon");
if (!link) {
return;
}
const applyFavicon = () => {
const explicitTheme = document.documentElement.getAttribute("data-theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const isDark =
explicitTheme === "dark" || (explicitTheme !== "light" && prefersDark);
link.href = isDark ? "/favicon-dark.svg" : "/favicon-light.svg";
};
const media = window.matchMedia("(prefers-color-scheme: dark)");
const observer = new MutationObserver(applyFavicon);
applyFavicon();
media.addEventListener("change", applyFavicon);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => {
media.removeEventListener("change", applyFavicon);
observer.disconnect();
};
}, []);
const value = useMemo(
() => ({
locale,
setLocale,
}),
[locale, setLocale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error("useLocale must be used within LocaleProvider");
}
return context;
}
export function useMessages() {
useLocale();
return m;
}

View File

@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
function readMessages(locale: "ru" | "en") {
const filepath = resolve(process.cwd(), "messages", `${locale}.json`);
return JSON.parse(readFileSync(filepath, "utf-8")) as Record<string, string>;
}
describe("message catalogs", () => {
it("have the same keyset for ru and en", () => {
const ru = readMessages("ru");
const en = readMessages("en");
const ruKeys = Object.keys(ru).sort();
const enKeys = Object.keys(en).sort();
expect(ruKeys).toEqual(enKeys);
});
it("have non-empty translations", () => {
const catalogs = [readMessages("ru"), readMessages("en")];
for (const catalog of catalogs) {
for (const [key, value] of Object.entries(catalog)) {
expect(
value.trim().length,
`Empty translation for key: ${key}`,
).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolveSeoPage } from "./seo";
describe("resolveSeoPage", () => {
it("returns about page for /about and nested about routes", () => {
expect(resolveSeoPage("/about")).toBe("about");
expect(resolveSeoPage("/about/team")).toBe("about");
});
it("returns home page for non-about routes", () => {
expect(resolveSeoPage("/")).toBe("home");
expect(resolveSeoPage("/pricing")).toBe("home");
expect(resolveSeoPage("/aboutness")).toBe("home");
});
});

View File

@@ -0,0 +1,9 @@
export type SeoPage = "home" | "about";
export function resolveSeoPage(pathname: string): SeoPage {
if (pathname === "/about" || pathname.startsWith("/about/")) {
return "about";
}
return "home";
}