chore: initialize project
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 1m46s
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 1m46s
This commit is contained in:
1
src/shared/lib/i18n/index.ts
Normal file
1
src/shared/lib/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./locale-context";
|
||||
125
src/shared/lib/i18n/locale-context.tsx
Normal file
125
src/shared/lib/i18n/locale-context.tsx
Normal 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;
|
||||
}
|
||||
33
src/shared/lib/i18n/messages-parity.test.ts
Normal file
33
src/shared/lib/i18n/messages-parity.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
15
src/shared/lib/i18n/seo.test.ts
Normal file
15
src/shared/lib/i18n/seo.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
9
src/shared/lib/i18n/seo.ts
Normal file
9
src/shared/lib/i18n/seo.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user