wip: working new i18n system (need to polish still)
This commit is contained in:
parent
a240a1b954
commit
d258014471
28 changed files with 1025 additions and 1769 deletions
|
|
@ -6,8 +6,7 @@ import { vOnClickOutside } from "@vueuse/components";
|
|||
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 { useLocale } from "./i18n";
|
||||
import { useLocale, type Locale } from "./new-i18n";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
|
|
@ -39,7 +38,7 @@ const NAVBAR_ITEM_ORDER = [
|
|||
|
||||
const navbarItems = computed(() =>
|
||||
NAVBAR_ITEM_ORDER.map((id): NavbarItem => {
|
||||
const label = locale.value.navbar[id];
|
||||
const label = locale.value.navbar[id]();
|
||||
|
||||
const to = ((): SmartDest => {
|
||||
switch (id) {
|
||||
|
|
|
|||
|
|
@ -11,19 +11,67 @@ home-sections-historyOfViossa-body = Viossa began as a Skype group in 2014, crea
|
|||
home-sections-community-title = Community
|
||||
home-sections-community-body = 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.
|
||||
home-images-viossaFlag-alt = Flag of the Viossa Language
|
||||
|
||||
richTest-slot = This is a \<slot\>!
|
||||
richTest-placeable = This is a { $wow } { $placeable ->
|
||||
[one] <
|
||||
*[other] multiple
|
||||
} { $placeable ->
|
||||
[on] single
|
||||
*[other] multiple
|
||||
} { $placeable ->
|
||||
[one] single
|
||||
*[other] multiple
|
||||
} { $placeable ->
|
||||
[one] single
|
||||
*[other] >
|
||||
} thing!
|
||||
richTest-bold = This is \*\*bolded\*\*!
|
||||
resources-title = Learning Resources
|
||||
resources-resources-discord-title = Discord Server
|
||||
resources-resources-discord-subtitle = This is where most of the action happens! Hop on in!
|
||||
resources-resources-discord-desc = Originally started in 2015 something something read the rules here, then click the link below to join!
|
||||
resources-resources-discord-buttons-join-label = Join
|
||||
resources-resources-discord-buttons-rules-label = Rules
|
||||
resources-images-discordLogo-alt = Discord logo
|
||||
kotoba-title = Tropos-agnostic search
|
||||
kotoba-searchHelp = To searcn tropos-agnostically, enter a term below.
|
||||
discord-rulesPage-title = Discord Server Rules
|
||||
discord-rulesPage-overview-title = Overview
|
||||
discord-rulesPage-overview-help = Click any rule to see details.
|
||||
discord-rulesPage-rules-noTranslation-overview-text = | No translation! Do not translate to/from Viossa on the server, except the big four translatables (you can learn in hard mode without them!)
|
||||
discord-rulesPage-rules-noTranslation-overview-subtext = |~
|
||||
discord-rulesPage-rules-noTranslation-section-header = Rule { $ruleNumber }: No translation
|
||||
discord-rulesPage-rules-noTranslation-section-body =
|
||||
| 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.
|
||||
| On the Viossa Diskordserver, you are allowed to translate the following four words. If you want an extra challenge, don't unspoiler the text:
|
||||
| *TODO - big 4*
|
||||
| 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.
|
||||
| Additionally, please don't attempt to derive or share translation-based learning materials on-server, or poach members for such a purpose.
|
||||
discord-rulesPage-rules-lfsv-overview-text = | If it's understood, it's Viossa.
|
||||
discord-rulesPage-rules-lfsv-overview-subtext = |~
|
||||
discord-rulesPage-rules-lfsv-section-header = Rule { $ruleNumber }: If it's understood, it's Viossa
|
||||
discord-rulesPage-rules-lfsv-section-body =
|
||||
| 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.
|
||||
| However, Viossa is a collaborative group project: members should strive to make others understand them, and in return make an effort to understand others.
|
||||
discord-rulesPage-rules-viossaOnlyChats-overview-text = | The chats in the Viossa Only category are Viossa only.
|
||||
discord-rulesPage-rules-viossaOnlyChats-overview-subtext = |~
|
||||
discord-rulesPage-rules-viossaOnlyChats-section-header = Rule { $ruleNumber }: Viossa-only chats
|
||||
discord-rulesPage-rules-viossaOnlyChats-section-body =
|
||||
| Chats in the Viossa Only section do not permit English. If you must use English to coach learners on the learning process, go to **#meta** instead.
|
||||
| This doesn't mean that other channels are English-only, though! Viossa is allowed everywhere.
|
||||
discord-rulesPage-rules-sfw-overview-text = | This server is SFW. No sexually explicit, gory, or violent content.
|
||||
discord-rulesPage-rules-sfw-overview-subtext = |~
|
||||
discord-rulesPage-rules-sfw-section-header = Rule { $ruleNumber }: SFW
|
||||
discord-rulesPage-rules-sfw-section-body =
|
||||
| If a mod does not like what you have posted, they will inform you; see [Rule 6](internal.replace:#rule-6). This is a public Discord server; think before you post.
|
||||
discord-rulesPage-rules-respectOthers-overview-text = | Don't use hate speech, and respect each other.
|
||||
discord-rulesPage-rules-respectOthers-overview-subtext = |~
|
||||
discord-rulesPage-rules-respectOthers-section-header = Rule { $ruleNumber }: Respect one another
|
||||
discord-rulesPage-rules-respectOthers-section-body =
|
||||
| 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.
|
||||
discord-rulesPage-rules-respectStaff-overview-text = | Respect the rulings of the staff (**@Yewald** and **@Yewaldnen**).
|
||||
discord-rulesPage-rules-respectStaff-overview-subtext = |~
|
||||
discord-rulesPage-rules-respectStaff-section-header = Rule { $ruleNumber }: Respect the staff's rulings
|
||||
discord-rulesPage-rules-respectStaff-section-body =
|
||||
| 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.
|
||||
| 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.
|
||||
| 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.
|
||||
discord-rulesPage-rules-controversialTopics-overview-text = | Discussion of controversial topics (politics, war, etc.) should be directed to **#polite**, which requires the **@Ike** role to view, which is itself locked behind **@Viossadjin** and **@mellandjin**.
|
||||
discord-rulesPage-rules-controversialTopics-overview-subtext = | **#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.
|
||||
discord-rulesPage-rules-controversialTopics-section-header = Rule { $ruleNumber }: #polite and ike
|
||||
discord-rulesPage-rules-controversialTopics-section-body =
|
||||
| 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.
|
||||
| # Venting vs seeking advice
|
||||
| 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.
|
||||
| # Self-harm and Violence
|
||||
| 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.
|
||||
| You should not use this space to:
|
||||
| - express intent or desire to harm yourself or others
|
||||
| - solicit help in stopping yourself from harming yourself or someone else
|
||||
| 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.
|
||||
| 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: [](external.new:https://blog.opencounseling.com/suicide-hotlines/)
|
||||
|
|
|
|||
3
apps/vdn-static/src/assets/locale/vp_VL.ftl
Normal file
3
apps/vdn-static/src/assets/locale/vp_VL.ftl
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
localeName = Viossa
|
||||
home-sections-whatIsViossa-title = Kafaen afto Viossa
|
||||
home-sections-whatIsViossa-body = Viossa tte glossa mahena grun vi nai vil fshtojena na bakadjin, grun vi svinnur ja! De aldjin zovti lera ne
|
||||
22
apps/vdn-static/src/assets/locale/wp_VL.ftl
Normal file
22
apps/vdn-static/src/assets/locale/wp_VL.ftl
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
localeName = wodox
|
||||
vilanticLangs-viossa = viosox
|
||||
vilanticLangs-wodox = wodox
|
||||
navbar-whatIsViossa = viosox e ano?
|
||||
navbar-resources = tropos
|
||||
navbar-kotoba = mot o viosox
|
||||
home-sections-whatIsViossa-title = viosox e ano?
|
||||
home-sections-whatIsViossa-body = 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.
|
||||
home-sections-historyOfViossa-title = zal o viosox
|
||||
home-sections-historyOfViossa-body = 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.
|
||||
home-sections-community-title = viosoxsi
|
||||
home-sections-community-body = 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.
|
||||
home-images-viossaFlag-alt = fomma o viosox
|
||||
resources-title = tropos o gen
|
||||
resources-resources-discord-title = server o Diskord
|
||||
resources-resources-discord-subtitle = axilkun ge genilkun po ce! wekatutsa!
|
||||
resources-resources-discord-desc = danzalil hez server po 2015. ibe dutukun musra po ce, de ibe wiftutsakun dof po pam, de wekatukun po server!
|
||||
resources-resources-discord-buttons-join-label = wekatutsa
|
||||
resources-resources-discord-buttons-rules-label = musra
|
||||
resources-images-discordLogo-alt = surat o Diskord
|
||||
kotoba-title = zalkuketutsa mot o viosox mit il o omni falmot
|
||||
kotoba-searchHelp = ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun.
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
<script setup lang="ts" generic="SlotName extends string">
|
||||
<script setup lang="ts" generic="Slot extends string">
|
||||
import {
|
||||
getCurrentInstance,
|
||||
onMounted,
|
||||
type DeepReadonly,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import { type CompiledRichTemplate } from "@/i18n";
|
||||
import RichTemplateParts from "./RichTemplateParts.vue";
|
||||
import MarkdownParts from "./MarkdownParts.vue";
|
||||
import OptionalParent from "./OptionalParent.vue";
|
||||
import type { Markdown } from "@/new-i18n-lib/markdown";
|
||||
import type { CssClass } from "@/utils/css";
|
||||
|
||||
const props = defineProps<{
|
||||
template: DeepReadonly<CompiledRichTemplate<SlotName>>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments -- I don't know why this error is here
|
||||
markdown: DeepReadonly<Markdown<Slot>>;
|
||||
lineClass?: CssClass;
|
||||
tag?: string;
|
||||
}>();
|
||||
const providedSlots =
|
||||
defineSlots<{ [K in DeepReadonly<SlotName>]: () => VNode[] }>();
|
||||
console.log(Object.entries(props.template));
|
||||
defineSlots<{ [K in DeepReadonly<Slot>]: () => VNode[] }>();
|
||||
console.log(Object.entries(props.markdown));
|
||||
|
||||
function tryResolveComponentName(type: unknown): string | undefined {
|
||||
if (!type || typeof type !== "object") return undefined;
|
||||
|
|
@ -53,9 +56,8 @@ const getComponentStack = () => {
|
|||
};
|
||||
|
||||
// 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 requiredSlots = props.markdown.slots;
|
||||
const missingSlots = requiredSlots.filter(
|
||||
(slot) => providedSlots[slot] === undefined,
|
||||
);
|
||||
|
|
@ -63,7 +65,7 @@ onMounted(() => {
|
|||
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}`,
|
||||
`Markdown component is missing slots!\n\tMissing Slots: ${missingSlots.join(", ")}${componentStack}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -71,11 +73,36 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<OptionalParent :is="tag">
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - this is an internal component for this component -->
|
||||
<RichTemplateParts :content="template.parts" :slots="template.slots">
|
||||
<template v-for="(slot, name) in providedSlots" :key="name" #[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</RichTemplateParts>
|
||||
<template v-for="(line, index) in markdown.lines" :key="index">
|
||||
<p v-if="line.type === 'paragraph'" :class="lineClass">
|
||||
<MarkdownParts
|
||||
:elements="line.elements"
|
||||
:slots="markdown.slots">
|
||||
<template
|
||||
v-for="(slot, name) in providedSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</p>
|
||||
<h3 v-else-if="line.type === 'header'" :class="lineClass">
|
||||
<MarkdownParts
|
||||
:elements="line.elements"
|
||||
:slots="markdown.slots">
|
||||
<template
|
||||
v-for="(slot, name) in providedSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</h3>
|
||||
<!-- <ul v-else-if="line.type === 'ulist'">
|
||||
<li v-for="(li, index) in line.ulist" :key="index">
|
||||
<RichTemplate :template="li" />
|
||||
</li>
|
||||
</ul> -->
|
||||
</template>
|
||||
</OptionalParent>
|
||||
</template>
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
<script setup lang="ts" generic="SlotName extends string">
|
||||
<script setup lang="ts" generic="Slot extends string">
|
||||
import { type DeepReadonly, type VNode } from "vue";
|
||||
import { type CompiledRichTemplatePart } from "@/i18n";
|
||||
import SmartLink from "../atoms/SmartLink.vue";
|
||||
import type { MarkdownLineElement } from "@/new-i18n-lib/markdown";
|
||||
|
||||
defineProps<{
|
||||
content: DeepReadonly<CompiledRichTemplatePart[]>;
|
||||
slots: DeepReadonly<SlotName[]>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
|
||||
elements: DeepReadonly<MarkdownLineElement<Slot>[]>;
|
||||
slots: DeepReadonly<Slot[]>;
|
||||
}>();
|
||||
|
||||
const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
|
||||
const vueSlots = defineSlots<{ [K in Slot]: () => VNode[] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(part, index) in content" :key="index">
|
||||
<template v-if="typeof part === 'string'">
|
||||
{{ part }}
|
||||
<template v-for="(part, index) in elements" :key="index">
|
||||
<template v-if="part.type === 'plain'">
|
||||
{{ part.plain }}
|
||||
</template>
|
||||
<template v-else-if="part.type === 'slot'">
|
||||
<template v-for="(slot, name) in vueSlots" :key="name">
|
||||
|
|
@ -26,40 +27,40 @@ const vueSlots = defineSlots<{ [K in SlotName]: () => VNode[] }>();
|
|||
<template v-else-if="part.type === 'bold'">
|
||||
<b>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<RichTemplateParts :content="part.bold" :slots="slots">
|
||||
<MarkdownParts :elements="part.bold" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</RichTemplateParts>
|
||||
</MarkdownParts>
|
||||
</b>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'italic'">
|
||||
<i>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<RichTemplateParts :content="part.italic" :slots="slots">
|
||||
<MarkdownParts :elements="part.italic" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</RichTemplateParts>
|
||||
</MarkdownParts>
|
||||
</i>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'link'">
|
||||
<SmartLink v-bind="part.link.props">
|
||||
<SmartLink :to="part.link.to" :new-tab="part.link.newTab">
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<RichTemplateParts :content="part.link.children" :slots="slots">
|
||||
<MarkdownParts :elements="part.link.label" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</RichTemplateParts>
|
||||
</MarkdownParts>
|
||||
</SmartLink>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
<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";
|
||||
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
|
||||
import type { Locale } from "@/new-i18n";
|
||||
import type { DeepReadonly } from "vue";
|
||||
import { isEmptyMarkdown } from "@/new-i18n-lib/markdown";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
overview: DeepReadonly<
|
||||
Value<
|
||||
CompileLocale<Locale>["discord"]["rulesPage"]["rules"]
|
||||
>["overview"]
|
||||
Value<Locale["discord"]["rulesPage"]["rules"]>["overview"]
|
||||
>;
|
||||
ruleNumber: number;
|
||||
}>();
|
||||
|
||||
const subtext = props.overview.subtext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -23,12 +23,14 @@ defineProps<{
|
|||
: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.parts.length > 0" class="mt-0 w-fit">
|
||||
<RichTemplate
|
||||
<MarkdownDisplay
|
||||
:markdown="overview.text()"
|
||||
line-class="mb-0" />
|
||||
<ul v-if="!isEmptyMarkdown(subtext)" class="mt-0 w-fit">
|
||||
<MarkdownDisplay
|
||||
tag="li"
|
||||
:style="{ width: 'fit-content' }"
|
||||
:template="overview.subtext" />
|
||||
:markdown="subtext" />
|
||||
</ul>
|
||||
</li>
|
||||
</SmartLink>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { CompileLocale } from "@/i18n";
|
||||
import type { Locale } from "@/i18n/locale";
|
||||
import type { Locale } from "@/new-i18n";
|
||||
import type { Value } from "@/utils/types";
|
||||
import RichTemplate from "../atoms/RichTemplate.vue";
|
||||
import type { DeepReadonly } from "vue";
|
||||
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
|
||||
|
||||
defineProps<{
|
||||
section: DeepReadonly<
|
||||
Value<CompileLocale<Locale>["discord"]["rulesPage"]["rules"]>["section"]
|
||||
Value<Locale["discord"]["rulesPage"]["rules"]>["section"]
|
||||
>;
|
||||
ruleNumber: number;
|
||||
}>();
|
||||
|
|
@ -16,18 +15,6 @@ defineProps<{
|
|||
<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>
|
||||
<MarkdownDisplay :markdown="section.body()" />
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/i18n";
|
||||
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/new-i18n";
|
||||
import { ref } from "vue";
|
||||
import { vOnClickOutside } from "@vueuse/components";
|
||||
import DropdownItem from "../atoms/DropdownItem.vue";
|
||||
|
|
@ -32,7 +32,7 @@ const setLocaleId = (id: LocaleId): void => {
|
|||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
@click="toggleOpen()">
|
||||
<span>{{ locale.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)">
|
||||
{{ useLocale({ locale: id }).value.localeName }}
|
||||
{{ useLocale({ locale: id }).value.localeName() }}
|
||||
</DropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,636 +0,0 @@
|
|||
import en_US from "../locales/en_US";
|
||||
import vp_VL from "../locales/vp_VL";
|
||||
import wp_VL from "../locales/wp_VL";
|
||||
import { computed, type DeepReadonly } from "vue";
|
||||
import { type Locale, type LocaleMask } from "./locale";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { type } from "arktype";
|
||||
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";
|
||||
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> = DeMessagePack<
|
||||
T extends Template<infer SlotName> ? CompiledTemplate<SlotName>
|
||||
: T extends RichTemplate<infer SlotName> ? CompiledRichTemplate<SlotName>
|
||||
: T extends (...args: infer Args) => infer Return ?
|
||||
(...args: Args) => _CompileLocale<Return>
|
||||
: T extends object ? { [K in keyof T]: _CompileLocale<T[K]> }
|
||||
: 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
|
||||
: // 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, undefined);
|
||||
console.log(compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
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 | undefined,
|
||||
): _CompileLocale<T> {
|
||||
return Object.fromEntries(
|
||||
Reflect.ownKeys(obj).map((key) => {
|
||||
return [
|
||||
key,
|
||||
compileUnknown(obj[key], keypathDot(keypath, key)),
|
||||
] 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, `${keypath}[${String(i)}]`),
|
||||
);
|
||||
}
|
||||
|
||||
return compileObject(value as Record<PropertyKey, unknown>, keypath);
|
||||
}
|
||||
|
||||
if (typeof value === "function") {
|
||||
return (...args: unknown[]) =>
|
||||
compileUnknown(value(...args), `${keypath}()`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
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, slots } = compileRichTemplateParts(parts);
|
||||
|
||||
return {
|
||||
[compiledRichTemplateSymbol]: true,
|
||||
parts: compiledParts,
|
||||
slots,
|
||||
keypath,
|
||||
};
|
||||
}
|
||||
|
||||
interface CompileRichTemplatePartRes<SlotName extends string> {
|
||||
compiledPart: CompiledRichTemplatePart;
|
||||
slots: SlotName[];
|
||||
}
|
||||
|
||||
function compileRichTemplatePart<SlotName extends string>(
|
||||
part: RichTemplatePart<SlotName>,
|
||||
): CompileRichTemplatePartRes<SlotName> {
|
||||
if (typeof part === "string") {
|
||||
return { compiledPart: part, slots: [] };
|
||||
}
|
||||
|
||||
if (isSlot(part)) {
|
||||
return {
|
||||
compiledPart: { type: "slot", slot: part.name },
|
||||
slots: [part.name],
|
||||
};
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case "bold": {
|
||||
const { compiledParts, slots } = compileRichTemplateParts(
|
||||
part.bold,
|
||||
);
|
||||
|
||||
return {
|
||||
compiledPart: { type: "bold", bold: compiledParts },
|
||||
slots,
|
||||
};
|
||||
}
|
||||
case "italic": {
|
||||
const { compiledParts, slots } = compileRichTemplateParts(
|
||||
part.italic,
|
||||
);
|
||||
|
||||
return {
|
||||
compiledPart: { type: "italic", italic: compiledParts },
|
||||
slots,
|
||||
};
|
||||
}
|
||||
case "link": {
|
||||
const { compiledParts, slots } = compileRichTemplateParts(
|
||||
part.link.children,
|
||||
);
|
||||
|
||||
return {
|
||||
compiledPart: {
|
||||
type: "link",
|
||||
link: { children: compiledParts, props: part.link.props },
|
||||
},
|
||||
slots,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CompileRichTemplatePartsRes<SlotName extends string> {
|
||||
compiledParts: CompiledRichTemplatePart[];
|
||||
slots: SlotName[];
|
||||
}
|
||||
|
||||
function compileRichTemplateParts<SlotName extends string>(
|
||||
parts: RichTemplatePart<SlotName>[],
|
||||
): CompileRichTemplatePartsRes<SlotName> {
|
||||
const slots: SlotName[] = [];
|
||||
|
||||
const compiledParts = parts.map((part) => {
|
||||
const res = compileRichTemplatePart(part);
|
||||
slots.push(...res.slots);
|
||||
return res.compiledPart;
|
||||
});
|
||||
|
||||
return { compiledParts, slots };
|
||||
}
|
||||
|
||||
const compiledRichTemplateSymbol: unique symbol = Symbol(
|
||||
"compiledRichTemplate",
|
||||
);
|
||||
export interface CompiledRichTemplate<SlotName extends string> {
|
||||
[compiledRichTemplateSymbol]: true;
|
||||
parts: CompiledRichTemplatePart[];
|
||||
slots: SlotName[];
|
||||
keypath: string;
|
||||
}
|
||||
|
||||
export type CompiledRichTemplatePart =
|
||||
| string
|
||||
| { type: "slot"; slot: 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;
|
||||
|
||||
export type LocaleId = typeof LocaleId.infer;
|
||||
export const LocaleId = type.enumerated(...LOCALE_IDS);
|
||||
|
||||
export const DEFAULT_LOCALE_ID = "en_US" satisfies LocaleId;
|
||||
|
||||
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>,
|
||||
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",
|
||||
DEFAULT_LOCALE_ID,
|
||||
);
|
||||
|
||||
export const localeId = computed({
|
||||
get: (): LocaleId => {
|
||||
const localeIdRes = LocaleId(localStorageLocaleId.value);
|
||||
if (localeIdRes instanceof type.errors) {
|
||||
// if invalid LocaleId, reset to default
|
||||
localStorageLocaleId.value = DEFAULT_LOCALE_ID;
|
||||
return DEFAULT_LOCALE_ID;
|
||||
}
|
||||
|
||||
// else return user's selection
|
||||
const localeId = localeIdRes;
|
||||
return localeId;
|
||||
},
|
||||
// custom setter to ensure it is only set to a valid LocaleId by our code
|
||||
// (since the localStorage ref is typed as `unknown`, it can be set to any value)
|
||||
set: (id: LocaleId) => {
|
||||
localStorageLocaleId.value = id;
|
||||
},
|
||||
});
|
||||
|
||||
// export const useLocale = (opt: UseLocaleOptions = {}) => {
|
||||
// const locale = computed<DeepReadonly<Locale>>(() => {
|
||||
// return fallbackProxy<Locale>(
|
||||
// locales[opt.locale ?? localeId.value],
|
||||
// locales["en_US"],
|
||||
// );
|
||||
// });
|
||||
|
||||
// return readonly(locale);
|
||||
// };
|
||||
|
||||
export interface UseLocaleOptions {
|
||||
locale?: LocaleId;
|
||||
}
|
||||
|
||||
function isObject(value: unknown) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function deepReadonly<T>(value: T): DeepReadonly<T> {
|
||||
// SAFETY: we're just making an immutable view to the type, this isn't dangerous
|
||||
return value as DeepReadonly<T>;
|
||||
}
|
||||
|
||||
type DeepFallbackable<T> = {
|
||||
[K in keyof T]?: DeepFallbackable<T[K]> | Fallback;
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// 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,
|
||||
// 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 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 fallbackValue;
|
||||
}
|
||||
|
||||
// else, proxy the returned object to support deep fallback proxying
|
||||
return createFallbacked<Fallback[keyof Fallback] & object>(
|
||||
finalValue,
|
||||
fallbackValue,
|
||||
);
|
||||
})();
|
||||
|
||||
return [rawKey, deepReadonly(value)] as const;
|
||||
});
|
||||
|
||||
// we're just disallowing mutations to the proxy, since its setter panics if used at runtime
|
||||
// TODO: add SAFETY comments for all `as` casts
|
||||
return deepReadonly(Object.fromEntries(entries) as Fallback);
|
||||
}
|
||||
|
||||
const createFallbackedCompiledLocale = (id: LocaleId) =>
|
||||
createFallbacked<CompileLocale<Locale>>(
|
||||
localeIdToCompiledLocale[id],
|
||||
localeIdToCompiledLocale[DEFAULT_LOCALE_ID],
|
||||
);
|
||||
|
||||
// const createFallbackedLocale = (id: LocaleId) =>
|
||||
// fallbackProxy<Locale>(
|
||||
// localeIdToLocale[id],
|
||||
// localeIdToLocale[DEFAULT_LOCALE_ID],
|
||||
// );
|
||||
|
||||
const createLocaleIdToFallbackCompiledLocale = (): Record<
|
||||
LocaleId,
|
||||
DeepReadonly<CompileLocale<Locale>>
|
||||
> => {
|
||||
return {
|
||||
en_US: createFallbackedCompiledLocale("en_US"),
|
||||
vp_VL: createFallbackedCompiledLocale("vp_VL"),
|
||||
wp_VL: createFallbackedCompiledLocale("wp_VL"),
|
||||
} as const;
|
||||
};
|
||||
|
||||
// const createLocaleIdToFallbackedLocale = (): Record<
|
||||
// LocaleId,
|
||||
// DeepReadonly<Locale>
|
||||
// > => {
|
||||
// return {
|
||||
// en_US: createFallbackedLocale("en_US"),
|
||||
// vp_VL: createFallbackedLocale("vp_VL"),
|
||||
// wp_VL: createFallbackedLocale("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;
|
||||
}
|
||||
|
||||
// const localeIdToFallbackedLocale = createLocaleIdToFallbackedLocale();
|
||||
|
||||
const localeIdToFallbackedCompiledLocale =
|
||||
createLocaleIdToFallbackCompiledLocale();
|
||||
|
||||
// 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];
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import type { DeepRemoveFallback } from "./marker";
|
||||
import type { RichTemplate } from "./rich";
|
||||
import type { VilanticId } from "./vilantic";
|
||||
|
||||
export interface Locale extends DeepRemoveFallback<LocaleMask> {}
|
||||
|
||||
export type LocaleMask = {
|
||||
localeName: string;
|
||||
vilanticLangs: VilanticLangs;
|
||||
navbar: Navbar;
|
||||
home: HomePage;
|
||||
resources: ResourcesPage;
|
||||
kotoba: KotobaPage;
|
||||
discord: Discord;
|
||||
};
|
||||
|
||||
export type VilanticLangs = Record<VilanticId, string>;
|
||||
|
||||
export type Navbar = Record<"whatIsViossa" | "resources" | "kotoba", string>;
|
||||
|
||||
export type HomePage = {
|
||||
sections: HomeSections;
|
||||
images: Record<"viossaFlag", Image>;
|
||||
};
|
||||
|
||||
export type HomeSections = Record<
|
||||
"whatIsViossa" | "historyOfViossa" | "community",
|
||||
HomeSection
|
||||
>;
|
||||
|
||||
export type HomeSection = { title: string; body: string };
|
||||
|
||||
export type ResourcesPage = {
|
||||
title: string;
|
||||
resources: Resources;
|
||||
images: Record<"discordLogo", Image>;
|
||||
};
|
||||
|
||||
export type Resources = { discord: Resource<"join" | "rules"> };
|
||||
|
||||
export type Resource<ButtonKey extends string> = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
desc: string;
|
||||
buttons: Record<ButtonKey, Button>;
|
||||
};
|
||||
|
||||
export type KotobaPage = { title: string; searchHelp: string };
|
||||
|
||||
export type Button = { label: string };
|
||||
|
||||
// coupled to require alt text for all images
|
||||
export type Image = { alt: string };
|
||||
|
||||
export type Discord = { rulesPage: DiscordRulesPage };
|
||||
|
||||
export type DiscordRulesPage = {
|
||||
title: string;
|
||||
overview: DiscordRulesPageOverview;
|
||||
rules: DiscordRules;
|
||||
};
|
||||
|
||||
export type DiscordRulesPageOverview = { title: string; help: string };
|
||||
|
||||
export type DiscordRules = Record<
|
||||
| "noTranslation"
|
||||
| "lfsv"
|
||||
| "viossaOnlyChats"
|
||||
| "sfw"
|
||||
| "respectOthers"
|
||||
| "respectStaff"
|
||||
| "controversialTopics",
|
||||
DiscordRule
|
||||
>;
|
||||
|
||||
export type DiscordRule = {
|
||||
overview: DiscordRuleOverview;
|
||||
section: DiscordRuleSection;
|
||||
};
|
||||
|
||||
export type DiscordRuleOverview = {
|
||||
text: RichTemplate<never>;
|
||||
subtext: RichTemplate<never>;
|
||||
};
|
||||
|
||||
export type 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>[] };
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// 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<
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
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 = never>(
|
||||
...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 = never>(
|
||||
...children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]]
|
||||
): RichTemplatePart<SlotName> & { type: "bold" } {
|
||||
return { type: "bold", bold: children };
|
||||
}
|
||||
|
||||
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 = never>(ctx: {
|
||||
children: [RichTemplatePart<SlotName>, ...RichTemplatePart<SlotName>[]];
|
||||
props: SmartLinkProps;
|
||||
}): RichTemplatePart<SlotName> & { type: "link" } {
|
||||
return { type: "link", link: ctx };
|
||||
}
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
import { type Locale } from "@/i18n/locale";
|
||||
import { boldT, italicT, linkT, richT } from "@/i18n/rich";
|
||||
import { type DeepStrictMessagePackValues } from "@/i18n/marker";
|
||||
|
||||
export default {
|
||||
localeName: "English",
|
||||
vilanticLangs: { viossa: "Viossa", wodox: "Wodoch" },
|
||||
navbar: {
|
||||
whatIsViossa: "What is Viossa?",
|
||||
resources: "Resources",
|
||||
kotoba: "Kotoba",
|
||||
},
|
||||
home: {
|
||||
sections: {
|
||||
whatIsViossa: {
|
||||
title: "What is Viossa?",
|
||||
body: "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.",
|
||||
},
|
||||
historyOfViossa: {
|
||||
title: "History of Viossa",
|
||||
body: "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.",
|
||||
},
|
||||
community: {
|
||||
title: "Community",
|
||||
body: "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.",
|
||||
},
|
||||
},
|
||||
images: { viossaFlag: { alt: "Flag of the Viossa Language" } },
|
||||
},
|
||||
resources: {
|
||||
title: "Learning Resources",
|
||||
resources: {
|
||||
discord: {
|
||||
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!",
|
||||
buttons: { join: { label: "Join" }, rules: { label: "Rules" } },
|
||||
},
|
||||
},
|
||||
images: { discordLogo: { alt: "Discord logo" } },
|
||||
},
|
||||
kotoba: {
|
||||
title: "Tropos-agnostic search",
|
||||
searchHelp: "To searcn tropos-agnostically, enter a term below.",
|
||||
},
|
||||
discord: {
|
||||
rulesPage: {
|
||||
title: "Discord Server Rules",
|
||||
overview: {
|
||||
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: richT(),
|
||||
},
|
||||
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: richT(),
|
||||
},
|
||||
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: {
|
||||
text: richT(
|
||||
"The chats in the Viossa Only category are Viossa only.",
|
||||
),
|
||||
subtext: richT(),
|
||||
},
|
||||
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: {
|
||||
text: richT(
|
||||
"This server is SFW. No sexually explicit, gory, or violent content.",
|
||||
),
|
||||
subtext: richT(),
|
||||
},
|
||||
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: {
|
||||
text: richT(
|
||||
"Don't use hate speech, and respect each other.",
|
||||
),
|
||||
subtext: richT(),
|
||||
},
|
||||
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: {
|
||||
text: richT(
|
||||
"Respect the rulings of the staff (",
|
||||
boldT("@Yewald"),
|
||||
" and ",
|
||||
boldT("@Yewaldnen"),
|
||||
").",
|
||||
),
|
||||
subtext: richT(),
|
||||
},
|
||||
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: {
|
||||
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.",
|
||||
),
|
||||
},
|
||||
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,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies DeepStrictMessagePackValues<Locale>;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { type LocaleMask } from "@/i18n/locale";
|
||||
import type { DeepPartial } from "@/utils/types";
|
||||
|
||||
export default {
|
||||
localeName: "Viossa",
|
||||
home: {
|
||||
sections: {
|
||||
whatIsViossa: {
|
||||
title: "Kafaen afto Viossa",
|
||||
body: "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>;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { type LocaleMask } from "@/i18n/locale";
|
||||
import type { DeepPartial } from "@/utils/types";
|
||||
|
||||
export default {
|
||||
localeName: "wodox",
|
||||
vilanticLangs: { viossa: "viosox", wodox: "wodox" },
|
||||
navbar: {
|
||||
whatIsViossa: "viosox e ano?",
|
||||
resources: "tropos",
|
||||
kotoba: "mot o viosox",
|
||||
},
|
||||
home: {
|
||||
sections: {
|
||||
whatIsViossa: {
|
||||
title: "viosox e ano?",
|
||||
body: "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.",
|
||||
},
|
||||
historyOfViossa: {
|
||||
title: "zal o viosox",
|
||||
body: "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.",
|
||||
},
|
||||
community: {
|
||||
title: "viosoxsi",
|
||||
body: "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.",
|
||||
},
|
||||
},
|
||||
images: { viossaFlag: { alt: "fomma o viosox" } },
|
||||
},
|
||||
resources: {
|
||||
title: "tropos o gen",
|
||||
resources: {
|
||||
discord: {
|
||||
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!",
|
||||
buttons: {
|
||||
join: { label: "wekatutsa" },
|
||||
rules: { label: "musra" },
|
||||
},
|
||||
},
|
||||
},
|
||||
images: { discordLogo: { alt: "surat o Diskord" } },
|
||||
},
|
||||
kotoba: {
|
||||
title: "zalkuketutsa mot o viosox mit il o omni falmot",
|
||||
searchHelp:
|
||||
"ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun.",
|
||||
},
|
||||
} as const satisfies DeepPartial<LocaleMask>;
|
||||
|
|
@ -1,41 +1,5 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { bundleToUncompiledLocale, loadFluentBundle } from "./new-i18n/setup";
|
||||
|
||||
import enUsLocaleSrc from "@/assets/locale/en_US.ftl";
|
||||
import { localeConfig, compileLocale } from "./new-i18n/config";
|
||||
import { parseMarkdown } from "./new-i18n/markdown";
|
||||
|
||||
const bundleRes = await loadFluentBundle("en-US", enUsLocaleSrc);
|
||||
if (bundleRes.type === "err") {
|
||||
throw new Error(bundleRes.err);
|
||||
}
|
||||
|
||||
const bundle = bundleRes.ok;
|
||||
const uncompiledRes = bundleToUncompiledLocale(bundle);
|
||||
if (uncompiledRes.type === "err") {
|
||||
throw new Error(uncompiledRes.err);
|
||||
}
|
||||
|
||||
const uncompiled = uncompiledRes.ok;
|
||||
|
||||
const localeRes = compileLocale({ config: localeConfig, uncompiled });
|
||||
if (localeRes.type === "err") {
|
||||
throw new Error(localeRes.err);
|
||||
}
|
||||
|
||||
const locale = localeRes.ok;
|
||||
// console.log(locale.richTest.placeable({}));
|
||||
|
||||
const markdownRes = parseMarkdown("# [internal:#hello](Google)");
|
||||
if (markdownRes.type === "err") {
|
||||
throw new Error(markdownRes.err);
|
||||
}
|
||||
|
||||
const markdown = markdownRes.ok;
|
||||
console.log(markdown);
|
||||
|
||||
console.log(uncompiled);
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
|
|
|||
321
apps/vdn-static/src/new-i18n-lib/config.ts
Normal file
321
apps/vdn-static/src/new-i18n-lib/config.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentVariable } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
|
||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
||||
export interface ConfigMessage<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = {
|
||||
[name in string]: ConfigPlaceableInfo;
|
||||
},
|
||||
Markdown extends
|
||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
||||
> {
|
||||
[configMessageSymbol]: true;
|
||||
placeables: Placeables;
|
||||
markdown: Markdown;
|
||||
}
|
||||
|
||||
export interface ConfigPlaceableInfo<
|
||||
Type extends "string" | "number" = "string" | "number",
|
||||
> {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export interface ConfigMarkdown<Slot extends string> {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
header?: boolean;
|
||||
link?: boolean;
|
||||
ulist?: boolean;
|
||||
slots?: Slot[];
|
||||
}
|
||||
|
||||
export function message<
|
||||
const Placeables extends {
|
||||
[name in string]?: ConfigPlaceableInfo;
|
||||
} = object,
|
||||
const Markdown extends ConfigMarkdown<string> | null = null,
|
||||
>(
|
||||
opt: Partial<
|
||||
Omit<
|
||||
ConfigMessage<Placeables, ConfigMarkdown<never> | Markdown>,
|
||||
typeof configMessageSymbol
|
||||
>
|
||||
> = {},
|
||||
): ConfigMessage<Placeables, Markdown> {
|
||||
return {
|
||||
[configMessageSymbol]: true,
|
||||
placeables: opt.placeables ?? {},
|
||||
markdown: opt.markdown ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type LocaleConfig<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Markdown extends
|
||||
ConfigMarkdown<string> | null = ConfigMarkdown<string> | null,
|
||||
> = {
|
||||
[id: string]:
|
||||
| ConfigMessage<Placeables, Markdown>
|
||||
| LocaleConfig<Placeables, Markdown>;
|
||||
};
|
||||
|
||||
type MessageCtx<
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
> = {
|
||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||
>;
|
||||
};
|
||||
|
||||
type PlaceableType<Var extends ConfigPlaceableInfo> =
|
||||
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
|
||||
|
||||
type ResolvedPlaceableType<Type extends "string" | "number"> =
|
||||
Type extends "string" ? string
|
||||
: Type extends "number" ? number
|
||||
: never;
|
||||
|
||||
export type InferLocale<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ? InferLocale<Config[K]>
|
||||
: Config[K] extends ConfigMessage<infer Placeables, infer Md> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => null extends Md ? string
|
||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
||||
: never
|
||||
: (ctx: MessageCtx<Placeables>) => null extends Md ? string
|
||||
: Md extends ConfigMarkdown<never> ? Markdown<never>
|
||||
: Md extends ConfigMarkdown<infer Slot> ? Markdown<Slot>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export function record<const Key extends PropertyKey, T>(
|
||||
keys: readonly Key[],
|
||||
initializer: () => T,
|
||||
): Record<Key, T> {
|
||||
const obj: Partial<Record<Key, T>> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = initializer();
|
||||
}
|
||||
|
||||
// SAFETY: we set all properties from keys array above
|
||||
return obj as Record<Key, T>;
|
||||
}
|
||||
|
||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||
uncompiled: UncompiledLocale;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export function compileLocale<Config extends LocaleConfig>(
|
||||
ctx: CompileLocaleCtx<Config>,
|
||||
): Result<InferLocale<Config>, string> {
|
||||
const { uncompiled, config } = ctx;
|
||||
|
||||
const { record, bundle } = uncompiled;
|
||||
|
||||
const recordKeys = new Set(Object.keys(record));
|
||||
const configKeys = new Set(Object.keys(config));
|
||||
const excessKeys = recordKeys.difference(configKeys);
|
||||
console.log(recordKeys, configKeys, excessKeys);
|
||||
if (excessKeys.size > 0) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Excess keys in record: ${[...excessKeys].join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
type GenericLocale = {
|
||||
[id: string]:
|
||||
| GenericLocale
|
||||
| ((args: Record<string, FluentVariable>) => string)
|
||||
| ((args: Record<string, FluentVariable>) => Markdown);
|
||||
};
|
||||
|
||||
const locale: GenericLocale = {};
|
||||
for (const [id, configValue] of Object.entries(config)) {
|
||||
const recordValue = record[id];
|
||||
|
||||
if (configMessageSymbol in configValue) {
|
||||
if (recordValue?.type !== "message") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected message for key \`${id}\`, found: ${typeof recordValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
const message = recordValue.message;
|
||||
console.log(message);
|
||||
|
||||
const pattern = message.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Pattern is null for message with ID: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariantsRes = computeAllVariants(pattern);
|
||||
if (allVariantsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute variants for key \`${id}\`:\n${allVariantsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = allVariantsRes.ok;
|
||||
console.log(allVariants);
|
||||
|
||||
// validate placeables
|
||||
console.log(pattern);
|
||||
if (typeof pattern !== "string") {
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const { selector } = element;
|
||||
if (selector.type !== "var") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected selector to be a var expression for key: ${id}; Found: ${selector.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
selector.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${selector.name}\` for key: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
element.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${element.name}\` for key: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "term":
|
||||
case "mesg":
|
||||
case "func":
|
||||
case "str":
|
||||
case "num": {
|
||||
break; // ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create function
|
||||
const markdown = configValue.markdown;
|
||||
const compiledFnRes = ((): Result<
|
||||
| ((args?: Record<string, FluentVariable>) => string)
|
||||
| ((args?: Record<string, FluentVariable>) => Markdown),
|
||||
string
|
||||
> => {
|
||||
if (markdown !== null) {
|
||||
const markdownSlots = markdown.slots ?? [];
|
||||
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const markdownRes = parseMarkdown(
|
||||
variant.string,
|
||||
markdownSlots,
|
||||
);
|
||||
console.log(markdownRes);
|
||||
if (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of key \`${id}\`:\n${markdownRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const res = parseMarkdown(
|
||||
bundle.formatPattern(pattern, args),
|
||||
markdownSlots,
|
||||
);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) =>
|
||||
bundle.formatPattern(pattern, args),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (compiledFnRes.type === "err") {
|
||||
return compiledFnRes;
|
||||
}
|
||||
|
||||
const compiledFn = compiledFnRes.ok;
|
||||
locale[id] = compiledFn;
|
||||
} else {
|
||||
if (recordValue?.type !== "subrecord") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected subrecord for key \`${id}\`, found: ${typeof recordValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
const subrecord = recordValue.subrecord;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
uncompiled: { bundle, record: subrecord },
|
||||
config: configValue,
|
||||
});
|
||||
|
||||
if (compiledSubrecordRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Error when compiling subrecord with key: \`${id}\`:\n${compiledSubrecordRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const compiledSubrecord = compiledSubrecordRes.ok;
|
||||
locale[id] = compiledSubrecord;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: validated above that all keys exist and are the correct type
|
||||
return { type: "ok", ok: locale as InferLocale<Config> };
|
||||
}
|
||||
|
|
@ -7,29 +7,59 @@ import type { Result } from "@/utils/types";
|
|||
import type { RouteNamedMap } from "vue-router/auto-routes";
|
||||
import { routes } from "vue-router/auto-routes";
|
||||
|
||||
export type MarkdownLine = {
|
||||
export interface Markdown<Slot extends string = string> {
|
||||
lines: MarkdownLine<Slot>[];
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
export type MarkdownLine<Slot extends string = string> = {
|
||||
type: "paragraph" | "header";
|
||||
elements: MarkdownLineElement[];
|
||||
elements: MarkdownLineElement<Slot>[];
|
||||
};
|
||||
|
||||
export type MarkdownFeature =
|
||||
| Exclude<MarkdownLine["type"], "paragraph">
|
||||
| Exclude<MarkdownLineElement["type"], "plain">;
|
||||
|
||||
export type MarkdownLineElement =
|
||||
export type MarkdownLineElement<Slot extends string = string> =
|
||||
| { type: "plain"; plain: string }
|
||||
| { type: "italic"; italic: MarkdownLineElement[] }
|
||||
| { type: "bold"; bold: MarkdownLineElement[] }
|
||||
| { type: "link"; link: { name: string; to: SmartDest } }
|
||||
| { type: "slot"; slot: string };
|
||||
| {
|
||||
type: "link";
|
||||
link: {
|
||||
label: MarkdownLineElement[];
|
||||
to: SmartDest;
|
||||
newTab: boolean;
|
||||
};
|
||||
}
|
||||
| { type: "slot"; slot: Slot };
|
||||
|
||||
export function parseMarkdown(
|
||||
export function parseMarkdown<Slot extends string>(
|
||||
markdownString: string,
|
||||
): Result<MarkdownLine[], string> {
|
||||
slots: readonly Slot[],
|
||||
): Result<Markdown<Slot>, string> {
|
||||
if (markdownString === "|~") {
|
||||
return { type: "ok", ok: { lines: [], slots: [] } };
|
||||
}
|
||||
|
||||
const lines = markdownString.split("\n");
|
||||
const markdownLines: MarkdownLine[] = [];
|
||||
const strippedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const markdownLineRes = parseMarkdownLine(line);
|
||||
const MARKDOWN_LINE_PREFIX = "| ";
|
||||
if (!line.startsWith(MARKDOWN_LINE_PREFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Line ${String(strippedLines.length + 1)} of markdown is not prefixed`,
|
||||
};
|
||||
}
|
||||
|
||||
strippedLines.push(line.substring(MARKDOWN_LINE_PREFIX.length));
|
||||
}
|
||||
|
||||
const markdownLines: MarkdownLine<Slot>[] = [];
|
||||
for (const line of strippedLines) {
|
||||
const markdownLineRes = parseMarkdownLine(line, slots);
|
||||
if (markdownLineRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
|
|
@ -41,12 +71,15 @@ export function parseMarkdown(
|
|||
markdownLines.push(markdownLine);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: markdownLines };
|
||||
return { type: "ok", ok: { lines: markdownLines, slots: [...slots] } };
|
||||
}
|
||||
|
||||
function parseMarkdownLine(line: string): Result<MarkdownLine, string> {
|
||||
function parseMarkdownLine<Slot extends string>(
|
||||
line: string,
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownLine<Slot>, string> {
|
||||
if (line.startsWith("#")) {
|
||||
const elementsRes = parseMarkdownLineElements(line.substring(1));
|
||||
const elementsRes = parseMarkdownLineElements(line.substring(1), slots);
|
||||
|
||||
if (elementsRes.type === "err") {
|
||||
return {
|
||||
|
|
@ -59,7 +92,7 @@ function parseMarkdownLine(line: string): Result<MarkdownLine, string> {
|
|||
return { type: "ok", ok: { type: "header", elements } };
|
||||
}
|
||||
|
||||
const elementsRes = parseMarkdownLineElements(line);
|
||||
const elementsRes = parseMarkdownLineElements(line, slots);
|
||||
if (elementsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
|
|
@ -71,9 +104,10 @@ function parseMarkdownLine(line: string): Result<MarkdownLine, string> {
|
|||
return { type: "ok", ok: { type: "paragraph", elements } };
|
||||
}
|
||||
|
||||
function parseMarkdownLineElements(
|
||||
function parseMarkdownLineElements<Slot extends string>(
|
||||
line: string,
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
if (line.startsWith("#")) {
|
||||
// subheaders may be supported in the future,
|
||||
// so ignoring them or treating them as h1 headers now would be a breaking change when
|
||||
|
|
@ -85,7 +119,7 @@ function parseMarkdownLineElements(
|
|||
|
||||
const trimmedLine = line.trim();
|
||||
const chars = trimmedLine.split("");
|
||||
const elementsRes = readMarkdownLineElements(chars, {
|
||||
const elementsRes = readMarkdownLineElements(chars, slots, {
|
||||
inItalic: false,
|
||||
inBold: false,
|
||||
});
|
||||
|
|
@ -102,13 +136,14 @@ interface ReadMarkdownLineElementCtx {
|
|||
inBold: boolean;
|
||||
}
|
||||
|
||||
function readMarkdownLineElements(
|
||||
function readMarkdownLineElements<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownLineElementCtx,
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
const elements: MarkdownLineElement[] = [];
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const elements: MarkdownLineElement<Slot>[] = [];
|
||||
while (true) {
|
||||
const elementRes = readMarkdownLineElement(chars, ctx);
|
||||
const elementRes = readMarkdownLineElement(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
|
@ -124,29 +159,36 @@ function readMarkdownLineElements(
|
|||
return { type: "ok", ok: elements };
|
||||
}
|
||||
|
||||
function readMarkdownLineElement(
|
||||
function readMarkdownLineElement<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownLineElementCtx,
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const [firstChar, secondChar] = chars;
|
||||
if (firstChar === undefined) {
|
||||
return { type: "ok", ok: [] };
|
||||
} else if (firstChar === "<") {
|
||||
return readMarkdownLineElementSlot(chars);
|
||||
return readMarkdownLineElementSlot(chars, slots);
|
||||
} else if (firstChar === "[") {
|
||||
return readMarkdownLineElementLink(chars);
|
||||
return readMarkdownLineElementLink(chars, slots, ctx);
|
||||
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
|
||||
return readMarkdownLineElementBold(chars, ctx);
|
||||
return readMarkdownLineElementBold(chars, slots, ctx);
|
||||
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
|
||||
return readMarkdownLineElementItalic(chars, ctx);
|
||||
return readMarkdownLineElementItalic(chars, slots, ctx);
|
||||
} else {
|
||||
return readMarkdownLineElementPlain(chars);
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownLineElementSlot(
|
||||
function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
|
||||
// SAFETY: this is just an equality check, it is safe to pass in any value
|
||||
return array.includes(value as U);
|
||||
}
|
||||
|
||||
function readMarkdownLineElementSlot<Slot extends string>(
|
||||
chars: string[],
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const openAngleRes = expectReadChar(chars, "<");
|
||||
if (openAngleRes.type === "err") {
|
||||
return openAngleRes;
|
||||
|
|
@ -163,28 +205,94 @@ function readMarkdownLineElementSlot(
|
|||
}
|
||||
|
||||
const slotName = slotNameRes.ok;
|
||||
if (!isInArray(slotName, slots)) {
|
||||
return { type: "err", err: `Unexpected slot name: ${slotName}` };
|
||||
}
|
||||
|
||||
return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
|
||||
}
|
||||
|
||||
function readMarkdownLineElementLink(
|
||||
function readMarkdownLineElementLink<Slot extends string>(
|
||||
chars: string[],
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownLineElementCtx,
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const openSquareRes = expectReadChar(chars, "[");
|
||||
if (openSquareRes.type === "err") {
|
||||
return openSquareRes;
|
||||
}
|
||||
|
||||
const destRes = ((): Result<SmartDest, string> => {
|
||||
if (peekStringEq(chars, "external:")) {
|
||||
const externalRes = expectReadString(chars, "external:");
|
||||
const labelElementsRes = readMarkdownLineElements(chars, slots, ctx);
|
||||
if (labelElementsRes.type === "err") {
|
||||
return labelElementsRes;
|
||||
}
|
||||
|
||||
const closeSquareRes = expectReadChar(chars, "]");
|
||||
if (closeSquareRes.type === "err") {
|
||||
return closeSquareRes;
|
||||
}
|
||||
|
||||
const labelElements = labelElementsRes.ok;
|
||||
const openParenRes = expectReadChar(chars, "(");
|
||||
if (openParenRes.type === "err") {
|
||||
return openParenRes;
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
dest: SmartDest;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
const linkPropsRes = ((): Result<LinkProps, string> => {
|
||||
if (peekStringEq(chars, "external")) {
|
||||
const externalRes = expectReadString(chars, "external");
|
||||
if (externalRes.type === "err") {
|
||||
return externalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
const destRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link dest",
|
||||
closingChar: "]",
|
||||
closingChar: ")",
|
||||
});
|
||||
|
||||
if (destRes.type === "err") {
|
||||
|
|
@ -198,20 +306,63 @@ function readMarkdownLineElementLink(
|
|||
}
|
||||
|
||||
const externalDest = externalDestRes.ok;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: { type: "external", external: externalDest },
|
||||
ok: {
|
||||
dest: { type: "external", external: externalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else if (peekStringEq(chars, "internal:")) {
|
||||
const internalRes = expectReadString(chars, "internal:");
|
||||
} else if (peekStringEq(chars, "internal")) {
|
||||
const internalRes = expectReadString(chars, "internal");
|
||||
if (internalRes.type === "err") {
|
||||
return internalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
const destRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link dest",
|
||||
closingChar: "]",
|
||||
closingChar: ")",
|
||||
});
|
||||
|
||||
if (destRes.type === "err") {
|
||||
|
|
@ -225,40 +376,47 @@ function readMarkdownLineElementLink(
|
|||
}
|
||||
|
||||
const internalDest = internalDestRes.ok;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: { type: "internal", internal: internalDest },
|
||||
ok: {
|
||||
dest: { type: "internal", internal: internalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected external: or internal: link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
|
||||
err: `Expected external or internal link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (destRes.type === "err") {
|
||||
return destRes;
|
||||
if (linkPropsRes.type === "err") {
|
||||
return linkPropsRes;
|
||||
}
|
||||
|
||||
const dest = destRes.ok;
|
||||
const openParenRes = expectReadChar(chars, "(");
|
||||
if (openParenRes.type === "err") {
|
||||
return openParenRes;
|
||||
}
|
||||
const { dest, newTab } = linkPropsRes.ok;
|
||||
|
||||
const nameRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link name",
|
||||
closingChar: ")",
|
||||
});
|
||||
const resolvedLabel: MarkdownLineElement[] =
|
||||
labelElements.length === 0 ?
|
||||
[
|
||||
{
|
||||
type: "plain",
|
||||
plain:
|
||||
dest.type === "external" ?
|
||||
dest.external
|
||||
: `${window.location.protocol}${window.location.hostname}${dest.internal.route ?? window.location.pathname}${dest.internal.id === undefined ? "" : `#${dest.internal.id}`}`,
|
||||
},
|
||||
]
|
||||
: labelElements;
|
||||
|
||||
if (nameRes.type === "err") {
|
||||
return nameRes;
|
||||
}
|
||||
|
||||
const name = nameRes.ok;
|
||||
return { type: "ok", ok: [{ type: "link", link: { name, to: dest } }] };
|
||||
return {
|
||||
type: "ok",
|
||||
ok: [
|
||||
{ type: "link", link: { label: resolvedLabel, to: dest, newTab } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function validateExternalDest(dest: string): Result<SmartExternalDest, string> {
|
||||
|
|
@ -329,10 +487,11 @@ function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
|
|||
}
|
||||
}
|
||||
|
||||
function readMarkdownLineElementBold(
|
||||
function readMarkdownLineElementBold<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownLineElementCtx,
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const firstStarRes = expectReadChar(chars, "*");
|
||||
if (firstStarRes.type === "err") {
|
||||
return firstStarRes;
|
||||
|
|
@ -344,10 +503,10 @@ function readMarkdownLineElementBold(
|
|||
}
|
||||
|
||||
ctx.inBold = true;
|
||||
const elements: MarkdownLineElement[] = [];
|
||||
const elements: MarkdownLineElement<Slot>[] = [];
|
||||
let closed = false;
|
||||
while (true) {
|
||||
const elementRes = readMarkdownLineElement(chars, ctx);
|
||||
const elementRes = readMarkdownLineElement(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
|
@ -381,20 +540,21 @@ function readMarkdownLineElementBold(
|
|||
}
|
||||
}
|
||||
|
||||
function readMarkdownLineElementItalic(
|
||||
function readMarkdownLineElementItalic<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownLineElementCtx,
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
const starRes = expectReadChar(chars, "*");
|
||||
if (starRes.type === "err") {
|
||||
return starRes;
|
||||
}
|
||||
|
||||
ctx.inItalic = true;
|
||||
const elements: MarkdownLineElement[] = [];
|
||||
const elements: MarkdownLineElement<Slot>[] = [];
|
||||
let closed = false;
|
||||
while (true) {
|
||||
const elementRes = readMarkdownLineElement(chars, ctx);
|
||||
const elementRes = readMarkdownLineElement(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
|
@ -424,9 +584,9 @@ function readMarkdownLineElementItalic(
|
|||
}
|
||||
}
|
||||
|
||||
function readMarkdownLineElementPlain(
|
||||
function readMarkdownLineElementPlain<Slot extends string>(
|
||||
chars: string[],
|
||||
): Result<MarkdownLineElement[], string> {
|
||||
): Result<MarkdownLineElement<Slot>[], string> {
|
||||
let plain = "";
|
||||
let escaped = false;
|
||||
while (true) {
|
||||
|
|
@ -444,7 +604,13 @@ function readMarkdownLineElementPlain(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (peek === "*" || peek === "<") {
|
||||
if (
|
||||
peek === "*"
|
||||
|| peek === "<"
|
||||
|| peek === ">"
|
||||
|| peek === "["
|
||||
|| peek === "]"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -453,7 +619,10 @@ function readMarkdownLineElementPlain(
|
|||
chars.shift();
|
||||
}
|
||||
|
||||
return { type: "ok", ok: [{ type: "plain", plain }] };
|
||||
return {
|
||||
type: "ok",
|
||||
ok: plain.length > 0 ? [{ type: "plain", plain }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function expectReadChar(
|
||||
|
|
@ -539,3 +708,23 @@ function readUntilClosing(ctx: ReadUntilClosingCtx): Result<string, string> {
|
|||
|
||||
return { type: "ok", ok: value };
|
||||
}
|
||||
|
||||
export function isEmptyMarkdown(markdown: Markdown): boolean {
|
||||
// const [firstLine] = markdown.lines;
|
||||
// if (firstLine === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// const [firstElement] = firstLine.elements;
|
||||
// if (firstElement === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// if (firstElement.type === "plain" && firstElement.plain.length === 0) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// return false;
|
||||
|
||||
return markdown.lines.length === 0;
|
||||
}
|
||||
|
|
@ -95,7 +95,26 @@ export function bundleToUncompiledLocale(
|
|||
return { type: "ok", ok: { bundle, record } };
|
||||
}
|
||||
|
||||
type SelectionChain = (Literal | SelectionChain)[];
|
||||
export type SelectionChain = (Literal | SelectionChain)[];
|
||||
|
||||
export function selectionChainToString(chain: SelectionChain): string {
|
||||
return chain
|
||||
.map((part) => {
|
||||
if ("type" in part) {
|
||||
switch (part.type) {
|
||||
case "str": {
|
||||
return `[${part.value}]`;
|
||||
}
|
||||
case "num": {
|
||||
return `[${String(part.value)};${String(part.precision)}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `(${selectionChainToString(part)})`;
|
||||
})
|
||||
.join("+");
|
||||
}
|
||||
|
||||
interface PatternVariant {
|
||||
selectionChain: SelectionChain;
|
||||
|
|
@ -110,6 +129,7 @@ export function computeAllVariants(
|
|||
}
|
||||
|
||||
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
||||
console.log(pattern);
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
variants = variants.map((variant) => ({
|
||||
|
|
@ -166,6 +186,13 @@ export function computeAllVariants(
|
|||
case "var": {
|
||||
continue;
|
||||
}
|
||||
case "str": {
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + element.value,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
|
|
@ -1,268 +1,72 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
type L10nRecord,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentVariable } from "@fluent/bundle";
|
||||
|
||||
export const configMessageSymbol: unique symbol = Symbol("configMessage");
|
||||
export interface ConfigMessage<
|
||||
Slot extends string = string,
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = {
|
||||
[name in string]: ConfigPlaceableInfo;
|
||||
},
|
||||
Markdown extends ConfigMarkdown = ConfigMarkdown,
|
||||
> {
|
||||
[configMessageSymbol]: true;
|
||||
slots: Slot[];
|
||||
placeables: Placeables;
|
||||
markdown: Markdown;
|
||||
}
|
||||
|
||||
export interface ConfigPlaceableInfo<
|
||||
Type extends "string" | "number" = "string" | "number",
|
||||
> {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export interface ConfigMarkdown<Bold extends boolean = boolean> {
|
||||
bold?: Bold;
|
||||
}
|
||||
|
||||
function message<
|
||||
const Slot extends string = never,
|
||||
const Placeables extends {
|
||||
[name in string]?: ConfigPlaceableInfo;
|
||||
} = object,
|
||||
const Markdown extends ConfigMarkdown = object,
|
||||
>(
|
||||
opt: Partial<
|
||||
Omit<
|
||||
ConfigMessage<Slot, Placeables, Markdown>,
|
||||
typeof configMessageSymbol
|
||||
>
|
||||
> = {},
|
||||
): ConfigMessage<Slot, Placeables, Markdown> {
|
||||
return {
|
||||
[configMessageSymbol]: true,
|
||||
slots: opt.slots ?? [],
|
||||
placeables: opt.placeables ?? {},
|
||||
markdown: opt.markdown ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
type LocaleConfig<
|
||||
Slot extends string = string,
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
Markdown extends ConfigMarkdown = object,
|
||||
> = {
|
||||
[id: string]:
|
||||
| ConfigMessage<Slot, Placeables, Markdown>
|
||||
| LocaleConfig<Slot, Placeables, Markdown>;
|
||||
};
|
||||
|
||||
type MessageCtx<
|
||||
Slot extends string = string,
|
||||
Placeables extends { [name in string]?: ConfigPlaceableInfo } = object,
|
||||
> = {
|
||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||
>;
|
||||
};
|
||||
|
||||
type PlaceableType<Var extends ConfigPlaceableInfo> =
|
||||
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
|
||||
|
||||
type ResolvedPlaceableType<Type extends "string" | "number"> =
|
||||
Type extends "string" ? string
|
||||
: Type extends "number" ? number
|
||||
: never;
|
||||
|
||||
type InferLocale<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ? InferLocale<Config[K]>
|
||||
: Config[K] extends ConfigMessage<infer Slot, infer Placeables> ?
|
||||
object extends MessageCtx<Slot, Placeables> ?
|
||||
() => string
|
||||
: (ctx: MessageCtx<Slot, Placeables>) => string
|
||||
: never;
|
||||
};
|
||||
message,
|
||||
record,
|
||||
type InferLocale,
|
||||
type LocaleConfig,
|
||||
} from "@/new-i18n-lib/config";
|
||||
|
||||
const homeSectionConfig = { title: message(), body: message() };
|
||||
|
||||
const imageConfig = { alt: message() };
|
||||
export interface Image extends InferLocale<typeof imageConfig> {}
|
||||
|
||||
const buttonConfig = { label: message() };
|
||||
|
||||
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
|
||||
title: message(),
|
||||
subtitle: message(),
|
||||
desc: message(),
|
||||
buttons: record(buttonKeys, () => buttonConfig),
|
||||
});
|
||||
|
||||
const discordRuleConfig = {
|
||||
overview: {
|
||||
text: message({ markdown: { bold: true, italic: true, link: true } }),
|
||||
subtext: message({
|
||||
markdown: { bold: true, italic: true, link: true },
|
||||
}),
|
||||
},
|
||||
section: {
|
||||
header: message({ placeables: { ruleNumber: { type: "number" } } }),
|
||||
body: message({
|
||||
markdown: { bold: true, header: true, italic: true, link: true },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const localeConfig = {
|
||||
localeName: message(),
|
||||
vilanticLangs: { viossa: message(), wodox: message() },
|
||||
navbar: {
|
||||
whatIsViossa: message(),
|
||||
resources: message(),
|
||||
kotoba: message(),
|
||||
},
|
||||
vilanticLangs: record(["viossa", "wodox"], () => message()),
|
||||
navbar: record(["whatIsViossa", "resources", "kotoba"], () => message()),
|
||||
home: {
|
||||
sections: {
|
||||
whatIsViossa: homeSectionConfig,
|
||||
historyOfViossa: homeSectionConfig,
|
||||
community: homeSectionConfig,
|
||||
},
|
||||
images: { viossaFlag: imageConfig },
|
||||
sections: record(
|
||||
["whatIsViossa", "historyOfViossa", "community"],
|
||||
() => homeSectionConfig,
|
||||
),
|
||||
images: record(["viossaFlag"], () => imageConfig),
|
||||
},
|
||||
richTest: {
|
||||
slot: message({ slots: ["slot"] }),
|
||||
placeable: message({
|
||||
placeables: {
|
||||
wow: { type: "string" },
|
||||
placeable: { type: "number" },
|
||||
},
|
||||
}),
|
||||
bold: message({ markdown: { bold: true } }),
|
||||
resources: {
|
||||
title: message(),
|
||||
resources: { discord: resourceConfig(["join", "rules"]) },
|
||||
images: record(["discordLogo"], () => imageConfig),
|
||||
},
|
||||
kotoba: { title: message(), searchHelp: message() },
|
||||
discord: {
|
||||
rulesPage: {
|
||||
title: message(),
|
||||
overview: { title: message(), help: message() },
|
||||
rules: record(
|
||||
[
|
||||
"noTranslation",
|
||||
"lfsv",
|
||||
"viossaOnlyChats",
|
||||
"sfw",
|
||||
"respectOthers",
|
||||
"respectStaff",
|
||||
"controversialTopics",
|
||||
],
|
||||
() => discordRuleConfig,
|
||||
),
|
||||
},
|
||||
},
|
||||
} as const satisfies LocaleConfig;
|
||||
|
||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||
uncompiled: UncompiledLocale;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export function compileLocale<Config extends LocaleConfig>(
|
||||
ctx: CompileLocaleCtx<Config>,
|
||||
): Result<InferLocale<Config>, string> {
|
||||
const { uncompiled, config } = ctx;
|
||||
|
||||
const { record, bundle } = uncompiled;
|
||||
|
||||
const recordKeys = new Set(Object.keys(record));
|
||||
const configKeys = new Set(Object.keys(config));
|
||||
const excessKeys = recordKeys.difference(configKeys);
|
||||
console.log(recordKeys, configKeys, excessKeys);
|
||||
if (excessKeys.size > 0) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Excess keys in record: ${[...excessKeys].join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
type GenericLocale = {
|
||||
[id: string]:
|
||||
| GenericLocale
|
||||
| ((args: Record<string, FluentVariable>) => string);
|
||||
};
|
||||
|
||||
const locale: GenericLocale = {};
|
||||
for (const [id, configValue] of Object.entries(config)) {
|
||||
const recordValue = record[id];
|
||||
|
||||
if (configMessageSymbol in configValue) {
|
||||
if (recordValue?.type !== "message") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected message for key \`${id}\`, found: ${typeof recordValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
const message = recordValue.message;
|
||||
console.log(message);
|
||||
|
||||
const pattern = message.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Pattern is null for message with ID: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = computeAllVariants(pattern);
|
||||
console.log(allVariants);
|
||||
|
||||
// validate placeables
|
||||
console.log(pattern);
|
||||
if (typeof pattern !== "string") {
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const { selector } = element;
|
||||
if (selector.type !== "var") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected selector to be a var expression for key: ${id}; Found: ${selector.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
selector.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${selector.name}\` for key: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
element.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${element.name}\` for key: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "term":
|
||||
case "mesg":
|
||||
case "func":
|
||||
case "str":
|
||||
case "num": {
|
||||
break; // ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if all variants are valid markdown
|
||||
|
||||
// create function
|
||||
locale[id] = (args: Record<string, FluentVariable> = {}) =>
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
bundle.formatPattern(pattern, args);
|
||||
} else {
|
||||
if (recordValue?.type !== "subrecord") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected subrecord for key \`${id}\`, found: ${typeof recordValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
const subrecord = recordValue.subrecord;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
uncompiled: { bundle, record: subrecord },
|
||||
config: configValue,
|
||||
});
|
||||
|
||||
if (compiledSubrecordRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Error when compiling subrecord with key: \`${id}\`:\n${compiledSubrecordRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const compiledSubrecord = compiledSubrecordRes.ok;
|
||||
locale[id] = compiledSubrecord;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: validated above that all keys exist and are the correct type
|
||||
return { type: "ok", ok: locale as InferLocale<Config> };
|
||||
}
|
||||
|
|
|
|||
161
apps/vdn-static/src/new-i18n/index.ts
Normal file
161
apps/vdn-static/src/new-i18n/index.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { compileLocale, type InferLocale } from "@/new-i18n-lib/config";
|
||||
import {
|
||||
bundleToUncompiledLocale,
|
||||
loadFluentBundle,
|
||||
} from "@/new-i18n-lib/setup";
|
||||
import { localeConfig } from "./config";
|
||||
import type { Result } from "@/utils/types";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { computed, type DeepReadonly } from "vue";
|
||||
import { type } from "arktype";
|
||||
import enUsFtlSrc from "@/assets/locale/en_US.ftl";
|
||||
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
|
||||
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
|
||||
import type { FluentBundle } from "@fluent/bundle";
|
||||
|
||||
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
|
||||
|
||||
export type LocaleId = typeof LocaleId.infer;
|
||||
export const LocaleId = type.enumerated(...LOCALE_IDS);
|
||||
|
||||
export const DEFAULT_LOCALE_ID = "en-US" satisfies LocaleId;
|
||||
|
||||
// users could manually edit localStorage to make this value anything, so we need to validate it
|
||||
const localStorageLocaleId = useLocalStorage<unknown>(
|
||||
"localeId",
|
||||
DEFAULT_LOCALE_ID,
|
||||
);
|
||||
|
||||
export const localeId = computed({
|
||||
get: (): LocaleId => {
|
||||
const localeIdRes = LocaleId(localStorageLocaleId.value);
|
||||
if (localeIdRes instanceof type.errors) {
|
||||
// if invalid LocaleId, reset to default
|
||||
localStorageLocaleId.value = DEFAULT_LOCALE_ID;
|
||||
return DEFAULT_LOCALE_ID;
|
||||
}
|
||||
|
||||
// else return user's selection
|
||||
const localeId = localeIdRes;
|
||||
return localeId;
|
||||
},
|
||||
// custom setter to ensure it is only set to a valid LocaleId by our code
|
||||
// (since the localStorage ref is typed as `unknown`, it can be set to any value)
|
||||
set: (id: LocaleId) => {
|
||||
localStorageLocaleId.value = id;
|
||||
},
|
||||
});
|
||||
|
||||
export interface Locale extends InferLocale<typeof localeConfig> {}
|
||||
|
||||
async function loadLocale(
|
||||
localeId: LocaleId,
|
||||
localeFtlSrc: string,
|
||||
): Promise<Result<FluentBundle, string>> {
|
||||
const bundleRes = await loadFluentBundle(localeId, localeFtlSrc);
|
||||
if (bundleRes.type === "err") {
|
||||
return bundleRes;
|
||||
}
|
||||
|
||||
const bundle = bundleRes.ok;
|
||||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
function setupLocale(
|
||||
localeBundle: FluentBundle,
|
||||
fallbackLocaleBundle: FluentBundle | undefined,
|
||||
): Result<Locale, string> {
|
||||
const maybeFallbackedBundle = (() => {
|
||||
if (fallbackLocaleBundle === undefined) {
|
||||
return localeBundle;
|
||||
}
|
||||
|
||||
console.log(localeBundle);
|
||||
console.log(fallbackLocaleBundle);
|
||||
|
||||
const localeMessageIds = new Set(localeBundle._messages.keys());
|
||||
const fallbackMessageIds = new Set(
|
||||
fallbackLocaleBundle._messages.keys(),
|
||||
);
|
||||
|
||||
const missingMessageIds =
|
||||
fallbackMessageIds.difference(localeMessageIds);
|
||||
|
||||
for (const id of missingMessageIds) {
|
||||
const fallbackMessage = fallbackLocaleBundle._messages.get(id);
|
||||
if (fallbackMessage) {
|
||||
localeBundle._messages.set(id, fallbackMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return localeBundle;
|
||||
})();
|
||||
|
||||
const uncompiledRes = bundleToUncompiledLocale(maybeFallbackedBundle);
|
||||
if (uncompiledRes.type === "err") {
|
||||
return uncompiledRes;
|
||||
}
|
||||
|
||||
const uncompiled = uncompiledRes.ok;
|
||||
|
||||
const localeRes = compileLocale({ config: localeConfig, uncompiled });
|
||||
|
||||
if (localeRes.type === "err") {
|
||||
return localeRes;
|
||||
}
|
||||
|
||||
const locale = localeRes.ok;
|
||||
return { type: "ok", ok: locale };
|
||||
}
|
||||
|
||||
function unwrap<T, E>(result: Result<T, E>): T {
|
||||
switch (result.type) {
|
||||
case "ok": {
|
||||
return result.ok;
|
||||
}
|
||||
case "err": {
|
||||
throw new Error(String(result.err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepReadonly<T>(value: T): DeepReadonly<T> {
|
||||
// SAFETY: we're just making an immutable view to the type, this isn't dangerous
|
||||
return value as DeepReadonly<T>;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCALE = unwrap(await loadLocale("en-US", enUsFtlSrc));
|
||||
|
||||
const doItAllForLocale = async (
|
||||
localeId: LocaleId,
|
||||
localeFtlSrc: string,
|
||||
): Promise<DeepReadonly<Locale>> =>
|
||||
deepReadonly(
|
||||
unwrap(
|
||||
setupLocale(
|
||||
unwrap(await loadLocale(localeId, localeFtlSrc)),
|
||||
DEFAULT_LOCALE,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const [vpVl, wpVl] = await Promise.all([
|
||||
doItAllForLocale("vp-VL", vpVlFtlSrc),
|
||||
doItAllForLocale("wp-VL", wpVlFtlSrc),
|
||||
]);
|
||||
|
||||
const localeIdToLocale = {
|
||||
"en-US": deepReadonly(unwrap(setupLocale(DEFAULT_LOCALE, DEFAULT_LOCALE))),
|
||||
"vp-VL": vpVl,
|
||||
"wp-VL": wpVl,
|
||||
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
||||
|
||||
export interface UseLocaleOptions {
|
||||
locale?: LocaleId;
|
||||
}
|
||||
|
||||
export const useLocale = (opt: UseLocaleOptions = {}) =>
|
||||
computed<DeepReadonly<Locale>>(() => {
|
||||
const localLocaleId = opt.locale ?? localeId.value;
|
||||
return localeIdToLocale[localLocaleId];
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
|
||||
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
|
||||
import { useLocale } from "@/i18n";
|
||||
import { useLocale } from "@/new-i18n";
|
||||
import { computed } from "vue";
|
||||
|
||||
const locale = useLocale();
|
||||
|
|
@ -24,13 +24,13 @@ const RULE_ORDER = [
|
|||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">
|
||||
{{ pageI18n.title }}
|
||||
{{ pageI18n.title() }}
|
||||
</h1>
|
||||
</section>
|
||||
<section class="section content">
|
||||
<h2>{{ pageI18n.overview.title }}</h2>
|
||||
<h2>{{ pageI18n.overview.title() }}</h2>
|
||||
<blockquote>
|
||||
{{ pageI18n.overview.help }}
|
||||
{{ pageI18n.overview.help() }}
|
||||
</blockquote>
|
||||
<ol :style="{ display: 'flex', flexDirection: 'column' }">
|
||||
<DiscordRuleOverview
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
|
||||
import { useLocale } from "@/i18n";
|
||||
import { GREETINGS, type Greeting } from "@/i18n/greeting";
|
||||
import type * as i18n from "@/i18n/locale";
|
||||
import { VILANTIC_ID_TO_FLAG } from "@/i18n/vilantic";
|
||||
import { GREETINGS, type Greeting } from "@/new-i18n/greeting";
|
||||
import { VILANTIC_ID_TO_FLAG } from "@/new-i18n/vilantic";
|
||||
import { randomElement } from "@/utils/random";
|
||||
import { computed } from "vue";
|
||||
import flakkaImg from "@/assets/flakka.png";
|
||||
import { useLocale, type Locale } from "@/new-i18n";
|
||||
import type * as i18n from "@/new-i18n/config";
|
||||
|
||||
interface SectionConfig {
|
||||
id: keyof i18n.Locale["home"]["sections"];
|
||||
id: keyof Locale["home"]["sections"];
|
||||
image?: keyof typeof imagesI18n.value;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ const sectionsI18n = computed(() =>
|
|||
<div
|
||||
class="subtitle is-size-6 is-flex is-flex-direction-row is-align-items-center is-gap-1 has-text-text-bold">
|
||||
— {{ greeting.author }} ({{
|
||||
locale.vilanticLangs[greeting.lang]
|
||||
locale.vilanticLangs[greeting.lang]()
|
||||
}})
|
||||
<figure class="image is-32x32">
|
||||
<img :src="VILANTIC_ID_TO_FLAG[greeting.lang]" />
|
||||
|
|
@ -71,10 +71,10 @@ const sectionsI18n = computed(() =>
|
|||
<HomeSectionWrapper
|
||||
v-for="(sectioni18n, index) in sectionsI18n"
|
||||
:key="index"
|
||||
:title="sectioni18n.text.title"
|
||||
:text="sectioni18n.text.body"
|
||||
:title="sectioni18n.text.title()"
|
||||
:text="sectioni18n.text.body()"
|
||||
:image="sectioni18n.image?.src"
|
||||
:alt="sectioni18n.image?.metadata.alt"
|
||||
:alt="sectioni18n.image?.metadata.alt()"
|
||||
:reverse="index % 2 !== 0" />
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useLocale } from "@/i18n";
|
||||
import { useLocale } from "@/new-i18n";
|
||||
|
||||
const locale = useLocale();
|
||||
</script>
|
||||
|
|
@ -7,12 +7,12 @@ const locale = useLocale();
|
|||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">{{ locale.kotoba.title }}</h1>
|
||||
<h1 class="title">{{ locale.kotoba.title() }}</h1>
|
||||
</section>
|
||||
|
||||
<section class="section container">
|
||||
<div class="notification is-info block">
|
||||
<p>{{ locale.kotoba.searchHelp }}</p>
|
||||
<p>{{ locale.kotoba.searchHelp() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="block is-flex is-flex-direction-row is-gap-2">
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
import LearningResourceWrapper, {
|
||||
type ResourceButton,
|
||||
} from "@/components/molecules/LearningResourceWrapper.vue";
|
||||
import { useLocale, type CompileLocale } from "@/i18n";
|
||||
import type * as i18n from "@/i18n/locale";
|
||||
import { useLocale, type Locale } from "@/new-i18n";
|
||||
import type * as i18n from "@/new-i18n/config";
|
||||
import { ignore } from "@/utils/ignore";
|
||||
import { computed } from "vue";
|
||||
import discordImg from "@/assets/discord.png";
|
||||
|
||||
interface ResourceConfig {
|
||||
id: keyof i18n.Locale["resources"]["resources"];
|
||||
id: keyof Locale["resources"]["resources"];
|
||||
image?: keyof typeof imagesI18n.value;
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ const resourcesI18n = computed(() =>
|
|||
);
|
||||
|
||||
const computeButtons = (
|
||||
id: keyof CompileLocale<i18n.Locale>["resources"]["resources"],
|
||||
id: keyof 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
|
||||
|
|
@ -62,14 +62,14 @@ const computeButtons = (
|
|||
},
|
||||
newTab: true,
|
||||
},
|
||||
label: buttons.join.label,
|
||||
label: buttons.join.label(),
|
||||
style: { color: "primary" },
|
||||
},
|
||||
{
|
||||
link: {
|
||||
to: { type: "internal", internal: { route: "/discord/rules" } },
|
||||
},
|
||||
label: buttons.rules.label,
|
||||
label: buttons.rules.label(),
|
||||
style: { color: "warning", outlined: true },
|
||||
},
|
||||
];
|
||||
|
|
@ -79,20 +79,20 @@ const computeButtons = (
|
|||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">{{ locale.resources.title }}</h1>
|
||||
<h1 class="title">{{ locale.resources.title() }}</h1>
|
||||
</section>
|
||||
|
||||
<section class="section container">
|
||||
<LearningResourceWrapper
|
||||
v-for="(resourceI18n, index) in resourcesI18n"
|
||||
:key="index"
|
||||
:title="resourceI18n.text.title"
|
||||
:subtitle="resourceI18n.text.subtitle"
|
||||
:desc="resourceI18n.text.desc"
|
||||
:title="resourceI18n.text.title()"
|
||||
:subtitle="resourceI18n.text.subtitle()"
|
||||
:desc="resourceI18n.text.desc()"
|
||||
:image="
|
||||
resourceI18n.image && {
|
||||
src: resourceI18n.image.src,
|
||||
alt: resourceI18n.image.metadata.alt,
|
||||
alt: resourceI18n.image.metadata.alt(),
|
||||
}
|
||||
"
|
||||
:buttons="computeButtons(resourceI18n.id)" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue