feat: completed wodox locale; improved i18n logic
This commit is contained in:
parent
9d5e67136e
commit
00651b8e36
11 changed files with 213 additions and 125 deletions
|
|
@ -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,16 +48,16 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
if (value === undefined) {
|
|
||||||
return fallbackValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === null && fallbackValue !== null) {
|
function fallbackProxy<T extends object>(
|
||||||
return fallbackValue;
|
mask: DeepPartial<T>,
|
||||||
|
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 (
|
// else, proxy the returned object to support deep fallback proxying
|
||||||
value === null
|
return fallbackProxy<T[keyof T] & object>(
|
||||||
|| fallbackValue === null
|
// SAFETY: we validate that finalValue is an object above.
|
||||||
|| typeof value !== "object"
|
// I don't know why TypeScript isn't narrowing the type for us
|
||||||
|| typeof fallbackValue !== "object"
|
// based on that predicate, but oh well
|
||||||
) {
|
finalValue as DeepPartial<T[keyof T]> & object,
|
||||||
return value;
|
fallbackValue,
|
||||||
}
|
);
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
1
apps/vdn-static/src/utils/deep-partial.ts
Normal file
1
apps/vdn-static/src/utils/deep-partial.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue