feat: completed wodox locale; improved i18n logic

This commit is contained in:
Benjamin Singleton 2026-02-09 16:10:44 -06:00 committed by Sheldon Cooper
parent 9d5e67136e
commit 00651b8e36
11 changed files with 213 additions and 125 deletions

View file

@ -3,6 +3,7 @@ import "./assets/style.scss";
import { ref, type Ref } from "vue"; import { ref, type Ref } from "vue";
import LocalePicker from "./components/organisms/LocalePicker.vue"; import LocalePicker from "./components/organisms/LocalePicker.vue";
import { vOnClickOutside } from "@vueuse/components"; import { vOnClickOutside } from "@vueuse/components";
import { useLocale } from "./i18n";
const burgerOpen: Ref<boolean> = ref<boolean>(false); const burgerOpen: Ref<boolean> = ref<boolean>(false);
@ -13,6 +14,8 @@ const toggleBurger = (): void => {
const closeBurger = (): void => { const closeBurger = (): void => {
burgerOpen.value = false; burgerOpen.value = false;
}; };
const locale = useLocale();
</script> </script>
<template> <template>
@ -45,18 +48,18 @@ const closeBurger = (): void => {
class="navbar-item" class="navbar-item"
to="/" to="/"
@click="closeBurger()" @click="closeBurger()"
>What is Viossa?</RouterLink >{{ locale.navbar.whatIsViossa }}</RouterLink
> >
<RouterLink <RouterLink
class="navbar-item" class="navbar-item"
to="/resources" to="/resources"
@click="closeBurger()" @click="closeBurger()"
>Resources</RouterLink >{{ locale.navbar.resources }}</RouterLink
> >
<RouterLink class="navbar-item" to="/kotoba"> <RouterLink class="navbar-item" to="/kotoba">
Kotoba {{ locale.navbar.kotoba }}
</RouterLink> </RouterLink>
<LocalePicker class="navbar-item"/> <LocalePicker class="navbar-item" />
</div> </div>
</div> </div>
</nav> </nav>

View file

@ -19,7 +19,7 @@ const locale = useLocale();
<section class="section container"> <section class="section container">
<HomeSectionWrapper <HomeSectionWrapper
v-for="(section, index) in localizeLayout(locale.home)" v-for="(section, index) in localizeLayout(locale.home.layout)"
:key="index" :key="index"
:title="section.title" :title="section.title"
:text="section.text" :text="section.text"

View file

@ -1,12 +1,19 @@
<script setup lang="ts">
import { useLocale } from '@/i18n';
const locale = useLocale();
</script>
<template> <template>
<div> <div>
<section class="section"> <section class="section">
<h1 class="title">Tropos-agnostic search</h1> <h1 class="title">{{ locale.kotoba.title }}</h1>
</section> </section>
<section class="section container"> <section class="section container">
<div class="notification is-info block"> <div class="notification is-info block">
<p>To searcn tropos-agnostically, enter a term below.</p> <p>{{ locale.kotoba.searchHelp }}</p>
</div> </div>
<div class="block is-flex is-flex-direction-row is-gap-2"> <div class="block is-flex is-flex-direction-row is-gap-2">
@ -21,7 +28,3 @@
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts">
</script>

View file

@ -9,12 +9,14 @@ const locale = useLocale();
<template> <template>
<div> <div>
<section class="section"> <section class="section">
<h1 class="title">Learning Resources</h1> <h1 class="title">{{ locale.resources.title }}</h1>
</section> </section>
<section class="section container"> <section class="section container">
<LearningResourceWrapper <LearningResourceWrapper
v-for="(resource, index) in localizeLayout(locale.resources)" v-for="(resource, index) in localizeLayout(
locale.resources.layout,
)"
:key="index" :key="index"
:title="resource.title" :title="resource.title"
:subtitle="resource.subtitle" :subtitle="resource.subtitle"

View file

@ -1,22 +1,22 @@
import en_US from "../locales/en_US"; import en_US from "../locales/en_US";
import vp_VL from "../locales/vp_VL"; import vp_VL from "../locales/vp_VL";
import wp_VL from "../locales/wp_VL"; import wp_VL from "../locales/wp_VL";
import { computed, readonly, ref } from "vue"; import { computed, readonly, ref, type DeepReadonly } from "vue";
import type { Locale } from "./locale"; import type { Locale } from "./locale";
import type { DeepPartial } from "@/utils/deep-partial";
export const LOCALE_IDS = ["en_US", "vp_VL", "wp_VL"] as const; export const LOCALE_IDS = ["en_US", "vp_VL", "wp_VL"] as const;
export type LocaleId = (typeof LOCALE_IDS)[number]; export type LocaleId = (typeof LOCALE_IDS)[number];
const locales = { en_US, vp_VL, wp_VL } as const satisfies Record< const locales = { en_US, vp_VL, wp_VL } as const satisfies {
LocaleId, en_US: Locale;
Locale } & Record<Exclude<LocaleId, "en_US">, DeepPartial<Locale>>;
>;
export const localeId = ref<LocaleId>("en_US"); export const localeId = ref<LocaleId>("en_US");
export function useLocale(opt: UseLocaleOptions = {}) { export function useLocale(opt: UseLocaleOptions = {}) {
const locale = computed<Locale>(() => { const locale = computed<DeepReadonly<Locale>>(() => {
return fallbackProxy( return fallbackProxy<Locale>(
locales[opt.locale ?? localeId.value], locales[opt.locale ?? localeId.value],
locales["en_US"], locales["en_US"],
); );
@ -29,32 +29,60 @@ export interface UseLocaleOptions {
locale?: LocaleId; locale?: LocaleId;
} }
function fallbackProxy(obj: any, fallback: any) { function isObject(value: unknown) {
return new Proxy(obj, { return value !== null && typeof value === "object";
get: (target, key) => { }
const value = target[key];
const fallbackValue = fallback[key]; function fallbackProxy<T extends object>(
if (value === undefined) { mask: DeepPartial<T>,
return fallbackValue; fallback: T,
): DeepReadonly<T> {
const proxy = new Proxy(fallback, {
get: (_target, key): DeepReadonly<T[keyof T]> => {
// SAFETY: typescript should ensure we're only ever trying to access keys
// that exist on T, and if the key doesn't, we'll be returning undefined anyway
// which is the expected behavior.
const tKey = key as keyof T;
// value may not exist on mask
const value: DeepPartial<T>[keyof T] | undefined = mask[tKey];
// all values exist on fallback
const fallbackValue: T[keyof T] = fallback[tKey];
// this is *not* T[keyof T], because if value is an object,
// it may still have missing properties.
// this only handles the case where the current value is undefined, not nested ones.
// thus, `finalValue` is still a `DeepPartial<T[keyof T]>` (but not undefined)
const finalValue: DeepPartial<T[keyof T]> =
value === undefined ? fallbackValue : value;
// check if finalValue or fallbackValue is not an object
// (we can't deep proxy unless both of them are objects)
if (!isObject(finalValue) || !isObject(fallbackValue)) {
// SAFETY: finalValue is not an object,
// so `DeepPartial<typeof finalValue>` == `typeof finalValue`
// (it is a primitive type)
// then we apply DeepReadonly to disallow mutations and to satisfy
// the return type of this getter function
return finalValue as DeepReadonly<T[keyof T]>;
} }
if (value === null && fallbackValue !== null) { // else, proxy the returned object to support deep fallback proxying
return fallbackValue; return fallbackProxy<T[keyof T] & object>(
} // SAFETY: we validate that finalValue is an object above.
// I don't know why TypeScript isn't narrowing the type for us
if ( // based on that predicate, but oh well
value === null finalValue as DeepPartial<T[keyof T]> & object,
|| fallbackValue === null fallbackValue,
|| typeof value !== "object" );
|| typeof fallbackValue !== "object"
) {
return value;
}
return fallbackProxy(value, fallbackValue);
}, },
set: () => { set: () => {
throw new Error("Cannot mutate locale at runtime"); throw new Error("Cannot mutate locale at runtime");
}, },
}); });
// SAFETY: we're just disallowing mutations to the proxy,
// since its setter panics if used at runtime
return proxy as DeepReadonly<T>;
} }

View file

@ -1,12 +1,24 @@
export interface Locale { export interface Locale {
localeName: string; localeName: string;
home: Layout<HomeSections>; navbar: Navbar;
resources: Layout<Resources>; home: HomePage;
resources: ResourcesPage;
kotoba: KotobaPage;
} }
export interface Layout<T> { export interface Layout<T> {
layout: (keyof T)[] | null; order: (keyof T)[];
data: { [K in keyof T]: T[K] | null }; data: { [K in keyof T]: T[K] };
}
export interface Navbar {
whatIsViossa: string;
resources: string;
kotoba: string;
}
export interface HomePage {
layout: Layout<HomeSections>;
} }
export interface HomeSections { export interface HomeSections {
@ -22,6 +34,11 @@ export interface HomeSection {
alt: string | null; alt: string | null;
} }
export interface ResourcesPage {
title: string;
layout: Layout<Resources>;
}
export interface Resources { export interface Resources {
discord: Resource; discord: Resource;
} }
@ -37,3 +54,8 @@ export interface Resource {
joinText: string; joinText: string;
rulesText: string; rulesText: string;
} }
export interface KotobaPage {
title: string;
searchHelp: string;
}

View file

@ -4,8 +4,14 @@ import discordImg from "@/assets/discord.png";
export default { export default {
localeName: "English", localeName: "English",
navbar: {
whatIsViossa: "What is Viossa?",
resources: "Resources",
kotoba: "Kotoba",
},
home: { home: {
layout: ["whatIsViossa", "historyOfViossa", "community"], layout: {
order: ["whatIsViossa", "historyOfViossa", "community"],
data: { data: {
whatIsViossa: { whatIsViossa: {
title: "What is Viossa?", title: "What is Viossa?",
@ -27,8 +33,11 @@ export default {
}, },
}, },
}, },
},
resources: { resources: {
layout: ["discord"], title: "Learning Resources",
layout: {
order: ["discord"],
data: { data: {
discord: { discord: {
title: "Discord Server", title: "Discord Server",
@ -44,4 +53,9 @@ export default {
}, },
}, },
}, },
},
kotoba: {
title: "Tropos-agnostic search",
searchHelp: "To searcn tropos-agnostically, enter a term below.",
},
} as const satisfies Locale; } as const satisfies Locale;

View file

@ -1,10 +1,11 @@
import type { Locale } from "@/i18n/locale"; import type { Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png"; import flakkaImg from "@/assets/flakka.png";
import type { DeepPartial } from "@/utils/deep-partial";
export default { export default {
localeName: "Viossa", localeName: "Viossa",
home: { home: {
layout: null, layout: {
data: { data: {
whatIsViossa: { whatIsViossa: {
title: "Kafaen afto Viossa", title: "Kafaen afto Viossa",
@ -12,9 +13,7 @@ export default {
image: flakkaImg, image: flakkaImg,
alt: "Flag of the Viossa Language", alt: "Flag of the Viossa Language",
}, },
historyOfViossa: null,
community: null,
}, },
}, },
resources: { layout: null, data: { discord: null } }, },
} as const satisfies Locale; } as const satisfies DeepPartial<Locale>;

View file

@ -1,11 +1,18 @@
import type { Locale } from "@/i18n/locale"; import type { Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png"; import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png"; import discordImg from "@/assets/discord.png";
import type { DeepPartial } from "@/utils/deep-partial";
export default { export default {
localeName: "Wodox", localeName: "wodox",
navbar: {
whatIsViossa: "viosox e ano?",
resources: "tropos",
kotoba: "mot o viosox",
},
home: { home: {
layout: ["whatIsViossa", "historyOfViossa", "community"], layout: {
order: ["whatIsViossa", "historyOfViossa", "community"],
data: { data: {
whatIsViossa: { whatIsViossa: {
title: "viosox e ano?", title: "viosox e ano?",
@ -27,8 +34,11 @@ export default {
}, },
}, },
}, },
},
resources: { resources: {
layout: ["discord"], title: "tropos o gen",
layout: {
order: ["discord"],
data: { data: {
discord: { discord: {
title: "server o Diskord", title: "server o Diskord",
@ -43,4 +53,10 @@ export default {
}, },
}, },
}, },
} as const satisfies Locale; },
kotoba: {
title: "zalkuketutsa mot o viosox mit il o omni falmot",
searchHelp:
"ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun.",
},
} as const satisfies DeepPartial<Locale>;

View file

@ -0,0 +1 @@
export type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };

View file

@ -5,7 +5,7 @@ export function localizeLayout<T>(
layout: DeepReadonly<Layout<T>>, layout: DeepReadonly<Layout<T>>,
): T[keyof T][] { ): T[keyof T][] {
const sections: T[keyof T][] = []; const sections: T[keyof T][] = [];
for (const sectionId of layout.layout ?? []) { for (const sectionId of layout.order ?? []) {
const section = (layout.data as T)[sectionId as keyof T]; const section = (layout.data as T)[sectionId as keyof T];
if (section) { if (section) {
sections.push(section); sections.push(section);