wip: RichText & Templating for i18n, refactored & began i18n for Discord Server Rules using the new features

This commit is contained in:
Benjamin Singleton 2026-02-14 22:56:17 -06:00
parent feacb9b754
commit a3adc2526f
25 changed files with 929 additions and 141 deletions

View file

@ -32,7 +32,13 @@ export default defineConfig([
"error",
{
element: ["a", "RouterLink"],
message: "Use <SmartLink> instead",
message: "Use <SmartLink> instead.",
},
{ element: ["i18n-t"], message: "Use <RichTemplate> instead." },
{
element: ["RichTemplateParts"],
message:
"Do not use the internal <RichTemplateParts> component. Use <RichTemplate> instead.",
},
],
// allow interfaces to only extend another interface without adding properties

View file

@ -3,11 +3,13 @@ import "./assets/style.scss";
import { computed, ref, type Ref } from "vue";
import LocalePicker from "./components/organisms/LocalePicker.vue";
import { vOnClickOutside } from "@vueuse/components";
import { useLocale } from "./i18n";
import { useRouter } from "vue-router";
import SmartLink from "./components/organisms/SmartLink.vue";
import SmartLink from "./components/atoms/SmartLink.vue";
import type { SmartDest } from "./utils/smart-dest";
import type { Locale } from "./i18n/locale";
import { useI18n } from "./i18n";
const i18n = useI18n();
const burgerOpen: Ref<boolean> = ref<boolean>(false);
@ -19,8 +21,6 @@ const closeBurger = (): void => {
burgerOpen.value = false;
};
const locale = useLocale();
const router = useRouter();
router.beforeEach(() => {
closeBurger();
@ -39,7 +39,7 @@ const NAVBAR_ITEM_ORDER = [
const navbarItems = computed(() =>
NAVBAR_ITEM_ORDER.map((id): NavbarItem => {
const label = locale.value.navbar[id];
const label = i18n.t(`navbar.${id}`);
const to = ((): SmartDest => {
switch (id) {

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from "vue";
defineProps<{
onClick: AnchorHTMLAttributes["onClick"];
}>() satisfies AnchorHTMLAttributes;
</script>
<template>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we're wrapping it into a useable type -->
<a v-bind="$props">
<slot />
</a>
</template>

View file

@ -0,0 +1,45 @@
<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

@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ is?: string | object | null | undefined }>();
</script>
<template>
<component v-if="is !== null && is !== undefined" :is="is">
<slot />
</component>
<template v-else>
<slot />
</template>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts" generic="SlotName extends string">
import { type VNode } from "vue";
import {
type CompiledRichTemplate,
type RichTemplateMessagePath,
} from "@/i18n";
import RichTemplateParts from "./RichTemplateParts.vue";
import OptionalParent from "./OptionalParent.vue";
defineProps<{ template: CompiledRichTemplate<SlotName>; tag?: string }>();
const slots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
</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]>
<component :is="slot" />
</template>
</RichTemplateParts>
</OptionalParent>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts" generic="SlotName extends string">
import { type VNode } from "vue";
import {
type CompiledRichTemplatePart,
type RichTemplateMessagePath,
} from "@/i18n";
import SmartLink from "../atoms/SmartLink.vue";
defineProps<{
keypath: RichTemplateMessagePath<SlotName>;
content: CompiledRichTemplatePart[];
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
}>();
const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
</script>
<template>
<template v-for="(part, index) in content" :key="index">
<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]>
<component :is="slot" />
</template>
</i18n-t>
</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">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</RichTemplateParts>
</b>
</template>
<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">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</RichTemplateParts>
</i>
</template>
<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">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</RichTemplateParts>
</SmartLink>
</template>
</template>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import type { RichText, RichTextPart } from "../../i18n/rich"; // relative import for vue sfc compiler
const props = defineProps<{ content: RichText | RichTextPart[] }>();
const parts = computed<RichTextPart[]>(() => {
const content = props.content;
if (Array.isArray(content)) {
return content;
}
return content.parts;
});
console.log(parts.value);
</script>
<template>
<template v-for="(part, index) in parts" :key="index">
<template v-if="typeof part === 'string'">
{{ part }}
</template>
<template v-else-if="part.type === 'bold'">
<b><RichText :content="part.bold" /></b>
</template>
<template v-else-if="part.type === 'italic'">
<i><RichText :content="part.italic" /></i>
</template>
</template>
</template>

View file

@ -0,0 +1,7 @@
import type { SmartDest } from "@/utils/smart-dest";
export interface SmartLinkProps {
to: SmartDest;
newTab?: boolean;
covert?: boolean;
}

View file

@ -1,13 +1,7 @@
<script setup lang="ts">
import type { CssClass } from "@/utils/css";
import type { SmartDest } from "../../utils/smart-dest"; // needs to be relative for vue sfc compiler
import { computed, ref } from "vue";
export interface SmartLinkProps {
to: SmartDest;
newTab?: boolean;
covert?: boolean;
}
import type { SmartLinkProps } from "./SmartLink";
const props = defineProps<SmartLinkProps>();

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Locale } from "@/i18n/locale";
import SmartLink from "../atoms/SmartLink.vue";
import type { Value } from "@/utils/types";
import RichTemplate from "../atoms/RichTemplate.vue";
import type { CompileLocale } from "@/i18n";
defineProps<{
overview: Value<
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
>["overview"];
ruleNumber: number;
}>();
</script>
<template>
<span>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: `rule-${ruleNumber}` } }"
:style="{ width: 'fit-content', display: 'inline-block' }">
<li :style="{ width: 'fit-content' }">
<RichTemplate :template="overview.text" />
<ul v-if="overview.subtext !== null" class="mt-0 w-fit">
<RichTemplate
tag="li"
:style="{ width: 'fit-content' }"
:template="overview.subtext" />
</ul>
</li>
</SmartLink>
</span>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import SmartLink, { type SmartLinkProps } from "../organisms/SmartLink.vue";
import type { SmartLinkProps } from "../atoms/SmartLink";
import type { CssClass } from "@/utils/css";
import SmartLink from "../atoms/SmartLink.vue";
export interface ResourceButton {
label: string;

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/i18n";
import { LOCALE_IDS, localeId, useI18n, type LocaleId } from "@/i18n";
import { ref } from "vue";
import { vOnClickOutside } from "@vueuse/components";
import DropdownItem from "../atoms/DropdownItem.vue";
const i18n = useI18n();
const isOpen = ref<boolean>(false);
@ -29,7 +32,7 @@ const setLocaleId = (id: LocaleId): void => {
aria-haspopup="true"
aria-controls="dropdown-menu"
@click="toggleOpen()">
<span>{{ useLocale().value.localeName }}</span>
<span>{{ i18n.t("localeName") }}</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
@ -37,14 +40,16 @@ const setLocaleId = (id: LocaleId): void => {
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a
<DropdownItem
v-for="(id, index) in LOCALE_IDS"
:key="index"
href="#"
:class="['dropdown-item', localeId === id && 'is-active']"
:class="[
'dropdown-item is-clickable',
localeId === id && 'is-active',
]"
@click="setLocaleId(id)">
{{ useLocale({ locale: id }).value.localeName }}
</a>
{{ i18n.t("localeName", { locale: id }) }}
</DropdownItem>
</div>
</div>
</div>

View file

@ -1,16 +1,238 @@
import en_US from "../locales/en_US";
import vp_VL from "../locales/vp_VL";
import wp_VL from "../locales/wp_VL";
import { computed, readonly, type DeepReadonly } from "vue";
import {
fallback,
type Fallback,
type Locale,
type LocaleMask,
} from "./locale";
import { computed, readonly, watch, type DeepReadonly } from "vue";
import { type Locale, type LocaleMask } from "./locale";
import { useLocalStorage } from "@vueuse/core";
import { type } from "arktype";
import type { DeepPartial } from "@/utils/types";
import { createI18n as createVueI18n, useI18n as useVueI18n } from "vue-i18n";
import {
fallback,
isSlot,
isTemplate,
type Fallback,
type Template,
} from "./marker";
import { isRichT, type RichTemplate, type RichTemplatePart } from "./rich";
import type { SmartLinkProps } from "@/components/atoms/SmartLink";
// opaque type to stop people from accessing raw template string on accident
// and to track slot names used in the template for strict typing
const compiledTemplate: unique symbol = Symbol("compiledTemplate");
export type CompiledTemplate<SlotName extends string> = {
[compiledTemplate]: true;
template: string;
slots: SlotName[];
};
type _CompileLocale<T> =
T extends Template<infer SlotName> ? CompiledTemplate<SlotName>
: T extends RichTemplate<infer SlotName> ? CompiledRichTemplate<SlotName>
: { [K in keyof T]: _CompileLocale<T[K]> };
export type CompileLocale<T extends DeepPartialLocale<LocaleMask>> =
_CompileLocale<T>;
type _DeepPartialLocale<T extends object> =
T extends Template<string> ? T
: { [K in keyof T]?: T[K] extends object ? _DeepPartialLocale<T[K]> : T[K] };
export type DeepPartialLocale<T extends LocaleMask> = _DeepPartialLocale<T>;
function compileLocale<const T extends DeepPartialLocale<LocaleMask>>(
locale: T,
): CompileLocale<T> {
return compileObject(locale, "");
}
function compileObject<const T extends Record<PropertyKey, unknown>>(
obj: T,
keypath: string,
): _CompileLocale<T> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const entryKeypath = `${keypath}.${key}`;
if (isTemplate(value)) {
return [key, compileTemplate(value)] as const;
}
if (isRichT(value)) {
return [key, compileRichTemplate(value, entryKeypath)] as const;
}
if (
value !== null
&& typeof value === "object"
&& !Array.isArray(value)
) {
return [
key,
compileObject(
value as Record<PropertyKey, unknown>,
entryKeypath,
),
] as const;
}
return [key, value] as const;
}),
) as _CompileLocale<T>;
}
function compileTemplate<SlotName extends string>(
template: Template<SlotName>,
): CompiledTemplate<SlotName> {
const { parts } = template;
let templateString = "";
const slots: SlotName[] = [];
for (const part of parts) {
if (typeof part === "string") {
templateString += part
.split("")
.map((c) => {
if (c === "{" || c === "}" || c === "|") {
return `{'${c}'}`;
}
return c;
})
.join("");
continue;
}
templateString += `{${part.name}}`;
slots.push(part.name);
}
return { [compiledTemplate]: true, template: templateString, slots };
}
function compileRichTemplate<SlotName extends string>(
template: RichTemplate<SlotName>,
keypath: string,
): CompiledRichTemplate<SlotName> {
const { parts } = template;
const { compiledParts, templateUuidToTemplate } =
compileRichTemplateParts(parts);
const slots: SlotName[] = [];
return {
[compiledRichTemplateSymbol]: true,
keypath,
parts: compiledParts,
templateUuidToTemplate,
slots,
};
}
interface CompileRichTemplatePartRes<SlotName extends string> {
compiledPart: CompiledRichTemplatePart;
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
}
function compileRichTemplatePart<SlotName extends string>(
part: RichTemplatePart<SlotName>,
): CompileRichTemplatePartRes<SlotName> {
if (typeof part === "string") {
return { compiledPart: part, templateUuidToTemplate: {}, slots: [] };
}
if (isSlot(part)) {
const templateString = `{${part.name}}`;
const templateUuid = crypto.randomUUID();
return {
compiledPart: { type: "templateUuid", templateUuid },
templateUuidToTemplate: { [templateUuid]: templateString },
slots: [part.name],
};
}
switch (part.type) {
case "bold": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.bold);
return {
compiledPart: { type: "bold", bold: compiledParts },
templateUuidToTemplate,
slots,
};
}
case "italic": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.italic);
return {
compiledPart: { type: "italic", italic: compiledParts },
templateUuidToTemplate,
slots,
};
}
case "link": {
const { compiledParts, templateUuidToTemplate, slots } =
compileRichTemplateParts(part.link.children);
return {
compiledPart: {
type: "link",
link: { children: compiledParts, props: part.link.props },
},
templateUuidToTemplate,
slots,
};
}
}
}
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 };
}
const compiledRichTemplateSymbol: unique symbol = Symbol(
"compiledRichTemplate",
);
export interface CompiledRichTemplate<SlotName extends string> {
[compiledRichTemplateSymbol]: true;
keypath: string;
parts: CompiledRichTemplatePart[];
templateUuidToTemplate: Record<string, string>;
slots: SlotName[];
}
export type CompiledRichTemplatePart =
| string
| { type: "templateUuid"; templateUuid: string }
| { type: "bold"; bold: CompiledRichTemplatePart[] }
| { type: "italic"; italic: CompiledRichTemplatePart[] }
| {
type: "link";
link: {
children: CompiledRichTemplatePart[];
props: SmartLinkProps;
};
};
export const LOCALE_IDS = ["en_US", "vp_VL", "wp_VL"] as const;
@ -19,11 +241,13 @@ export const LocaleId = type.enumerated(...LOCALE_IDS);
export const DEFAULT_LOCALE_ID = "en_US" satisfies LocaleId;
const locales = { en_US, vp_VL, wp_VL } as const satisfies {
[DEFAULT_LOCALE_ID]: Locale;
} & Record<
const localeIdToCompiledLocale = {
en_US: compileLocale(en_US),
vp_VL: compileLocale(vp_VL),
wp_VL: compileLocale(wp_VL),
} as const satisfies { [DEFAULT_LOCALE_ID]: CompileLocale<Locale> } & Record<
Exclude<LocaleId, typeof DEFAULT_LOCALE_ID>,
DeepPartial<LocaleMask>
CompileLocale<DeepPartialLocale<LocaleMask>>
>;
// users could manually edit localStorage to make this value anything, so we need to validate it
@ -52,16 +276,16 @@ export const localeId = computed({
},
});
export const useLocale = (opt: UseLocaleOptions = {}) => {
const locale = computed<DeepReadonly<Locale>>(() => {
return fallbackProxy<Locale>(
locales[opt.locale ?? localeId.value],
locales["en_US"],
);
});
// export const useLocale = (opt: UseLocaleOptions = {}) => {
// const locale = computed<DeepReadonly<Locale>>(() => {
// return fallbackProxy<Locale>(
// locales[opt.locale ?? localeId.value],
// locales["en_US"],
// );
// });
return readonly(locale);
};
// return readonly(locale);
// };
export interface UseLocaleOptions {
locale?: LocaleId;
@ -137,3 +361,148 @@ function fallbackProxy<Fallback extends object>(
// we're just disallowing mutations to the proxy, since its setter panics if used at runtime
return deepReadonly(proxy);
}
const createLocale = (id: LocaleId) =>
fallbackProxy<CompileLocale<Locale>>(
localeIdToCompiledLocale[id],
localeIdToCompiledLocale["en_US"],
);
const createLocaleIdToMessages = (): Record<
LocaleId,
DeepReadonly<CompileLocale<Locale>>
> => {
return {
en_US: createLocale("en_US"),
vp_VL: createLocale("vp_VL"),
wp_VL: createLocale("wp_VL"),
} as const;
};
const vueI18n = createVueI18n<[DeepReadonly<CompileLocale<Locale>>], LocaleId>({
locale: localeId.value,
messages: createLocaleIdToMessages(),
});
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 {
// return path;
// }
type StringResourcePath<T> = _StringResourcePath<T, ResourcePath<T>>;
type _StringResourcePath<T, TP extends ResourcePath<T>> =
TP extends infer P extends ResourcePath<T> ?
ResourceValue<T, P> extends string ?
P
: never
: never;
type TemplateResourcePath<T> = _TemplateResourcePath<T, ResourcePath<T>>;
type _TemplateResourcePath<T, TP extends ResourcePath<T>> =
TP extends infer P extends ResourcePath<T> ?
ResourceValue<T, P> extends CompiledTemplate<string> ?
P
: never
: never;
type RichTemplateResourcePath<
T,
SlotName extends string,
> = _RichTemplateResourcePath<T, ResourcePath<T>, SlotName>;
type _RichTemplateResourcePath<
T,
TP extends ResourcePath<T>,
SlotName extends string,
> =
TP extends infer P extends ResourcePath<T> ?
ResourceValue<T, P> extends CompiledRichTemplate<SlotName> ?
P
: never
: never;
// source: https://github.com/intlify/vue-i18n/issues/1116
// ---- Taken from https://github.com/intlify/vue-i18n/blob/v9.9.1/packages/core-base/src/types/utils.ts
type __ResourcePath<T, Key extends keyof T> =
Key extends string ?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T[Key] extends Record<string, any> ?
| `${Key}.${__ResourcePath<
T[Key],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Exclude<keyof T[Key], keyof any[]>
>
& string}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| `${Key}.${Exclude<keyof T[Key], keyof any[]> & string}`
: never
: never;
type _ResourcePath<T> = __ResourcePath<T, keyof T> | keyof T;
type ResourcePath<T> =
_ResourcePath<T> extends string | keyof T ? _ResourcePath<T> : keyof T;
type ResourceValue<T, P extends ResourcePath<T>> =
P extends `${infer Key}.${infer Rest}` ?
Key extends keyof T ?
Rest extends ResourcePath<T[Key]> ?
ResourceValue<T[Key], Rest>
: never
: never
: P extends keyof T ? T[P]
: never;
// ----
// Currying to simplify the types
export type MessagePath = ResourcePath<CompileLocale<Locale>>;
export type StringMessagePath = StringResourcePath<CompileLocale<Locale>>;
export type TemplateMessagePath = TemplateResourcePath<CompileLocale<Locale>>;
export type RichTemplateMessagePath<SlotName extends string> =
RichTemplateResourcePath<CompileLocale<Locale>, SlotName>;
export type MessageValue<Path extends ResourcePath<CompileLocale<Locale>>> =
ResourceValue<CompileLocale<Locale>, Path>;
// type I18nType = typeof localeIdToVueI18n;
// type PatchedI18nType = Omit<I18nType, "global"> & {
// global: Omit<I18nType["global"], "tm"> & {
// // t(path: MessagePath, named?: NamedValue | string[]): string;
// tm<Path extends MessagePath>(
// path: Path,
// named?: NamedValue | string[],
// ): MessageValue<Path>;
// };
// };
export interface I18nTOptions {
locale?: LocaleId;
}
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 localVueI18n = useVueI18n({ locale: localLocaleId });
return localVueI18n.tm(path);
},
});
};
export const i18nPlugin = vueI18n;

View file

@ -1,16 +1,7 @@
import type { DeepRemoveFallback, Fallback } from "./marker";
import type { RichTemplate } from "./rich";
import type { VilanticId } from "./vilantic";
// special symbol to explicitly specify when to fallback to default (en_US) translation
// instead of translating a specific key, which still not being marked as a missing translation.
// can only be used on keys which are typed as being able to use a fallback.
export const fallback: unique symbol = Symbol("fallback");
export type Fallback = typeof fallback;
type DeepRemoveFallback<T> = Exclude<
{ [K in keyof T]: DeepRemoveFallback<T[K]> },
Fallback
>;
export interface Locale extends DeepRemoveFallback<LocaleMask> {}
export interface LocaleMask {
@ -91,13 +82,23 @@ export interface DiscordRulesPageOverview {
help: string;
}
export interface DiscordRules extends Record<"noTranslation", DiscordRule> {}
export interface DiscordRules
extends Record<
| "noTranslation"
| "lfsv"
| "viossaOnlyChats"
| "sfw"
| "respectOthers"
| "respectStaff"
| "controversialTopics",
DiscordRule
> {}
export interface DiscordRule {
overview: DiscordRuleOverview;
}
export interface DiscordRuleOverview {
text: string;
subtext: string | null;
text: RichTemplate<never>;
subtext: RichTemplate<never> | null;
}

View file

@ -0,0 +1,39 @@
// special symbol to explicitly specify when to fallback to default (en_US) translation
// instead of translating a specific key, which still not being marked as a missing translation.
// can only be used on keys which are typed as being able to use a fallback.
export const fallback: unique symbol = Symbol("fallback");
export type Fallback = typeof fallback;
const templateSymbol: unique symbol = Symbol("template");
export type Template<SlotName extends string> = {
[templateSymbol]: true;
parts: (string | Slot<SlotName>)[];
};
const slotSymbol: unique symbol = Symbol("slot");
export type Slot<Name extends string> = { [slotSymbol]: true; name: Name };
export function template<SlotName extends string>(
...parts: (string | Slot<SlotName>)[]
): Template<SlotName> {
return { [templateSymbol]: true, parts };
}
export function isTemplate(value: unknown): value is Template<string> {
return (
value !== null && typeof value === "object" && templateSymbol in value
);
}
export function slot<Name extends string>(name: Name): Slot<Name> {
return { [slotSymbol]: true, name };
}
export function isSlot(value: unknown): value is Slot<string> {
return value !== null && typeof value === "object" && slotSymbol in value;
}
export type DeepRemoveFallback<T> = Exclude<
{ [K in keyof T]: DeepRemoveFallback<T[K]> },
Fallback
>;

View file

@ -0,0 +1,81 @@
import type { Slot } from "./marker";
import type { SmartLinkProps } from "@/components/atoms/SmartLink";
const richTextSymbol: unique symbol = Symbol("richText");
export interface RichText {
[richTextSymbol]: true;
parts: RichTextPart[];
}
export function rich(...parts: RichTextPart[]): RichText {
return { [richTextSymbol]: true, parts };
}
export type RichTextPart =
| string
| { type: "bold"; bold: RichTextPart[] }
| { type: "italic"; italic: RichTextPart[] };
export function bold(
...content: RichTextPart[]
): RichTextPart & { type: "bold" } {
return { type: "bold", bold: content };
}
export function italic(
...content: RichTextPart[]
): RichTextPart & { type: "italic" } {
return { type: "italic", italic: content };
}
const richTemplateSymbol: unique symbol = Symbol("richTemplate");
export interface RichTemplate<SlotName extends string> {
[richTemplateSymbol]: true;
parts: RichTemplatePart<SlotName>[];
}
export type RichTemplatePart<SlotName extends string> =
| string
| { type: "bold"; bold: RichTemplatePart<SlotName>[] }
| { type: "italic"; italic: RichTemplatePart<SlotName>[] }
| {
type: "link";
link: {
children: RichTemplatePart<SlotName>[];
props: SmartLinkProps;
};
}
| Slot<SlotName>;
export function richT<SlotName extends string>(
...parts: RichTemplatePart<SlotName>[]
): RichTemplate<SlotName> {
return { [richTemplateSymbol]: true, parts };
}
export function isRichT(value: unknown): value is RichTemplate<string> {
return (
value !== null
&& typeof value === "object"
&& richTemplateSymbol in value
);
}
export function boldT<SlotName extends string>(
...children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]]
): RichTemplatePart<SlotName> & { type: "bold" } {
return { type: "bold", bold: children };
}
export function italicT<SlotName extends string>(
...children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]]
): RichTemplatePart<SlotName> & { type: "italic" } {
return { type: "italic", italic: children };
}
export function linkT<SlotName extends string>(ctx: {
children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]];
props: SmartLinkProps;
}): RichTemplatePart<SlotName> & { type: "link" } {
return { type: "link", link: ctx };
}

View file

@ -1,6 +1,7 @@
import type { Locale } from "@/i18n/locale";
import { type Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png";
import { boldT, richT } from "@/i18n/rich";
export default {
localeName: "English",
@ -25,7 +26,7 @@ export default {
community: {
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: { src: flakkaImg, alt: "Flag of the Viossa Language" },
image: null,
},
},
},
@ -53,6 +54,77 @@ export default {
title: "Overview",
help: "Click any rule to see details.",
},
rules: {
noTranslation: {
overview: {
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,
},
},
lfsv: {
overview: {
text: richT("If it's understood, it's Viossa."),
subtext: null,
},
},
viossaOnlyChats: {
overview: {
text: richT(
"The chats in the Viossa Only category are Viossa only.",
),
subtext: null,
},
},
sfw: {
overview: {
text: richT(
"This server is SFW. No sexually explicit, gory, or violent content.",
),
subtext: null,
},
},
respectOthers: {
overview: {
text: richT(
"Don't use hate speech, and respect each other.",
),
subtext: null,
},
},
respectStaff: {
overview: {
text: richT(
"Respect the rulings of the staff (",
boldT("@Yewald"),
" and ",
boldT("@Yewaldnen"),
").",
),
subtext: null,
},
},
controversialTopics: {
overview: {
text: richT(
"Discussion of controversial topics (politics, war, etc.) should be directed to ",
boldT("#polite"),
", which requires the ",
boldT("@Ike"),
" role to view, which is itself locked behind ",
boldT("@Viossadjin"),
" and ",
boldT("@mellandjin"),
".",
),
subtext: richT(
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.",
),
},
},
},
},
},
} as const satisfies Locale;

View file

@ -1,4 +1,5 @@
import { fallback, type LocaleMask } from "@/i18n/locale";
import { fallback } from "@/i18n/marker";
import { type LocaleMask } from "@/i18n/locale";
import type { DeepPartial } from "@/utils/types";
export default {

View file

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

View file

@ -1,83 +1,41 @@
<script setup lang="ts">
import SmartLink from "@/components/organisms/SmartLink.vue";
import { useLocale } from "@/i18n";
import SmartLink from "@/components/atoms/SmartLink.vue";
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import { useI18n } from "@/i18n";
const locale = useLocale();
const i18n = useI18n();
const rules = i18n.v("discord.rulesPage.rules");
const RULE_ORDER = [
"noTranslation",
"lfsv",
"viossaOnlyChats",
"sfw",
"respectOthers",
"respectStaff",
"controversialTopics",
] as const satisfies (keyof typeof rules)[];
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.discord.rulesPage.title }}</h1>
<h1 class="title">
{{ i18n.t("discord.rulesPage.title") }}
</h1>
</section>
<section class="section content">
<h2>{{ locale.discord.rulesPage.overview.title }}</h2>
<h2>{{ i18n.t("discord.rulesPage.overview.title") }}</h2>
<blockquote>
{{ locale.discord.rulesPage.overview.help }}
{{ i18n.t("discord.rulesPage.overview.help") }}
</blockquote>
<ol>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-1' } }">
<li>
No translation! Do not translate to/from Viossa on the
server, except the big four translatables (you can learn
in hard mode without them!)
</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-2' } }">
<li>If it's understood, it's Viossa.</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-3' } }">
<li>
The chats in the Viossa Only category are Viossa only.
</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-4' } }">
<li>
This server is SFW. No sexually explicit, gory, or
violent content.
</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-5' } }">
<li>Don't use hate speech, and respect each other.</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-6' } }">
<li>
Respect the rulings of the staff (<b>@Yewald</b> and
<b>@Yewaldnen</b>).
</li>
</SmartLink>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: 'rule-7' } }">
<li>
Discussion of controversial topics (politics, war, etc.)
should be directed to #polite, which requires the @Ike
role to view, which is itself locked behind
<b>@Viossadjin</b> and <b>@mellandjin</b>.
<ul class="mt-0">
<li>
<b>#feels-and-advice</b> 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.
</li>
</ul>
</li>
</SmartLink>
<ol :style="{ display: 'flex', flexDirection: 'column' }">
<DiscordRuleOverview
v-for="(id, index) in RULE_ORDER"
:key="index"
:rule-number="index + 1"
:overview="rules[id].overview" />
</ol>
</section>
<section class="section content" id="rule-1">

View file

@ -1,13 +1,14 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { useLocale } from "@/i18n";
import { useI18n } 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 locale = useLocale();
const i18n = useI18n();
const greeting: Greeting = randomElement(GREETINGS);
const SECTION_ORDER = [
@ -17,7 +18,7 @@ const SECTION_ORDER = [
] as const satisfies (keyof Locale["home"]["sections"])[];
const sections = computed(() =>
SECTION_ORDER.map((id) => locale.value.home.sections[id]),
SECTION_ORDER.map((id) => i18n.v(`home.sections.${id}`)),
);
</script>
@ -34,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 }} ({{
locale.vilanticLangs[greeting.lang]
i18n.v("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 { useLocale } from "@/i18n";
import { useI18n } from "@/i18n";
const locale = useLocale();
const i18n = useI18n();
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.kotoba.title }}</h1>
<h1 class="title">{{ i18n.t("kotoba.title") }}</h1>
</section>
<section class="section container">
<div class="notification is-info block">
<p>{{ locale.kotoba.searchHelp }}</p>
<p>{{ i18n.t("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 { useLocale } from "@/i18n";
import { useI18n } from "@/i18n";
import type { Locale } from "@/i18n/locale";
import { ignore } from "@/utils/ignore";
import { computed } from "vue";
const locale = useLocale();
const i18n = useI18n();
const resourceIdToResource = computed(() => locale.value.resources.resources);
const resourceIdToResource = computed(() => i18n.v("resources.resources"));
const RESOURCE_ORDER = [
"discord",
@ -53,7 +53,7 @@ const computeButtons = (
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.resources.title }}</h1>
<h1 class="title">{{ i18n.t("resources.title") }}</h1>
</section>
<section class="section container">

View file

@ -12,6 +12,11 @@
"noUncheckedIndexedAccess": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"lib": [
"ESNext",
"DOM",
],
"rootDir": "src",
"paths": {
"@/*": [