wip: fixed issues with i18n fallbacking for value-type objects/arrays, removed i18n integration and replaced with proprietary implementation again

This commit is contained in:
Benjamin Singleton 2026-02-15 23:33:35 -06:00
parent 771a8cc4bf
commit d2020cafec
19 changed files with 529 additions and 420 deletions

View file

@ -7,9 +7,9 @@ import { useRouter } from "vue-router";
import SmartLink from "./components/atoms/SmartLink.vue";
import type { SmartDest } from "./utils/smart-dest";
import type { Locale } from "./i18n/locale";
import { useI18n } from "./i18n";
import { useLocale } from "./i18n";
const i18n = useI18n();
const locale = useLocale();
const burgerOpen: Ref<boolean> = ref<boolean>(false);
@ -39,7 +39,7 @@ const NAVBAR_ITEM_ORDER = [
const navbarItems = computed(() =>
NAVBAR_ITEM_ORDER.map((id): NavbarItem => {
const label = i18n.t(`navbar.${id}`);
const label = locale.value.navbar[id];
const to = ((): SmartDest => {
switch (id) {

View file

@ -1,45 +0,0 @@
<script setup lang="ts" generic="Path extends TemplateMessagePath">
import {
type MessageValue,
type TemplateMessagePath,
type CompiledTemplate,
useI18n,
} from "@/i18n";
import type { VNode } from "vue";
import { useSlots, onMounted } from "vue";
const props = defineProps<{ keypath: Path }>();
const i18n = useI18n();
// Extract slot names from the CompiledTemplate type
type PathSlotName =
MessageValue<Path> extends CompiledTemplate<infer SlotName> ? SlotName
: never;
const slots = defineSlots<{ [K in PathSlotName]: () => VNode[] }>();
const runtimeSlots = useSlots();
// Validate required slots at runtime
onMounted(() => {
const requiredSlots = i18n.v(props.keypath).slots;
const missingSlots = requiredSlots.filter(
(slot) => !runtimeSlots[slot as string],
);
if (missingSlots.length > 0) {
throw new Error(
`Template is missing slots!\n\tTemplate: ${props.keypath}\n\tMissing Slots: ${missingSlots.join(", ")}`,
);
}
});
</script>
<template>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - this is the safe wrapper -->
<i18n-t :keypath="`${keypath}.template`" scope="global">
<template v-for="(slot, name) in slots" :key="name" #[name]>
<component :is="slot" />
</template>
</i18n-t>
</template>

View file

@ -1,29 +1,79 @@
<script setup lang="ts" generic="SlotName extends string">
import { type VNode } from "vue";
import {
type CompiledRichTemplate,
type RichTemplateMessagePath,
} from "@/i18n";
getCurrentInstance,
onMounted,
type DeepReadonly,
type VNode,
} from "vue";
import { type CompiledRichTemplate } from "@/i18n";
import RichTemplateParts from "./RichTemplateParts.vue";
import OptionalParent from "./OptionalParent.vue";
const props = defineProps<{
template: CompiledRichTemplate<SlotName>;
template: DeepReadonly<CompiledRichTemplate<SlotName>>;
tag?: string;
}>();
const slots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
const providedSlots =
defineSlots<{ [K in DeepReadonly<SlotName>]: () => VNode[] }>();
console.log(Object.entries(props.template));
function tryResolveComponentName(type: unknown): string | undefined {
if (!type || typeof type !== "object") return undefined;
const maybeType = type as { name?: string; __file?: string };
if (maybeType.name) return maybeType.name;
if (maybeType.__file) {
const parts = maybeType.__file.split(/[\\/]/);
const filename = parts.at(-1);
if (filename === undefined) {
return undefined;
}
const filenameParts = filename.split(".");
filenameParts.pop();
return filenameParts.join(".");
}
return undefined;
}
function resolveComponentName(type: unknown): string {
return tryResolveComponentName(type) ?? "(unresolvable)";
}
const getComponentStack = () => {
const instance = getCurrentInstance();
if (!instance) return "";
const names: string[] = [resolveComponentName(instance.type)];
let current = instance.parent;
while (current) {
names.push(resolveComponentName(current.type));
current = current.parent;
}
return names.length > 0 ? `\n\tComponent Stack: ${names.join(" > ")}` : "";
};
// Validate required slots at runtime
// FIXME: currently this won't flag required slots that aren't actually used by the template - this should be handled by seperately registering all required slots as a tuple somewhere else eventually
onMounted(() => {
const requiredSlots = props.template.slots;
const missingSlots = requiredSlots.filter(
(slot) => providedSlots[slot] === undefined,
);
if (missingSlots.length > 0) {
const componentStack = getComponentStack();
throw new Error(
`Template is missing slots!\n\tTemplate: ${props.template.keypath}\n\tMissing Slots: ${missingSlots.join(", ")}${componentStack}`,
);
}
});
</script>
<template>
<OptionalParent :is="tag">
<!-- eslint-disable-next-line vue/no-restricted-html-elements - this is an internal component for this component -->
<RichTemplateParts
:keypath="template.keypath as RichTemplateMessagePath<SlotName>"
:content="template.parts"
:template-uuid-to-template="template.templateUuidToTemplate"
:slots="template.slots">
<template v-for="(slot, name) in slots" :key="name" #[name]>
<RichTemplateParts :content="template.parts" :slots="template.slots">
<template v-for="(slot, name) in providedSlots" :key="name" #[name]>
<component :is="slot" />
</template>
</RichTemplateParts>

View file

@ -1,16 +1,11 @@
<script setup lang="ts" generic="SlotName extends string">
import { type VNode } from "vue";
import {
type CompiledRichTemplatePart,
type RichTemplateMessagePath,
} from "@/i18n";
import { type DeepReadonly, type VNode } from "vue";
import { type CompiledRichTemplatePart } from "@/i18n";
import SmartLink from "../atoms/SmartLink.vue";
defineProps<{
keypath: RichTemplateMessagePath<SlotName>;
content: CompiledRichTemplatePart[];
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
content: DeepReadonly<CompiledRichTemplatePart[]>;
slots: DeepReadonly<SlotName[]>;
}>();
const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
@ -21,23 +16,17 @@ const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
<template v-if="typeof part === 'string'">
{{ part }}
</template>
<template v-else-if="part.type === 'templateUuid'">
<!-- eslint-disable-next-line vue/no-restricted-html-elements - this is the safe wrapper -->
<i18n-t
:keypath="`${keypath}.templateUuidToTemplate.${part.templateUuid}`">
<template v-for="(slot, name) in vueSlots" :key="name" #[name]>
<template v-else-if="part.type === 'slot'">
<template v-for="(slot, name) in vueSlots" :key="name">
<template v-if="name === part.slot">
<component :is="slot" />
</template>
</i18n-t>
</template>
</template>
<template v-else-if="part.type === 'bold'">
<b>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<RichTemplateParts
:keypath="keypath"
:content="part.bold"
:template-uuid-to-template="templateUuidToTemplate"
:slots="slots">
<RichTemplateParts :content="part.bold" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"
@ -50,11 +39,7 @@ const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
<template v-else-if="part.type === 'italic'">
<i>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<RichTemplateParts
:keypath="keypath"
:content="part.italic"
:template-uuid-to-template="templateUuidToTemplate"
:slots="slots">
<RichTemplateParts :content="part.italic" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"
@ -67,11 +52,7 @@ const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
<template v-else-if="part.type === 'link'">
<SmartLink v-bind="part.link.props">
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<RichTemplateParts
:keypath="keypath"
:content="part.link.children"
:template-uuid-to-template="templateUuidToTemplate"
:slots="slots">
<RichTemplateParts :content="part.link.children" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"

View file

@ -4,11 +4,14 @@ import SmartLink from "../atoms/SmartLink.vue";
import type { Value } from "@/utils/types";
import RichTemplate from "../atoms/RichTemplate.vue";
import type { CompileLocale } from "@/i18n";
import type { DeepReadonly } from "vue";
defineProps<{
overview: Value<
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
>["overview"];
overview: DeepReadonly<
Value<
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
>["overview"]
>;
ruleNumber: number;
}>();
</script>

View file

@ -3,11 +3,12 @@ import type { CompileLocale } from "@/i18n";
import type { Locale } from "@/i18n/locale";
import type { Value } from "@/utils/types";
import RichTemplate from "../atoms/RichTemplate.vue";
import type { DeepReadonly } from "vue";
defineProps<{
section: Value<
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
>["section"];
section: DeepReadonly<
Value<CompileLocale<Locale>["discord"]["rulesPage"]["rules"]>["section"]
>;
ruleNumber: number;
}>();
</script>

View file

@ -1,10 +1,10 @@
<script setup lang="ts">
import { LOCALE_IDS, localeId, useI18n, type LocaleId } from "@/i18n";
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/i18n";
import { ref } from "vue";
import { vOnClickOutside } from "@vueuse/components";
import DropdownItem from "../atoms/DropdownItem.vue";
const i18n = useI18n();
const locale = useLocale();
const isOpen = ref<boolean>(false);
@ -32,7 +32,7 @@ const setLocaleId = (id: LocaleId): void => {
aria-haspopup="true"
aria-controls="dropdown-menu"
@click="toggleOpen()">
<span>{{ i18n.t("localeName") }}</span>
<span>{{ locale.localeName }}</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
@ -48,7 +48,7 @@ const setLocaleId = (id: LocaleId): void => {
localeId === id && 'is-active',
]"
@click="setLocaleId(id)">
{{ i18n.t("localeName", { locale: id }) }}
{{ useLocale({ locale: id }).value.localeName }}
</DropdownItem>
</div>
</div>

View file

@ -1,16 +1,19 @@
import en_US from "../locales/en_US";
import vp_VL from "../locales/vp_VL";
import wp_VL from "../locales/wp_VL";
import { computed, readonly, watch, type DeepReadonly } from "vue";
import { computed, type DeepReadonly } from "vue";
import { type Locale, type LocaleMask } from "./locale";
import { useLocalStorage } from "@vueuse/core";
import { type } from "arktype";
import { createI18n as createVueI18n, useI18n as useVueI18n } from "vue-i18n";
import {
fallback,
isMessagePack,
isSlot,
isTemplate,
type DeMessagePack,
type Fallback,
type MessagePack,
type NotMessagePack,
type Template,
} from "./marker";
import { isRichT, type RichTemplate, type RichTemplatePart } from "./rich";
@ -25,47 +28,55 @@ export type CompiledTemplate<SlotName extends string> = {
slots: SlotName[];
};
type _CompileLocale<T> =
type _CompileLocale<T> = DeMessagePack<
T extends Template<infer SlotName> ? CompiledTemplate<SlotName>
: T extends RichTemplate<infer SlotName> ? CompiledRichTemplate<SlotName>
: T extends Function ? T
: T extends (...args: infer Args) => infer Return ?
(...args: Args) => _CompileLocale<Return>
: T extends object ? { [K in keyof T]: _CompileLocale<T[K]> }
: T;
: T
>;
export type CompileLocale<T extends DeepPartialLocale<LocaleMask>> =
_CompileLocale<T>;
type _DeepPartialLocaleObject<T extends object> = {
[K in keyof T]?: T[K] extends object ? _DeepPartialLocale<T[K]> : T[K];
};
type _DeepPartialLocale<T extends object> =
T extends Template<string> ? T
: T extends RichTemplate<string> ? T
: T extends Function ? T
: T extends object ?
{
[K in keyof T]?: T[K] extends object ? _DeepPartialLocale<T[K]>
: T[K];
}
: T;
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: any[]) => any ? T
: T extends MessagePack<unknown> ? MessagePack<_DeepPartialLocaleObject<T>>
: NotMessagePack<T>;
export type DeepPartialLocale<T extends LocaleMask> = _DeepPartialLocale<T>;
function compileLocale<const T extends DeepPartialLocale<LocaleMask>>(
locale: T,
): CompileLocale<T> {
const compiled = compileObject(locale, "");
const compiled = compileObject(locale, undefined);
console.log(compiled);
return compiled;
}
function keypathResolve(...keys: string[]): string {
return keys.filter((key) => key.length > 0).join(".");
function keypathDot(
root: string | undefined,
...dots: (string | symbol)[]
): string {
const dotpath = dots.map((dot) => String(dot)).join(".");
return root === undefined ? dotpath : `${root}.${dotpath}`;
}
function compileObject<const T extends Record<PropertyKey, unknown>>(
obj: T,
keypath: string,
keypath: string | undefined,
): _CompileLocale<T> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const entryKeypath = keypathResolve(keypath, key);
return [key, compileUnknown(value, entryKeypath)] as const;
Reflect.ownKeys(obj).map((key) => {
return [
key,
compileUnknown(obj[key], keypathDot(keypath, key)),
] as const;
}),
) as _CompileLocale<T>;
}
@ -82,13 +93,18 @@ function compileUnknown(value: unknown, keypath: string): unknown {
if (value !== null && typeof value === "object") {
if (Array.isArray(value)) {
return value.map((x, i) =>
compileUnknown(x, keypathResolve(keypath, String(i))),
compileUnknown(x, `${keypath}[${String(i)}]`),
);
}
return compileObject(value as Record<PropertyKey, unknown>, keypath);
}
if (typeof value === "function") {
return (...args: unknown[]) =>
compileUnknown(value(...args), `${keypath}()`);
}
return value;
}
@ -128,23 +144,18 @@ function compileRichTemplate<SlotName extends string>(
): CompiledRichTemplate<SlotName> {
const { parts } = template;
const { compiledParts, templateUuidToTemplate } =
compileRichTemplateParts(parts);
const slots: SlotName[] = [];
const { compiledParts, slots } = compileRichTemplateParts(parts);
return {
[compiledRichTemplateSymbol]: true,
keypath,
parts: compiledParts,
templateUuidToTemplate,
slots,
keypath,
};
}
interface CompileRichTemplatePartRes<SlotName extends string> {
compiledPart: CompiledRichTemplatePart;
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
}
@ -152,50 +163,47 @@ function compileRichTemplatePart<SlotName extends string>(
part: RichTemplatePart<SlotName>,
): CompileRichTemplatePartRes<SlotName> {
if (typeof part === "string") {
return { compiledPart: part, templateUuidToTemplate: {}, slots: [] };
return { compiledPart: part, slots: [] };
}
if (isSlot(part)) {
const templateString = `{${part.name}}`;
const templateUuid = crypto.randomUUID();
return {
compiledPart: { type: "templateUuid", templateUuid },
templateUuidToTemplate: { [templateUuid]: templateString },
compiledPart: { type: "slot", slot: part.name },
slots: [part.name],
};
}
switch (part.type) {
case "bold": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.bold);
const { compiledParts, slots } = compileRichTemplateParts(
part.bold,
);
return {
compiledPart: { type: "bold", bold: compiledParts },
templateUuidToTemplate,
slots,
};
}
case "italic": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.italic);
const { compiledParts, slots } = compileRichTemplateParts(
part.italic,
);
return {
compiledPart: { type: "italic", italic: compiledParts },
templateUuidToTemplate,
slots,
};
}
case "link": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.link.children);
const { compiledParts, slots } = compileRichTemplateParts(
part.link.children,
);
return {
compiledPart: {
type: "link",
link: { children: compiledParts, props: part.link.props },
},
templateUuidToTemplate,
slots,
};
}
@ -204,24 +212,21 @@ function compileRichTemplatePart<SlotName extends string>(
interface CompileRichTemplatePartsRes<SlotName extends string> {
compiledParts: CompiledRichTemplatePart[];
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
}
function compileRichTemplateParts<SlotName extends string>(
parts: RichTemplatePart<SlotName>[],
): CompileRichTemplatePartsRes<SlotName> {
const templateUuidToTemplate = {};
const slots: SlotName[] = [];
const compiledParts = parts.map((part) => {
const res = compileRichTemplatePart(part);
Object.assign(templateUuidToTemplate, res.templateUuidToTemplate);
slots.push(...res.slots);
return res.compiledPart;
});
return { compiledParts, templateUuidToTemplate, slots };
return { compiledParts, slots };
}
const compiledRichTemplateSymbol: unique symbol = Symbol(
@ -229,15 +234,14 @@ const compiledRichTemplateSymbol: unique symbol = Symbol(
);
export interface CompiledRichTemplate<SlotName extends string> {
[compiledRichTemplateSymbol]: true;
keypath: string;
parts: CompiledRichTemplatePart[];
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
keypath: string;
}
export type CompiledRichTemplatePart =
| string
| { type: "templateUuid"; templateUuid: string }
| { type: "slot"; slot: string }
| { type: "bold"; bold: CompiledRichTemplatePart[] }
| { type: "italic"; italic: CompiledRichTemplatePart[] }
| {
@ -264,6 +268,15 @@ const localeIdToCompiledLocale = {
CompileLocale<DeepPartialLocale<LocaleMask>>
>;
// const localeIdToLocale = {
// en_US: en_US,
// vp_VL: vp_VL,
// wp_VL: wp_VL,
// } as const satisfies { [DEFAULT_LOCALE_ID]: Locale } & Record<
// Exclude<LocaleId, typeof DEFAULT_LOCALE_ID>,
// DeepPartialLocale<LocaleMask>
// >;
// users could manually edit localStorage to make this value anything, so we need to validate it
const localStorageLocaleId = useLocalStorage<unknown>(
"localeId",
@ -318,13 +331,83 @@ type DeepFallbackable<T> = {
[K in keyof T]?: DeepFallbackable<T[K]> | Fallback;
};
function fallbackProxy<Fallback extends object>(
// function fallbackProxy<Fallback extends object>(
// maskObj: DeepFallbackable<Fallback>,
// fallbackObj: Fallback,
// ): DeepReadonly<Fallback> {
// type Mask = typeof maskObj;
// const proxy = new Proxy(fallbackObj, {
// get: (_target, rawKey): DeepReadonly<Fallback[keyof Fallback]> => {
// // SAFETY: typescript should ensure we're only ever trying to access keys
// // that exist on Fallback, and if the key doesn't,
// // just process its fallback as if it did,
// // everything should work as expected still
// const key = rawKey as keyof Fallback;
// // value may not exist on mask
// const maskValue: Mask[keyof Fallback] | undefined = maskObj[key];
// // all values exist on fallback
// const fallbackValue: Fallback[keyof Fallback] = fallbackObj[key];
// // this only handles the case where the current value is undefined, not nested ones.
// // thus, `finalValue` is still deeply partial (but not undefined or Fallback)
// const finalValue: Mask[keyof Fallback] =
// maskValue === undefined || maskValue === fallback ?
// fallbackValue
// : maskValue;
// // check if finalValue is not an object
// // if not, it is a primitive
// if (!isObject(finalValue)) {
// // SAFETY: finalValue is not an object, so it is not affected by DeepPartial
// // so `Mask[keyof Fallback]` is the same as `Fallback[keyof Fallback]`
// return deepReadonly(finalValue as Fallback[keyof Fallback]);
// }
// // else, finalValue is an object, so we need to proxy it as well
// // check if fallbackValue is an object so that it can be used as finalValue's fallback
// if (!isObject(fallbackValue)) {
// // if not, we can't use finalValue as we'll have no fallback for it.
// // send the fallbackValue no matter what instead
// return deepReadonly(fallbackValue);
// }
// // else, proxy the returned object to support deep fallback proxying
// return fallbackProxy<Fallback[keyof Fallback] & object>(
// finalValue,
// fallbackValue,
// );
// },
// set: () => {
// throw new Error("Cannot mutate locale at runtime");
// },
// });
// // we're just disallowing mutations to the proxy, since its setter panics if used at runtime
// return deepReadonly(proxy);
// }
function createFallbacked<Fallback extends object>(
maskObj: DeepFallbackable<Fallback>,
fallbackObj: Fallback,
): DeepReadonly<Fallback> {
type Mask = typeof maskObj;
const proxy = new Proxy(fallbackObj, {
get: (_target, rawKey): DeepReadonly<Fallback[keyof Fallback]> => {
// FIXME: This info (what object are message packs) really needs to live somewhere separate from the translation data, probably like a separate locale schema thing like zod wheere you specify what is a message pack, what values can be fallbacked, etc.
if (!isMessagePack(maskObj) || !isMessagePack(fallbackObj)) {
// TODO: add SAFETY comment
return deepReadonly({ ...maskObj } as Fallback);
}
const keys = new Set([
...Reflect.ownKeys(maskObj),
...Reflect.ownKeys(fallbackObj),
]);
const entries = [...keys].map((rawKey) => {
const value = (() => {
// SAFETY: typescript should ensure we're only ever trying to access keys
// that exist on Fallback, and if the key doesn't,
// just process its fallback as if it did,
@ -349,60 +432,82 @@ function fallbackProxy<Fallback extends object>(
if (!isObject(finalValue)) {
// SAFETY: finalValue is not an object, so it is not affected by DeepPartial
// so `Mask[keyof Fallback]` is the same as `Fallback[keyof Fallback]`
return deepReadonly(finalValue as Fallback[keyof Fallback]);
return finalValue as Fallback[keyof Fallback];
}
// else, finalValue is an object, so we need to proxy it as well
// check if fallbackValue is an object so that it can be used as finalValue's fallback
if (!isObject(fallbackValue)) {
// FIXME: this is here because we can't distinguish between objects as organization (fallbackable) and objects as values (e.g. tagged unions, nonfallbackable) currently. Fixing this would be a more in-depth process though
if (fallbackValue === undefined) {
return finalValue;
}
// if not, we can't use finalValue as we'll have no fallback for it.
// send the fallbackValue no matter what instead
return deepReadonly(fallbackValue);
return fallbackValue;
}
// else, proxy the returned object to support deep fallback proxying
return fallbackProxy<Fallback[keyof Fallback] & object>(
return createFallbacked<Fallback[keyof Fallback] & object>(
finalValue,
fallbackValue,
);
},
set: () => {
throw new Error("Cannot mutate locale at runtime");
},
})();
return [rawKey, deepReadonly(value)] as const;
});
// we're just disallowing mutations to the proxy, since its setter panics if used at runtime
return deepReadonly(proxy);
// TODO: add SAFETY comments for all `as` casts
return deepReadonly(Object.fromEntries(entries) as Fallback);
}
const createLocale = (id: LocaleId) =>
fallbackProxy<CompileLocale<Locale>>(
const createFallbackedCompiledLocale = (id: LocaleId) =>
createFallbacked<CompileLocale<Locale>>(
localeIdToCompiledLocale[id],
localeIdToCompiledLocale["en_US"],
localeIdToCompiledLocale[DEFAULT_LOCALE_ID],
);
const createLocaleIdToMessages = (): Record<
// const createFallbackedLocale = (id: LocaleId) =>
// fallbackProxy<Locale>(
// localeIdToLocale[id],
// localeIdToLocale[DEFAULT_LOCALE_ID],
// );
const createLocaleIdToFallbackCompiledLocale = (): Record<
LocaleId,
DeepReadonly<CompileLocale<Locale>>
> => {
return {
en_US: createLocale("en_US"),
vp_VL: createLocale("vp_VL"),
wp_VL: createLocale("wp_VL"),
en_US: createFallbackedCompiledLocale("en_US"),
vp_VL: createFallbackedCompiledLocale("vp_VL"),
wp_VL: createFallbackedCompiledLocale("wp_VL"),
} as const;
};
const vueI18n = createVueI18n<[DeepReadonly<CompileLocale<Locale>>], LocaleId>({
locale: localeId.value,
messages: createLocaleIdToMessages(),
});
// const createLocaleIdToFallbackedLocale = (): Record<
// LocaleId,
// DeepReadonly<Locale>
// > => {
// return {
// en_US: createFallbackedLocale("en_US"),
// vp_VL: createFallbackedLocale("vp_VL"),
// wp_VL: createFallbackedLocale("wp_VL"),
// } as const;
// };
export const vueI18nPlugin = vueI18n;
// const vueI18n = createVueI18n<[DeepReadonly<CompileLocale<Locale>>], LocaleId>({
// locale: localeId.value,
// messages: createLocaleIdToMessages(),
// });
watch(localeId, (id: LocaleId) => {
vueI18n.global.locale = id;
});
// export const vueI18nPlugin = vueI18n;
// watch(localeId, (id: LocaleId) => {
// vueI18n.global.locale = id;
// });
// // TODO: eventually set up a lint to require this to be used with vue-i18n's t/$t
// export function tPath<const T extends StringResourcePath<Locale>>(path: T): T {
@ -500,23 +605,32 @@ export interface I18nVOptions {
locale?: LocaleId;
}
export const useI18n = () => {
return readonly({
t: (path: StringMessagePath, opt: I18nTOptions = {}): string => {
const localLocaleId = opt.locale ?? localeId.value;
return vueI18n.global.t(path, {}, { locale: localLocaleId });
},
v: <P extends MessagePath>(
path: P,
opt: I18nVOptions = {},
): MessageValue<P> => {
const localLocaleId = opt.locale ?? localeId.value;
// const localeIdToFallbackedLocale = createLocaleIdToFallbackedLocale();
const localVueI18n = useVueI18n({ locale: localLocaleId });
const localeIdToFallbackedCompiledLocale =
createLocaleIdToFallbackCompiledLocale();
return localVueI18n.tm(path);
},
// export const useI18n = () => {
// return readonly({
// t: (path: StringMessagePath, opt: I18nTOptions = {}): string => {
// const localLocaleId = opt.locale ?? localeId.value;
// return localeIdToFallbackedLocale[localLocaleId].
// },
// v: <P extends MessagePath>(
// path: P,
// opt: I18nVOptions = {},
// ): MessageValue<P> => {
// const localLocaleId = opt.locale ?? localeId.value;
// const localVueI18n = useVueI18n({ locale: localLocaleId });
// return localVueI18n.tm(path);
// },
// });
// };
export const useLocale = (opt: UseLocaleOptions = {}) =>
computed<DeepReadonly<CompileLocale<Locale>>>(() => {
const localLocaleId = opt.locale ?? localeId.value;
return localeIdToFallbackedCompiledLocale[localLocaleId];
});
};
export const i18nPlugin = vueI18n;

View file

@ -1,10 +1,10 @@
import type { DeepRemoveFallback, Fallback } from "./marker";
import type { DeepRemoveFallback, Fallback, MessagePack } from "./marker";
import type { RichTemplate } from "./rich";
import type { VilanticId } from "./vilantic";
export interface Locale extends DeepRemoveFallback<LocaleMask> {}
export interface LocaleMask {
export type LocaleMask = MessagePack<{
localeName: string;
vilanticLangs: VilanticLangs;
navbar: Navbar;
@ -12,78 +12,66 @@ export interface LocaleMask {
resources: ResourcesPage;
kotoba: KotobaPage;
discord: Discord;
}
}>;
export interface VilanticLangs extends Record<VilanticId, string> {}
export type VilanticLangs = MessagePack<Record<VilanticId, string>>;
export interface Navbar
extends Record<"whatIsViossa" | "resources" | "kotoba", string> {}
export type Navbar = MessagePack<
Record<"whatIsViossa" | "resources" | "kotoba", string>
>;
export interface HomePage {
sections: HomeSections;
}
export type HomePage = MessagePack<{ sections: HomeSections }>;
export interface HomeSections
extends Record<
"whatIsViossa" | "historyOfViossa" | "community",
HomeSection
> {}
export type HomeSections = MessagePack<
Record<"whatIsViossa" | "historyOfViossa" | "community", HomeSection>
>;
export interface HomeSection {
export type HomeSection = MessagePack<{
title: string;
text: string;
image: Image | null;
}
}>;
export interface ResourcesPage {
export type ResourcesPage = MessagePack<{
title: string;
resources: Resources;
}
}>;
export interface Resources {
discord: Resource<"join" | "rules">;
}
export type Resources = MessagePack<{ discord: Resource<"join" | "rules"> }>;
export interface Resource<ButtonKey extends string> {
export type Resource<ButtonKey extends string> = MessagePack<{
title: string;
subtitle: string;
desc: string;
image: Image | null;
buttons: Record<ButtonKey, Button>;
}
buttons: MessagePack<Record<ButtonKey, Button>>;
}>;
export interface KotobaPage {
title: string;
searchHelp: string;
}
export type KotobaPage = MessagePack<{ title: string; searchHelp: string }>;
export interface Button {
label: string;
}
export type Button = MessagePack<{ label: string }>;
// coupled to require alt text for all images
export interface Image {
export type Image = MessagePack<{
src: string | Fallback; // fallback can be used if image doesn't need to be translated
alt: string;
}
}>;
export interface Discord {
rulesPage: DiscordRulesPage;
}
export type Discord = MessagePack<{ rulesPage: DiscordRulesPage }>;
export interface DiscordRulesPage {
export type DiscordRulesPage = MessagePack<{
title: string;
overview: DiscordRulesPageOverview;
rules: DiscordRules;
}
}>;
export interface DiscordRulesPageOverview {
export type DiscordRulesPageOverview = MessagePack<{
title: string;
help: string;
}
}>;
export interface DiscordRules
extends Record<
export type DiscordRules = MessagePack<
Record<
| "noTranslation"
| "lfsv"
| "viossaOnlyChats"
@ -92,22 +80,23 @@ export interface DiscordRules
| "respectStaff"
| "controversialTopics",
DiscordRule
> {}
>
>;
export interface DiscordRule {
export type DiscordRule = MessagePack<{
overview: DiscordRuleOverview;
section: DiscordRuleSection;
}
}>;
export interface DiscordRuleOverview {
export type DiscordRuleOverview = MessagePack<{
text: RichTemplate<never>;
subtext: RichTemplate<never> | null;
}
}>;
export interface DiscordRuleSection {
export type DiscordRuleSection = MessagePack<{
header: (ctx: { ruleNumber: number }) => string;
body: DiscordRuleSectionBodyElement[];
}
}>;
export type DiscordRuleSectionBodyElement =
| { type: "paragraph"; paragraph: RichTemplate<never> }

View file

@ -34,8 +34,53 @@ export function isSlot(value: unknown): value is Slot<string> {
}
export type DeepRemoveFallback<T> = Exclude<
T extends Function ? T
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: any[]) => any ? T
: T extends object ? { [K in keyof T]: DeepRemoveFallback<T[K]> }
: T,
Fallback
>;
const messagePackSymbol: unique symbol = Symbol("messagePack");
export type MessagePack<T> = Omit<T, typeof messagePackSymbol> & {
[messagePackSymbol]: true;
};
export type NotMessagePack<T> = Omit<T, typeof messagePackSymbol> & {
[messagePackSymbol]?: undefined;
};
export type DeMessagePack<T> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: any[]) => any ? T
: T extends unknown[] ? T
: T extends object ? Omit<T, typeof messagePackSymbol>
: T;
export type DeepStrictMessagePackValues<T> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: any[]) => any ? T
: T extends object ?
T extends MessagePack<T> ?
MessagePack<{ [K in keyof T]: DeepStrictMessagePackValues<T[K]> }>
: NotMessagePack<{ [K in keyof T]: DeepStrictMessagePackValues<T[K]> }>
: T;
export function messagePack<T>(
value: T extends NotMessagePack<unknown> ? never
: Omit<T, typeof messagePackSymbol>,
): T {
return { ...value, [messagePackSymbol]: true };
}
export function isMessagePack<T>(
value: T,
): value is (T extends MessagePack<infer U> ? MessagePack<U>
: MessagePack<unknown>)
& T {
return (
value !== null
&& typeof value === "object"
&& messagePackSymbol in value
);
}

View file

@ -47,7 +47,7 @@ export type RichTemplatePart<SlotName extends string> =
}
| Slot<SlotName>;
export function richT<SlotName extends string>(
export function richT<SlotName extends string = never>(
...parts: RichTemplatePart<SlotName>[]
): RichTemplate<SlotName> {
return { [richTemplateSymbol]: true, parts };
@ -61,19 +61,19 @@ export function isRichT(value: unknown): value is RichTemplate<string> {
);
}
export function boldT<SlotName extends string>(
export function boldT<SlotName extends string = never>(
...children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]]
): RichTemplatePart<SlotName> & { type: "bold" } {
return { type: "bold", bold: children };
}
export function italicT<SlotName extends string>(
export function italicT<SlotName extends string = never>(
...children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]]
): RichTemplatePart<SlotName> & { type: "italic" } {
return { type: "italic", italic: children };
}
export function linkT<SlotName extends string>(ctx: {
export function linkT<SlotName extends string = never>(ctx: {
children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]];
props: SmartLinkProps;
}): RichTemplatePart<SlotName> & { type: "link" } {

View file

@ -2,67 +2,77 @@ import { type Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png";
import { boldT, italicT, linkT, richT } from "@/i18n/rich";
import { messagePack, type DeepStrictMessagePackValues } from "@/i18n/marker";
export default {
export default messagePack({
localeName: "English",
vilanticLangs: { viossa: "Viossa", wodox: "Wodoch" },
navbar: {
vilanticLangs: messagePack({ viossa: "Viossa", wodox: "Wodoch" }),
navbar: messagePack({
whatIsViossa: "What is Viossa?",
resources: "Resources",
kotoba: "Kotoba",
},
home: {
sections: {
whatIsViossa: {
}),
home: messagePack({
sections: messagePack({
whatIsViossa: messagePack({
title: "What is Viossa?",
text: "Viossa is a community-created artificial pidgin language, created to simulate the formation of natural pidgin languages. Viossa is characterized by its lack of standardization, with each speaker developing a personal idiolect. Spelling and pronunciation can vary greatly, and serve as a form of personal self-expression. Viossa is learnt and taught entirely by immersion — translation is prohibited while learning.",
image: { src: flakkaImg, alt: "Flag of the Viossa Language" },
},
historyOfViossa: {
image: messagePack({
src: flakkaImg,
alt: "Flag of the Viossa Language",
}),
}),
historyOfViossa: messagePack({
title: "History of Viossa",
text: "Viossa began as a Skype group in 2014, created by members of the r/conlangs community on Reddit, as an experiment to simulate the formation of a pidgin language. Pidgins are simplified languages resulting from contact between populations with no shared common language. Unlike most pidgins, which usually have two to three contributor languages, Viossa comes from many diverse languages. This is because people from all around the world helped to contribute to Viossa's vocabulary.",
image: { src: flakkaImg, alt: "Flag of the Viossa Language" },
},
community: {
image: messagePack({
src: flakkaImg,
alt: "Flag of the Viossa Language",
}),
}),
community: messagePack({
title: "Community",
text: "The Viossa community is rich and colourful, drawing from many global traditions due to its worldwide online membership. Since the teaching culture puts an emphasis on linguistic immersion, and discourages prescriptivism, the culture of Viossa is as diverse and varied as the language and the people who speak it. For many, their personal dialect is a key form of identity and expression. The fluid nature of Viossa and lack of defined meanings makes Viossa popular for creative purposes, such as poetry and songwriting.",
image: null,
},
},
},
resources: {
}),
}),
}),
resources: messagePack({
title: "Learning Resources",
resources: {
discord: {
resources: messagePack({
discord: messagePack({
title: "Discord Server",
subtitle:
"This is where most of the action happens! Hop on in!",
desc: "Originally started in 2015 something something read the rules here, then click the link below to join!",
image: { src: discordImg, alt: "Discord logo" },
buttons: { join: { label: "Join" }, rules: { label: "Rules" } },
},
},
},
kotoba: {
image: messagePack({ src: discordImg, alt: "Discord logo" }),
buttons: messagePack({
join: messagePack({ label: "Join" }),
rules: messagePack({ label: "Rules" }),
}),
}),
}),
}),
kotoba: messagePack({
title: "Tropos-agnostic search",
searchHelp: "To searcn tropos-agnostically, enter a term below.",
},
discord: {
rulesPage: {
}),
discord: messagePack({
rulesPage: messagePack({
title: "Discord Server Rules",
overview: {
overview: messagePack({
title: "Overview",
help: "Click any rule to see details.",
},
rules: {
noTranslation: {
overview: {
}),
rules: messagePack({
noTranslation: messagePack({
overview: messagePack({
text: richT(
"No translation! Do not translate to/from Viossa on the server, except the big four translatables (you can learn in hard mode without them!)",
),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: No translation`,
body: [
@ -97,14 +107,14 @@ export default {
),
},
],
},
},
lfsv: {
overview: {
}),
}),
lfsv: messagePack({
overview: messagePack({
text: richT("If it's understood, it's Viossa."),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: If it's understood, it's Viossa`,
body: [
@ -121,16 +131,16 @@ export default {
),
},
],
},
},
viossaOnlyChats: {
overview: {
}),
}),
viossaOnlyChats: messagePack({
overview: messagePack({
text: richT(
"The chats in the Viossa Only category are Viossa only.",
),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Viossa-only chats`,
body: [
@ -149,16 +159,16 @@ export default {
),
},
],
},
},
sfw: {
overview: {
}),
}),
sfw: messagePack({
overview: messagePack({
text: richT(
"This server is SFW. No sexually explicit, gory, or violent content.",
),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: SFW`,
body: [
@ -179,16 +189,16 @@ export default {
),
},
],
},
},
respectOthers: {
overview: {
}),
}),
respectOthers: messagePack({
overview: messagePack({
text: richT(
"Don't use hate speech, and respect each other.",
),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Respect one another`,
body: [
@ -199,10 +209,10 @@ export default {
),
},
],
},
},
respectStaff: {
overview: {
}),
}),
respectStaff: messagePack({
overview: messagePack({
text: richT(
"Respect the rulings of the staff (",
boldT("@Yewald"),
@ -211,8 +221,8 @@ export default {
").",
),
subtext: null,
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Respect the staff's rulings`,
body: [
@ -235,10 +245,10 @@ export default {
),
},
],
},
},
controversialTopics: {
overview: {
}),
}),
controversialTopics: messagePack({
overview: messagePack({
text: richT(
"Discussion of controversial topics (politics, war, etc.) should be directed to ",
boldT("#polite"),
@ -254,8 +264,8 @@ export default {
boldT("#feels-and-advice"),
" is for talking about your feelings openly, but we draw the line at suicidal or violent ideation. These are trains of thought to be brought to a therapist, and are not jokes. Because of their seriousness, they simply don't belong here.",
),
},
section: {
}),
section: messagePack({
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: #polite and ike`,
body: [
@ -334,9 +344,9 @@ export default {
),
},
],
},
},
},
},
},
} as const satisfies Locale;
}),
}),
}),
}),
}),
} as const) satisfies DeepStrictMessagePackValues<Locale>;

View file

@ -1,14 +1,15 @@
import type { DeepPartialLocale } from "@/i18n";
import { type LocaleMask } from "@/i18n/locale";
import type { DeepPartial } from "@/utils/types";
import { messagePack } from "@/i18n/marker";
export default {
export default messagePack({
localeName: "Viossa",
home: {
sections: {
whatIsViossa: {
home: messagePack({
sections: messagePack({
whatIsViossa: messagePack({
title: "Kafaen afto Viossa",
text: "Viossa tte glossa mahena grun vi nai vil fshtojena na bakadjin, grun vi svinnur ja! De aldjin zovti lera ne",
},
},
},
} as const satisfies DeepPartial<LocaleMask>;
}),
}),
}),
} as const) satisfies DeepPartialLocale<LocaleMask>;

View file

@ -1,66 +1,52 @@
import { fallback } from "@/i18n/marker";
import { fallback, messagePack } from "@/i18n/marker";
import { type LocaleMask } from "@/i18n/locale";
import type { DeepPartial } from "@/utils/types";
import { richT } from "@/i18n/rich";
import type { DeepPartialLocale } from "@/i18n";
export default {
export default messagePack({
localeName: "wodox",
vilanticLangs: { viossa: "viosox", wodox: "wodox" },
navbar: {
vilanticLangs: messagePack({ viossa: "viosox", wodox: "wodox" }),
navbar: messagePack({
whatIsViossa: "viosox e ano?",
resources: "tropos",
kotoba: "mot o viosox",
},
home: {
sections: {
whatIsViossa: {
}),
home: messagePack({
sections: messagePack({
whatIsViossa: messagePack({
title: "viosox e ano?",
text: "viosox e hez ox pamzal, zoz stende zalkun tuo mit multa nengwi ox. zal o viosox stende lik zal o hez il ox keta, zalilkun wi tuo mit multa nengwi ox. mono i fal o viosox stendenai; omni axsi o viosox zal nengokun fal o viosox, de falmot wi falax o il stende e keko trenengwi tua o nengwi stende, ge fala e keko lik ro o tuo viosoxsi. genil viosox ibe il wi nengwi stende axkun ge pisakun po tuo ox — stende gen muskunnai mit zaiox.",
image: { src: fallback, alt: "fomma o viosox" },
},
historyOfViossa: {
image: messagePack({ src: fallback, alt: "fomma o viosox" }),
}),
historyOfViossa: messagePack({
title: "zal o viosox",
text: "wi o zal o viosox stende po multa o Skype wi 2014 ibe stendera o multa r/conlangs o Reddit. zalsi o viosox danzalgo hez, tuo zal o viosox e lik zal o hez il ox keta, zalil tuo ox mit nengwi multa ox ibe zalsi fiemnaikun sama i ox. viosox e nengwi tuo ox pamzal keta; zalilkun keko ox keta mit lik du wi tre ox, aga zalil viosox mit multa wi plus obo o ox na il ox keta ibe zalsi o viosox stende po multa mi o mo.",
image: { src: fallback, alt: "fomma o viosox" },
},
community: {
image: messagePack({ src: fallback, alt: "fomma o viosox" }),
}),
community: messagePack({
title: "viosoxsi",
text: "nengwi multa ro o viosoxsi stende ibe stendenura po nengwi multa mi o mo ge wekakunnura zai nengwi viosoxsi po jilobo. ibe mono i fal o viosox stendenai ge ibe viosoxsi zalkun nengwi multa fal o viosox, de nengwi zoz ko o ro lik ro o viosoxsi stende po ro o viosa. po multa hez viosoxsi, falmot wi falax o tuo stende stende po ro o tuo stende. ibe mono i fal o viosox stendenai ge ibe ro o mot inkun nengwi po nengwi viosoxsi, de multa stende amanata hez, zal zalgonukun surat au mola au sucik.",
image: null,
},
},
},
resources: {
}),
}),
}),
resources: messagePack({
title: "tropos o gen",
resources: {
discord: {
resources: messagePack({
discord: messagePack({
title: "server o Diskord",
subtitle: "axilkun ge genilkun po ce! wekatutsa!",
desc: "danzalil hez server po 2015. ibe dutukun musra po ce, de ibe wiftutsakun dof po pam, de wekatukun po server!",
image: { src: fallback, alt: "surat o Diskord" },
buttons: {
join: { label: "wekatutsa" },
rules: { label: "musra" },
},
},
},
},
kotoba: {
image: messagePack({ src: fallback, alt: "surat o Diskord" }),
buttons: messagePack({
join: messagePack({ label: "wekatutsa" }),
rules: messagePack({ label: "musra" }),
}),
}),
}),
}),
kotoba: messagePack({
title: "zalkuketutsa mot o viosox mit il o omni falmot",
searchHelp:
"ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun.",
},
discord: {
rulesPage: {
rules: {
lfsv: {
section: {
body: [
{ type: "header", header: richT("wawaawawaawawa") },
],
},
},
},
},
},
} as const satisfies DeepPartial<LocaleMask>;
}),
} as const) satisfies DeepPartialLocale<LocaleMask>;

View file

@ -1,6 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { vueI18nPlugin } from "./i18n";
createApp(App).use(router).use(vueI18nPlugin).mount("#app");
createApp(App).use(router).mount("#app");

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
import { useI18n } from "@/i18n";
import { useLocale } from "@/i18n";
import { computed } from "vue";
const i18n = useI18n();
const locale = useLocale();
const pageI18n = computed(() => i18n.v("discord.rulesPage"));
const pageI18n = computed(() => locale.value.discord.rulesPage);
const rules = computed(() => pageI18n.value.rules);
const RULE_ORDER = [
@ -18,31 +18,6 @@ const RULE_ORDER = [
"respectStaff",
"controversialTopics",
] as const satisfies (keyof typeof pageI18n.value.rules)[];
function unwrapProxy<T extends object>(
value: T,
maxDepth: number,
depth: number = 0,
): T {
if (depth > maxDepth) {
return value;
}
const entries = Object.entries(value);
const unwrappedEntries = entries.map(
([key, value]) =>
[
key,
value !== null && typeof value === "object" ?
unwrapProxy(value, maxDepth, depth + 1)
: value,
] as const,
);
return Object.fromEntries(unwrappedEntries) as T;
}
console.log(unwrapProxy(rules.value, 5));
</script>
<template>

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { useI18n } from "@/i18n";
import { useLocale } from "@/i18n";
import { GREETINGS, type Greeting } from "@/i18n/greeting";
import type { Locale } from "@/i18n/locale";
import { VILANTIC_ID_TO_FLAG } from "@/i18n/vilantic";
import { randomElement } from "@/utils/random";
import { computed } from "vue";
const i18n = useI18n();
const locale = useLocale();
const greeting: Greeting = randomElement(GREETINGS);
@ -18,7 +18,7 @@ const SECTION_ORDER = [
] as const satisfies (keyof Locale["home"]["sections"])[];
const sections = computed(() =>
SECTION_ORDER.map((id) => i18n.v(`home.sections.${id}`)),
SECTION_ORDER.map((id) => locale.value.home.sections[id]),
);
</script>
@ -35,7 +35,7 @@ const sections = computed(() =>
<div
class="subtitle is-size-6 is-flex is-flex-direction-row is-align-items-center is-gap-1 has-text-text-bold">
&mdash; {{ greeting.author }} ({{
i18n.v("vilanticLangs")[greeting.lang]
locale.vilanticLangs[greeting.lang]
}})
<figure class="image is-32x32">
<img :src="VILANTIC_ID_TO_FLAG[greeting.lang]" />

View file

@ -1,18 +1,18 @@
<script setup lang="ts">
import { useI18n } from "@/i18n";
import { useLocale } from "@/i18n";
const i18n = useI18n();
const locale = useLocale();
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ i18n.t("kotoba.title") }}</h1>
<h1 class="title">{{ locale.kotoba.title }}</h1>
</section>
<section class="section container">
<div class="notification is-info block">
<p>{{ i18n.t("kotoba.searchHelp") }}</p>
<p>{{ locale.kotoba.searchHelp }}</p>
</div>
<div class="block is-flex is-flex-direction-row is-gap-2">

View file

@ -2,14 +2,14 @@
import LearningResourceWrapper, {
type ResourceButton,
} from "@/components/molecules/LearningResourceWrapper.vue";
import { useI18n } from "@/i18n";
import { useLocale, type CompileLocale } from "@/i18n";
import type { Locale } from "@/i18n/locale";
import { ignore } from "@/utils/ignore";
import { computed } from "vue";
const i18n = useI18n();
const locale = useLocale();
const resourceIdToResource = computed(() => i18n.v("resources.resources"));
const resourceIdToResource = computed(() => locale.value.resources.resources);
const RESOURCE_ORDER = [
"discord",
@ -20,7 +20,7 @@ const resources = computed(() =>
);
const computeButtons = (
id: keyof Locale["resources"]["resources"],
id: keyof CompileLocale<Locale>["resources"]["resources"],
): ResourceButton[] => {
// will warn us if a new variant is added that isn't handled, and so we should add a switch
// once we have a switch statement, this won't be needed as that will check for exhaustiveness
@ -53,7 +53,7 @@ const computeButtons = (
<template>
<div>
<section class="section">
<h1 class="title">{{ i18n.t("resources.title") }}</h1>
<h1 class="title">{{ locale.resources.title }}</h1>
</section>
<section class="section container">