wip: Discord Rules page fully using i18n, i18n function messages, fixed Locale compiler bugs, still some bugs with locale fallbacks that need fixing

This commit is contained in:
Benjamin Singleton 2026-02-15 13:55:22 -06:00
parent a3adc2526f
commit 771a8cc4bf
9 changed files with 361 additions and 193 deletions

View file

@ -7,8 +7,12 @@ import {
import RichTemplateParts from "./RichTemplateParts.vue";
import OptionalParent from "./OptionalParent.vue";
defineProps<{ template: CompiledRichTemplate<SlotName>; tag?: string }>();
const props = defineProps<{
template: CompiledRichTemplate<SlotName>;
tag?: string;
}>();
const slots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
console.log(Object.entries(props.template));
</script>
<template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { CompileLocale } from "@/i18n";
import type { Locale } from "@/i18n/locale";
import type { Value } from "@/utils/types";
import RichTemplate from "../atoms/RichTemplate.vue";
defineProps<{
section: Value<
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
>["section"];
ruleNumber: number;
}>();
</script>
<template>
<section class="section content" :id="`rule-${ruleNumber}`">
<h2>{{ section.header({ ruleNumber }) }}</h2>
<template v-for="(element, index) in section.body" :key="index">
<p v-if="element.type === 'paragraph'">
<RichTemplate :template="element.paragraph" />
</p>
<h3 v-else-if="element.type === 'header'">
<RichTemplate :template="element.header" />
</h3>
<ul v-else-if="element.type === 'ulist'">
<li v-for="(li, index) in element.ulist" :key="index">
<RichTemplate :template="li" />
</li>
</ul>
</template>
</section>
</template>

View file

@ -28,19 +28,34 @@ export type CompiledTemplate<SlotName extends string> = {
type _CompileLocale<T> =
T extends Template<infer SlotName> ? CompiledTemplate<SlotName>
: T extends RichTemplate<infer SlotName> ? CompiledRichTemplate<SlotName>
: { [K in keyof T]: _CompileLocale<T[K]> };
: T extends Function ? T
: T extends object ? { [K in keyof T]: _CompileLocale<T[K]> }
: T;
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] };
: 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;
export type DeepPartialLocale<T extends LocaleMask> = _DeepPartialLocale<T>;
function compileLocale<const T extends DeepPartialLocale<LocaleMask>>(
locale: T,
): CompileLocale<T> {
return compileObject(locale, "");
const compiled = compileObject(locale, "");
console.log(compiled);
return compiled;
}
function keypathResolve(...keys: string[]): string {
return keys.filter((key) => key.length > 0).join(".");
}
function compileObject<const T extends Record<PropertyKey, unknown>>(
@ -49,35 +64,34 @@ function compileObject<const T extends Record<PropertyKey, unknown>>(
): _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;
const entryKeypath = keypathResolve(keypath, key);
return [key, compileUnknown(value, entryKeypath)] as const;
}),
) as _CompileLocale<T>;
}
function compileUnknown(value: unknown, keypath: string): unknown {
if (isTemplate(value)) {
return compileTemplate(value);
}
if (isRichT(value)) {
return compileRichTemplate(value, keypath);
}
if (value !== null && typeof value === "object") {
if (Array.isArray(value)) {
return value.map((x, i) =>
compileUnknown(x, keypathResolve(keypath, String(i))),
);
}
return compileObject(value as Record<PropertyKey, unknown>, keypath);
}
return value;
}
function compileTemplate<SlotName extends string>(
template: Template<SlotName>,
): CompiledTemplate<SlotName> {

View file

@ -96,9 +96,20 @@ export interface DiscordRules
export interface DiscordRule {
overview: DiscordRuleOverview;
section: DiscordRuleSection;
}
export interface DiscordRuleOverview {
text: RichTemplate<never>;
subtext: RichTemplate<never> | null;
}
export interface DiscordRuleSection {
header: (ctx: { ruleNumber: number }) => string;
body: DiscordRuleSectionBodyElement[];
}
export type DiscordRuleSectionBodyElement =
| { type: "paragraph"; paragraph: RichTemplate<never> }
| { type: "header"; header: RichTemplate<never> }
| { type: "ulist"; ulist: RichTemplate<never>[] };

View file

@ -34,6 +34,8 @@ export function isSlot(value: unknown): value is Slot<string> {
}
export type DeepRemoveFallback<T> = Exclude<
{ [K in keyof T]: DeepRemoveFallback<T[K]> },
T extends Function ? T
: T extends object ? { [K in keyof T]: DeepRemoveFallback<T[K]> }
: T,
Fallback
>;

View file

@ -1,7 +1,7 @@
import { type Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png";
import { boldT, richT } from "@/i18n/rich";
import { boldT, italicT, linkT, richT } from "@/i18n/rich";
export default {
localeName: "English",
@ -62,12 +62,66 @@ export default {
),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: No translation`,
body: [
{
type: "paragraph",
paragraph: richT(
"Translation is not how we learn and teach Viossa. Instead, we teach using pictures, diagrams, video calls, and other aids to couple words to meaning.",
),
},
{
type: "paragraph",
paragraph: richT(
"On the Viossa Diskordserver, you are allowed to translate the following four words. If you want an extra challenge, don't unspoiler the text:",
),
},
{
type: "paragraph",
paragraph: richT(
boldT(italicT("TODO - big 4")),
),
},
{
type: "paragraph",
paragraph: richT(
"Outside of the teaching-learning cycle, we also make an exception for artistic translations (such as those of songs, books, or poems), as well as for academic translations (such as for a formal research paper). In both cases, this exception is dependent on translations of either class appearing in the appropriate place. If you're not sure where that is, please ask.",
),
},
{
type: "paragraph",
paragraph: richT(
"Additionally, please don't attempt to derive or share translation-based learning materials on-server, or poach members for such a purpose.",
),
},
],
},
},
lfsv: {
overview: {
text: richT("If it's understood, it's Viossa."),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: If it's understood, it's Viossa`,
body: [
{
type: "paragraph",
paragraph: richT(
"All that is required to speak Viossa is that other speakers be able to understand you. There is no right or wrong way to speak or write, and no global standard.",
),
},
{
type: "paragraph",
paragraph: richT(
"However, Viossa is a collaborative group project: members should strive to make others understand them, and in return make an effort to understand others.",
),
},
],
},
},
viossaOnlyChats: {
overview: {
@ -76,6 +130,26 @@ export default {
),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Viossa-only chats`,
body: [
{
type: "paragraph",
paragraph: richT(
"Chats in the Viossa Only section do not permit English. If you must use English to coach learners on the learning process, go to ",
boldT("#meta"),
" instead.",
),
},
{
type: "paragraph",
paragraph: richT(
"This doesn't mean that other channels are English-only, though! Viossa is allowed everywhere.",
),
},
],
},
},
sfw: {
overview: {
@ -84,6 +158,28 @@ export default {
),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: SFW`,
body: [
{
type: "paragraph",
paragraph: richT(
"If a mod does not like what you have posted, they will inform you; see ",
linkT({
children: ["Rule 6"],
props: {
to: {
type: "internal",
internal: { id: "rule-6" },
},
},
}),
". This is a public Discord server; think before you post.",
),
},
],
},
},
respectOthers: {
overview: {
@ -92,6 +188,18 @@ export default {
),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Respect one another`,
body: [
{
type: "paragraph",
paragraph: richT(
"Respect one another. Using slurs or hate speech against others, whether on- or off-server, or advocating for violence are not welcome. This is an LGBTQ+ friendly international community.",
),
},
],
},
},
respectStaff: {
overview: {
@ -104,6 +212,30 @@ export default {
),
subtext: null,
},
section: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: Respect the staff's rulings`,
body: [
{
type: "paragraph",
paragraph: richT(
"The word of staff (the Yewald as well as the Yewaldnen) is final, and they may kick, ban, or mute members or change members' access permissions to make sure this environment stays respectful and puts the Viossa community first.",
),
},
{
type: "paragraph",
paragraph: richT(
"Appeals will always be considered, and if you feel that a mod action was inappropriate, you can DM any Yewald or open a ticket with YAGPDB's /tickets open command.",
),
},
{
type: "paragraph",
paragraph: richT(
"If you are banned, there will be instructions on how to appeal the ban, however, please take the time to reflect on the ban reason before appealing.",
),
},
],
},
},
controversialTopics: {
overview: {
@ -123,6 +255,86 @@ export default {
" 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: {
header: ({ ruleNumber }) =>
`Rule ${String(ruleNumber)}: #polite and ike`,
body: [
{
type: "paragraph",
paragraph: richT(
"Many are life's troubling realities, and vast is our need to discuss them. The ike category is an opt-in set of chats where discussion of heavy, sensitive, or potentially contentious topics is allowed, provided that users are especially respectful of each other during such discussions. By accepting the ike role, you agree to adhere to this rule and encourage others to do the same.",
),
},
{
type: "header",
header: richT("Venting vs seeking advice"),
},
{
type: "paragraph",
paragraph: richT(
"Sometimes you want to let people know that you're dealing with an issue and just be acknowledged, other times you want help in solving a problem. If you are open to one but not the other, it's often a good idea to let people know as part of the discussion so that you can receive the kind of responses you are looking for.",
),
},
{
type: "header",
header: richT(`Self-harm and Violence`),
},
{
type: "paragraph",
paragraph: richT(
"While discussing self-harm in general is allowed (with appropriate and clear use of content warnings), this server in itself is not an emergency mental health resource, and is not a substitute for professional help. Asking others for advice in finding support or resources off-server is fine, but asking others to participate in talking you down is inappropriate.",
),
},
{
type: "paragraph",
paragraph: richT(
"You should not use this space to:",
),
},
{
type: "ulist",
ulist: [
richT(
"express intent or desire to harm yourself or others",
),
richT(
"solicit help in stopping yourself from harming yourself or someone else",
),
],
},
{
type: "paragraph",
paragraph: richT(
"By crossing these boundaries, please be aware that you are asking members of the server (including moderators and the owner) to perform a role for which they are not trained or equipped. At the moderators' sole discretion, this may not be tolerated and may result in a warning, timeout, removal of the ",
boldT("@ike"),
" role, or removal from the server.",
),
},
{
type: "paragraph",
paragraph: richT(
"If you are struggling with thoughts of this nature, but are not immediately in danger, please consider seeking counseling. If you are experiencing an immediate crisis, please call ",
boldT("988"),
" (in the United States), ",
boldT("999"),
" (in the UK), or locate an emergency hotline appropriate for you. A list of resources by country exists here: ",
linkT({
children: [
"https://blog.opencounseling.com/suicide-hotlines/",
],
props: {
to: {
type: "external",
external:
"https://blog.opencounseling.com/suicide-hotlines/",
},
newTab: true,
},
}),
),
},
],
},
},
},
},

View file

@ -1,6 +1,7 @@
import { fallback } from "@/i18n/marker";
import { type LocaleMask } from "@/i18n/locale";
import type { DeepPartial } from "@/utils/types";
import { richT } from "@/i18n/rich";
export default {
localeName: "wodox",
@ -49,4 +50,17 @@ export default {
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>;

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import SmartLink from "@/components/atoms/SmartLink.vue";
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
import { useI18n } from "@/i18n";
import { computed } from "vue";
const i18n = useI18n();
const rules = i18n.v("discord.rulesPage.rules");
const pageI18n = computed(() => i18n.v("discord.rulesPage"));
const rules = computed(() => pageI18n.value.rules);
const RULE_ORDER = [
"noTranslation",
@ -15,20 +17,45 @@ const RULE_ORDER = [
"respectOthers",
"respectStaff",
"controversialTopics",
] as const satisfies (keyof typeof rules)[];
] 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>
<div>
<section class="section">
<h1 class="title">
{{ i18n.t("discord.rulesPage.title") }}
{{ pageI18n.title }}
</h1>
</section>
<section class="section content">
<h2>{{ i18n.t("discord.rulesPage.overview.title") }}</h2>
<h2>{{ pageI18n.overview.title }}</h2>
<blockquote>
{{ i18n.t("discord.rulesPage.overview.help") }}
{{ pageI18n.overview.help }}
</blockquote>
<ol :style="{ display: 'flex', flexDirection: 'column' }">
<DiscordRuleOverview
@ -38,158 +65,10 @@ const RULE_ORDER = [
:overview="rules[id].overview" />
</ol>
</section>
<section class="section content" id="rule-1">
<h2>Rule 1: No translation</h2>
<p>
Translation is not how we learn and teach Viossa. Instead, we
teach using pictures, diagrams, video calls, and other aids to
couple words to meaning.
</p>
<p>
On the Viossa Diskordserver, you are allowed to translate the
following four words. If you want an extra challenge, don't
unspoiler the text:
</p>
<blockquote>TODO - big 4</blockquote>
<p>
Outside of the teaching-learning cycle, we also make an
exception for artistic translations (such as those of songs,
books, or poems), as well as for academic translations (such as
for a formal research paper). In both cases, this exception is
dependent on translations of either class appearing in the
appropriate place. If you're not sure where that is, please ask.
</p>
<p>
Additionally, please don't attempt to derive or share
translation-based learning materials on-server, or poach members
for such a purpose.
</p>
</section>
<section class="section content" id="rule-2">
<h2>Rule 2: If it's understood, it's Viossa</h2>
<p>
All that is required to speak Viossa is that other speakers be
able to understand you. There is no right or wrong way to speak
or write, and no global standard.
</p>
<p>
However, Viossa is a collaborative group project: members should
strive to make others understand them, and in return make an
effort to understand others.
</p>
</section>
<section class="section content" id="rule-3">
<h2>Rule 3: Viossa-only chats</h2>
<p>
Chats in the Viossa Only section do not permit English. If you
must use English to coach learners on the learning process, go
to <b>#meta</b> instead.
</p>
<p>
This doesn't mean that other channels are English-only, though!
Viossa is allowed everywhere.
</p>
</section>
<section class="section content" id="rule-4">
<h2>Rule 4: SFW</h2>
<p>
If a mod does not like what you have posted, they will inform
you; see
<SmartLink
:to="{ type: 'internal', internal: { id: 'rule-6' } }"
>Rule 6</SmartLink
>. This is a public Discord server; think before you post.
</p>
</section>
<section class="section content" id="rule-5">
<h2>Rule 5: Respect one another</h2>
<p>
Respect one another. Using slurs or hate speech against others,
whether on- or off-server, or advocating for violence are not
welcome. This is an LGBTQ+ friendly international community.
</p>
</section>
<section class="section content" id="rule-6">
<h2>Rule 6: Respect the staff's rulings</h2>
<p>
The word of staff (the Yewald as well as the Yewaldnen) is
final, and they may kick, ban, or mute members or change
members' access permissions to make sure this environment stays
respectful and puts the Viossa community first.
</p>
<p>
Appeals will always be considered, and if you feel that a mod
action was inappropriate, you can DM any Yewald or open a ticket
with YAGPDB's /tickets open command.
</p>
<p>
If you are banned, there will be instructions on how to appeal
the ban, however, please take the time to reflect on the ban
reason before appealing.
</p>
</section>
<section class="section content" id="rule-7">
<h2>Rule 7: #polite and ike</h2>
<p>
Many are life's troubling realities, and vast is our need to
discuss them. The ike category is an opt-in set of chats where
discussion of heavy, sensitive, or potentially contentious
topics is allowed, provided that users are especially respectful
of each other during such discussions. By accepting the ike
role, you agree to adhere to this rule and encourage others to
do the same.
</p>
<h3>Venting vs seeking advice</h3>
<p>
Sometimes you want to let people know that you're dealing with
an issue and just be acknowledged, other times you want help in
solving a problem. If you are open to one but not the other,
it's often a good idea to let people know as part of the
discussion so that you can receive the kind of responses you are
looking for.
</p>
<h3>Self-harm and Violence</h3>
<p>
While discussing self-harm in general is allowed (with
appropriate and clear use of content warnings), this server in
itself is not an emergency mental health resource, and is not a
substitute for professional help. Asking others for advice in
finding support or resources off-server is fine, but asking
others to participate in talking you down is inappropriate.
</p>
<p>You should not use this space to:</p>
<ul>
<li>express intent or desire to harm yourself or others</li>
<li>
solicit help in stopping yourself from harming yourself or
someone else
</li>
</ul>
<p>
By crossing these boundaries, please be aware that you are
asking members of the server (including moderators and the
owner) to perform a role for which they are not trained or
equipped. At the moderators' sole discretion, this may not be
tolerated and may result in a warning, timeout, removal of the
@ike role, or removal from the server.
</p>
<p>
If you are struggling with thoughts of this nature, but are not
immediately in danger, please consider seeking counseling. If
you are experiencing an immediate crisis, please call 988 (in
the United States), 999 (in the UK), or locate an emergency
hotline appropriate for you. A list of resources by country
exists here:
<SmartLink
:to="{
type: 'external',
external:
'https://blog.opencounseling.com/suicide-hotlines/',
}"
new-tab
>https://blog.opencounseling.com/suicide-hotlines/</SmartLink
>
</p>
</section>
<DiscordRuleSection
v-for="(id, index) in RULE_ORDER"
:key="index"
:section="rules[id].section"
:rule-number="index + 1" />
</div>
</template>

View file

@ -1,6 +1,6 @@
export type DeepPartial<T extends object> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
export type DeepPartial<T extends object> =
T extends Function ? T
: { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
export type Prettify<T> = T extends object ? { [K in keyof T]: T[K] } & {} : T;
export type Value<T> = T[keyof T];