Merge branch 'main' into patch-1

This commit is contained in:
Jezza Hehn 2026-05-03 23:11:18 -04:00 committed by GitHub
commit 69d1ff8219
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4111 additions and 1136 deletions

View file

@ -3,7 +3,6 @@
bråtula viossa.net måde! We're here to build an informational website about Viossa.
## The Stack
**What will we be using to build this site?**
### Core
- [TypeScript](https://www.typescriptlang.org/)
@ -14,11 +13,11 @@ bråtula viossa.net måde! We're here to build an informational website about Vi
- [Vue 3](https://vuejs.org/)
- [Vite](https://vite.dev/)
Additionally, we will be following [**atomic design principles**](https://bradfrost.com/blog/post/atomic-web-design/) to organize the components of the project.
See [**atomic design principles**](https://bradfrost.com/blog/post/atomic-web-design/) for guidance on designing components.
### Styling
- [Bulma](https://bulma.io/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Bulma](https://bulma.io/) - the primary stylesheet, which supports lots of basic elements simply. Prototyping is easy, since Bulma is built around applying classes to generic elements like `div` et al in order to achieve visual componentness
- [Tailwind CSS](https://tailwindcss.com/) - Included supplimentarily: Bulma is opinionated and only maintained by one person, so we may discover it is inadequate for certain purposes. if necessary, we can use (or fully switch to) Tailwind.
- [Sass](https://sass-lang.com/)
### Backend
@ -29,7 +28,6 @@ Additionally, we will be following [**atomic design principles**](https://bradfr
- [ESLint](https://eslint.org/)
## Setup/Installation
**How do we install this project?**
1. Download and install Node.js and pnpm
- [Node.js installation instructions](https://nodejs.org/en/download)

View file

@ -1,24 +1,62 @@
import js from '@eslint/js';
import globals from 'globals';
import ts from 'typescript-eslint';
import vue from 'eslint-plugin-vue';
import js from "@eslint/js";
import globals from "globals";
import ts from "typescript-eslint";
import vue from "eslint-plugin-vue";
import { defineConfig, globalIgnores } from "eslint/config";
import vueParser from "vue-eslint-parser";
export default ts.config(
{ ignores: ['dist'] },
export default defineConfig([
globalIgnores(["dist"]),
{
extends: [
js.configs.recommended,
...ts.configs.recommendedTypeChecked,
...vue.configs.recommendedTypeChecked,
ts.configs.strictTypeChecked,
...vue.configs["flat/essential"],
],
files: ['**/*.{js,ts,vue}'],
files: ["./src/**/*.{js,ts,vue}"],
plugins: { vue },
languageOptions: {
ecmaVersion: 2020,
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser,
parser: vueParser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
parser: ts.parser,
extraFileExtensions: [".vue"],
},
},
rules: {
"vue/no-restricted-html-elements": [
"error",
{
element: ["a", "RouterLink"],
message: "Use <SmartLink> instead.",
},
)
{ element: ["i18n-t"], message: "Use <RichTemplate> instead." },
{
element: ["RichTemplateParts"],
message:
"Do not use the internal <RichTemplateParts> component. Use <RichTemplate> instead.",
},
],
// allow interfaces to only extend another interface without adding properties
// good for aliasing more complex types
"@typescript-eslint/no-empty-object-type": [
"error",
{ allowInterfaces: "with-single-extends" },
],
"vue/no-ref-object-reactivity-loss": ["error"],
"@typescript/no-unnecessary-conditions": [
"error",
{ allowConstantLoopConditions: "only-allowed-literals" },
],
},
},
// disable multi-word-component-names for unplugin-vue-router
{
files: ["src/pages/**/*.vue"],
rules: { "vue/multi-word-component-names": "off" },
},
]);

View file

@ -1,15 +1,19 @@
<!doctype html>
<html lang="en" class="has-navbar-fixed-top">
<head>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/viossa_circle.svg"/>
<link rel="icon" type="image/svg+xml" href="/viossa_circle.svg" />
<link href='https://cdn.boxicons.com/fonts/basic/boxicons.min.css' rel='stylesheet'>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Viossa.net</title>
</head>
<body>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</body>
</html>

View file

@ -9,31 +9,35 @@
"preview": "vite preview"
},
"dependencies": {
"@fluent/bundle": "^0.19.1",
"@tailwindcss/vite": "^4.1.6",
"@types/node": "^22.15.17",
"@types/node": "^22.15.31",
"@vueuse/components": "^13.3.0",
"@vueuse/core": "^13.3.0",
"arktype": "^2.1.29",
"axios": "^1.11.0",
"bulma": "^1.0.4",
"tailwindcss": "^4.1.6",
"vue": "^3.5.13",
"vue-i18n": "^11.1.3",
"vue-router": "^4.5.1"
"vue-router": "^5.0.4"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@repo/common": "workspace:*",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.26.0",
"eslint-plugin-vue": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"globals": "^17.3.0",
"prettier": "^3.5.3",
"sass": "^1.87.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.32.1",
"unplugin-vue-router": "^0.12.0",
"typescript-eslint": "^8.55.0",
"unplugin-vue-router": "^0.19.2",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
"vue": "^3.5.32",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.2.6"
},
"packageManager": "pnpm@10.11.0"
}

View file

@ -1,9 +1,14 @@
<script setup lang="ts">
import "./assets/style.scss";
import { ref, type Ref } from "vue";
import { computed, ref, type Ref } from "vue";
import LocalePicker from "./components/organisms/LocalePicker.vue";
import { vOnClickOutside } from "@vueuse/components";
import { useLocale } from "./i18n";
import { useRouter } from "vue-router";
import SmartLink from "./components/atoms/SmartLink.vue";
import type { SmartDest } from "./utils/smart-dest";
import { useLocale, type Locale } from "./i18n";
const locale = useLocale();
const burgerOpen: Ref<boolean> = ref<boolean>(false);
@ -15,7 +20,46 @@ const closeBurger = (): void => {
burgerOpen.value = false;
};
const locale = useLocale();
const router = useRouter();
router.beforeEach(() => {
closeBurger();
});
interface NavbarItem {
to: SmartDest;
label: string;
}
const NAVBAR_ITEM_ORDER = [
"whatIsViossa",
"resources",
"kotoba",
] as const satisfies (keyof Locale["navbar"])[];
const navbarItems = computed(() =>
NAVBAR_ITEM_ORDER.map((id): NavbarItem => {
const label = locale.value.navbar[id]();
const to = ((): SmartDest => {
switch (id) {
case "whatIsViossa": {
return { type: "internal", internal: { route: "/" } };
}
case "resources": {
return {
type: "internal",
internal: { route: "/resources" },
};
}
case "kotoba": {
return { type: "internal", internal: { route: "/kotoba" } };
}
}
})();
return { to, label };
}),
);
</script>
<template>
@ -26,11 +70,13 @@ const locale = useLocale();
role="navigation"
aria-label="main navigation">
<div class="navbar-brand">
<RouterLink class="navbar-item has-text-weight-bold" to="/"
><img src="@/assets/ViossaFlagRect.svg" alt=""
/></RouterLink>
<SmartLink
class="navbar-item has-text-weight-bold"
:to="{ type: 'internal', internal: { route: '/' } }">
<img src="@/assets/ViossaFlagRect.svg" alt="" />
</SmartLink>
<div class="navbar-item">
<div class="navbar-item is-hidden-desktop">
<button
type="button"
@click="toggleBurger()"
@ -44,21 +90,13 @@ const locale = useLocale();
<div :class="`navbar-menu ${burgerOpen ? 'is-active' : ''}`">
<div class="navbar-start">
<RouterLink
<SmartLink
v-for="(item, index) in navbarItems"
:key="index"
class="navbar-item"
to="/"
@click="closeBurger()"
>{{ locale.navbar.whatIsViossa }}</RouterLink
>
<RouterLink
class="navbar-item"
to="/resources"
@click="closeBurger()"
>{{ locale.navbar.resources }}</RouterLink
>
<RouterLink class="navbar-item" to="/kotoba">
{{ locale.navbar.kotoba }}
</RouterLink>
:to="item.to"
>{{ item.label }}
</SmartLink>
<LocalePicker class="navbar-item" />
</div>
</div>

4
apps/vdn-static/src/assets.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.ftl" {
const src: string;
export default src;
}

View file

@ -0,0 +1,78 @@
localeName = "English"
vilanticLangs-viossa = "Viossa"
vilanticLangs-wodox = "Wodoch"
vilanticLangs-minemiaha = "Minemiaha"
navbar-whatIsViossa = "What is Viossa?"
navbar-resources = "Resources"
navbar-kotoba = "Kotoba"
home-sections-whatIsViossa-title = "What is Viossa?"
home-sections-whatIsViossa-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."
home-sections-historyOfViossa-title = "History of Viossa"
home-sections-historyOfViossa-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."
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"
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 = "Viossa Diskordserver (VDS) was founded in 2016, as the successor to the original Viossa chat on Skype, since then it has grown to have over 6,000 members. Via the buttons, please read the rules and then join the server!"
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 = md "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 = md --
discord-rulesPage-rules-noTranslation-section-header = "Rule { $ruleNumber }: No translation"
discord-rulesPage-rules-noTranslation-section-body = md
"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 = md "If it's understood, it's Viossa."
discord-rulesPage-rules-lfsv-overview-subtext = md --
discord-rulesPage-rules-lfsv-section-header = "Rule { $ruleNumber }: If it's understood, it's Viossa"
discord-rulesPage-rules-lfsv-section-body = md
"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 = md "The chats in the Viossa Only category are Viossa only."
discord-rulesPage-rules-viossaOnlyChats-overview-subtext = md --
discord-rulesPage-rules-viossaOnlyChats-section-header = "Rule { $ruleNumber }: Viossa-only chats"
discord-rulesPage-rules-viossaOnlyChats-section-body = md
"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 = md "This server is SFW. No sexually explicit, gory, or violent content."
discord-rulesPage-rules-sfw-overview-subtext = md --
discord-rulesPage-rules-sfw-section-header = "Rule { $ruleNumber }: SFW"
discord-rulesPage-rules-sfw-section-body = md
"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 = md "Don't use hate speech, and respect each other."
discord-rulesPage-rules-respectOthers-overview-subtext = md --
discord-rulesPage-rules-respectOthers-section-header = "Rule { $ruleNumber }: Respect one another"
discord-rulesPage-rules-respectOthers-section-body = md
"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 = md "Respect the rulings of the staff (**@Yewald** and **@Yewaldnen**)."
discord-rulesPage-rules-respectStaff-overview-subtext = md --
discord-rulesPage-rules-respectStaff-section-header = "Rule { $ruleNumber }: Respect the staff's rulings"
discord-rulesPage-rules-respectStaff-section-body = md
"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 = md "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 = md "**#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 = md
"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/)"

View file

@ -0,0 +1,78 @@
localeName = "Español (Latinoamérica)"
vilanticLangs-viossa = "Viossa"
vilanticLangs-wodox = "Wodoch"
vilanticLangs-minemiaha = "Minemiaha"
navbar-whatIsViossa = "¿Qué es Viossa?"
navbar-resources = "Recursos"
navbar-kotoba = "Kotoba"
home-sections-whatIsViossa-title = "¿Qué es Viossa?"
home-sections-whatIsViossa-body = "Viossa es una lengua pidgin artificial que fue creada en comunidad para simular la formación de idiomas pidgin naturales. Viossa se caracteriza por no tener ningn estandár, por lo que cada hablante desarrolla su propio idiolecto. La pronunciación y la ortografía pueden variar mucho y cumplen un rol importante en la autoexpresión. Viossa se enseña y aprende únicamente por inmersión. Las traducciones están prohibidas en el proceso de aprendizaje."
home-sections-historyOfViossa-title = "Historia de Viossa"
home-sections-historyOfViossa-body = "Viossa nació como un grupo de Skype en 2014, el cual fue creado por miembros del subreddit r/conlangs como un experimento para simular la formación de un idioma pidgin. Un pidgin es una lengua simple que surge por medio del contacto entre grupos sin una lengua en común. A diferencia de la mayoría de pidgins, que están formados a partir de dos o tres lenguas contribuyentes, Viossa tiene raíces en un gran número de idiomas diversos. Esto se debe a que mucha gente alrededor del mundo se unió para aportar al vocabulario de Viossa."
home-sections-community-title = "Comunidad"
home-sections-community-body = "La diversidad cultural de sus miembros y la integración de tradiciones de muchas partes del mundo le dan a la comunidad de Viossa una riqueza y valor únicos. La filosofía de su enseñanza promueve la inmersión lingüística y se opone al prescriptivismo, lo que resulta en una cultura tan diversa como el idioma en sí y sus hablantes. Para muchos, sus idiolectos son fundamentales para su forma de expresarse y su identidad. La naturaleza particular de Viossa y la ausencia de significados rígidos en él lo hacen ideal para actividades creativas como escribir poemas o canciones."
home-images-viossaFlag-alt = "Bandera del idioma Viossa"
resources-title = "Recursos de aprendizaje"
resources-resources-discord-title = "Servidor de Discord"
resources-resources-discord-subtitle = "¡Aquí es donde ocurre la mayor parte de la acción! ¡Únetenos!"
resources-resources-discord-desc = "Viossa Diskordserver (VDS) fue fundado en 2016 como sucesor del chat original de Viossa en Skype. Desde entonces ha superado los 6.000 miembros. ¡Lee las reglas con ayuda de los botones y luego únete al servidor!"
resources-resources-discord-buttons-join-label = "Unirse"
resources-resources-discord-buttons-rules-label = "Reglas"
resources-images-discordLogo-alt = "Logo de Discord"
kotoba-title = "Búsqueda independiente de tropos"
kotoba-searchHelp = "Introduce un término abajo para buscar sin importar el tropos"
discord-rulesPage-title = "Las reglas del servidor de Discord"
discord-rulesPage-overview-title = "Descripción general"
discord-rulesPage-overview-help = "Haz click en cualquier regla para ver detalles."
discord-rulesPage-rules-noTranslation-overview-text = md "¡Nada de traducciones! No traduzcas nada de/a Viossa dentro del servidor a excepción de las Grandes Cuatro (¡puedes aprender sin ellas en modo difícil!)"
discord-rulesPage-rules-noTranslation-overview-subtext = md --
discord-rulesPage-rules-noTranslation-section-header = "Regla { $ruleNumber }: Nada de traducciones"
discord-rulesPage-rules-noTranslation-section-body = md
"No traducimos nada en la enseñanza y aprendizaje de Viossa. En cambio, utilizamos imágenes, diagramas, videollamadas y otros métodos para conectar las palabras con sus significados."
"Está permitido traducir las siguientes cuatro palabras en el servidor. Si quieres un reto más difícil, no reveles el texto:"
"*TODO - big 4*"
"Permitimos también, fuera del proceso de aprendizaje, las traducciones artísticas (como aquellas de canciones, libros o poemas) así como las académicas (como aquellas utilizadas en investigaciones formales). En ambos casos, estas excepciones dependen de que dichas traducciones sean presentadas en el lugar adecuado. Por favor pregúntanos si no tienes certeza de dónde ponerlas."
"Además, no intentes replicar ni compartir ningún material de aprendizaje basado en la traducción en el servidor, y tampoco convenzas a los demás miembros de hacer esto."
discord-rulesPage-rules-lfsv-overview-text = md "Si se entiende, es Viossa."
discord-rulesPage-rules-lfsv-overview-subtext = md --
discord-rulesPage-rules-lfsv-section-header = "Regla { $ruleNumber }: Si se entiende, es Viossa"
discord-rulesPage-rules-lfsv-section-body = md
"Lo único que se requiere para hablar Viossa es que los demás hablantes te entiendan. No hay formas correctas ni erróneas de hablar o escribir ni hay un estándar global."
"No obstante, Viossa es un proyecto colaborativo: los miembros deben procurar darse a entender por los demás y a su vez esforzarse para entender a otros."
discord-rulesPage-rules-viossaOnlyChats-overview-text = md "Sólo se puede hablar Viossa en los canales de la categoría Viossa Only (Sólo Viossa)"
discord-rulesPage-rules-viossaOnlyChats-overview-subtext = md --
discord-rulesPage-rules-viossaOnlyChats-section-header = "Regla { $ruleNumber }: Canales en Viossa Only (Sólo Viossa)"
discord-rulesPage-rules-viossaOnlyChats-section-body = md
"No se permiten otros idiomas en los canales de la categoría Viossa Only. Si tienes que guiar a alguien en el proceso de aprendizaje y no puedes hacerlo en Viossa, dirígete a **#meta**"
"Sin embargo, esto no quiere decir que no puedas usar Viossa en los demás canales. ¡Háblalo donde quieras!"
discord-rulesPage-rules-sfw-overview-text = md "Este servidor es SFW. Se prohíbe el contenido violento, gore o sexualmente explícito."
discord-rulesPage-rules-sfw-overview-subtext = md --
discord-rulesPage-rules-sfw-section-header = "Regla { $ruleNumber }: SFW"
discord-rulesPage-rules-sfw-section-body = md
"Si a un moderador no le gusta lo que mandaste, te lo va a informar; mira [la regla 6](internal.replace:#rule-6). Esto es un servidor público; piensa antes de enviar algo."
discord-rulesPage-rules-respectOthers-overview-text = md "No se permite el discurso de odio. Hay que respetarse unos a otros."
discord-rulesPage-rules-respectOthers-overview-subtext = md --
discord-rulesPage-rules-respectOthers-section-header = "Rule { $ruleNumber }: Trata a los demás con respeto"
discord-rulesPage-rules-respectOthers-section-body = md
"Hay que respetarse unos a otros. No son bienvenidos el discurso de odio, la discriminación ni el apoyo a la violencia dentro o fuera del servidor. Ésta es una comunidad internacional, amigable y LGBTQ+."
discord-rulesPage-rules-respectStaff-overview-text = md "Respeta las decisiones del staff (**@Yewald** y **@Yewaldnen**)."
discord-rulesPage-rules-respectStaff-overview-subtext = md --
discord-rulesPage-rules-respectStaff-section-header = "Regla { $ruleNumber }: Respeta las decisiones del staff"
discord-rulesPage-rules-respectStaff-section-body = md
"Los miembros del staff (los Yewald al igual que los Yewaldnen) tienen la última palabra y pueden kickear, banear o silenciar miembros o cambiar sus permisos de acceso para garantizar que este entorno se mantenga seguro y para priorizar la integridad de la comunidad de Viossa."
"Siempre se tendrán en cuenta las apelaciones y puedes mandar un MD a cualquier Yewald o abrir un ticket con este comando de YAGPDB /tickets si consideras que un moderador actuó de forma inapropiada."
"Si has sido baneado, habrá instrucciones para apelar esta sanción, pero reflexiona a profundidad sobre el motivo del baneo antes de apelarlo."
discord-rulesPage-rules-controversialTopics-overview-text = md "Las discusiones sobre temas controversiales (política, guerra, entre otros) deben ser dirigidas al canal **#polite**, el cual requiere el rol **@Ike** (que sólo es accesible para los **@Viossadjin** y **@mellandjin**) para ser visto."
discord-rulesPage-rules-controversialTopics-overview-subtext = md "**#feels-and-advice** es para hablar abiertamente de tus sentimientos, pero ponemos el límite en las ideaciones suicidas o violentas. Éstas son cadenas de pensamiento que deben ser manejadas por un terapeuta y no son chistes. No pertenecen aquí por su seriedad."
discord-rulesPage-rules-controversialTopics-section-header = "Regla { $ruleNumber }: #polite e ike"
discord-rulesPage-rules-controversialTopics-section-body = md
"La vida está llena de situaciones complicadas y todos tenemos la necesidad de hablar de ellas. La categoría ike brinda el acceso a un grupo opcional de canales donde se permiten las discusiones de temas pesados, sensibles y potencialmente polémicos con la condición de que los usuarios sean especialmente respetuosos entre sí durante dichas discusiones. Al aceptar el rol ike estás de acuerdo con esta regla y fomentas que los demás hagan lo mismo."
"# Desahogarse vs pedir ayuda"
"A veces uno quiere expresar sus problemas para ser visto y otras veces necesita ayuda para resolverlos. Si estás abierto a uno y no al otro, te sugerimos comunicar esto en la conversación para recibir el tipo de respuesta más adecuado para ti."
"# Violencia y autolesiones"
"A pesar de que se permite hablar de las autolesiones (con el uso apropiado y claro de advertencias de contenido), este servidor no es un recurso de emergencia para la salud mental y no sustituye la ayuda profesional. Pedirle consejos a otros para encontrar apoyo o recursos fuera del servidor está bien, pero pedirles que te tranquilicen en medio de una crisis es inapropiado."
"No debes usar este espacio para:"
"- expresar intenciones o deseos de lastimarte a ti mismo o a alguien más"
"- pedir que te ayuden a evitar lastimarte a ti mismo o a alguien más"
"Al cruzar estos límites estás pidiéndole a los miembros del servidor (incluyendo a los moderadores y al dueño) que cumplan un rol para el cual no han sido entrenados ni equipados. Bajo la discreción de los moderadores esto puede no ser tolerado y resultar en una advertencia, un tiempo fuera, la eliminación del rol **@ike** o la expulsión del servidor."
"Si tienes problemas con pensamientos de esta naturaleza, pero no estás en peligro inmediato, considera buscar asesoramiento. Si estás pasando por una crisis inmediata, llama a los servicios de emergencia locales o busca un número de emergencia adecuado para ti. Aquí hay una lista de recursos por país: [](external.new:https://findahelpline.com/)"

View file

@ -0,0 +1,22 @@
localeName = "Viossa"
vilanticLangs-viossa = "Viossa"
vilanticLangs-wodox = "Wodossa"
navbar-whatIsViossa = "Ka Viossa?"
navbar-resources = "Lerakran"
navbar-kotoba = "Kotoba"
home-sections-whatIsViossa-title = "Ka Viossa?"
home-sections-whatIsViossa-body = "Viossa viskena-mahaossa mahajena na klaani, per mverm hur gvir viskossa mahajena. Viossa nai har rasmi, tont pashun bruk aparchigau tropos. Kakutro au hanutro deki chigaudai, au deki brukena per impla pashun. Viossa lerajena au opetajena na hel na hanu/kaku — dekinai kjannos per lera."
home-sections-historyOfViossa-title = "Danvimi fu Viossa"
home-sections-historyOfViossa-body = "Viossa hadjidan na Skype na 2014, mahajena na klaani fu r/conlangs na Reddit, grun tuvat per mverm hur viskossa mahajena. Viskossa plussimper fal fu glossa grun klaani uten kamagglossa na sama plas. Na leste viskossa jam na snano 2-3 ranyaossa, men Viossa mahajena grun mange chigau ranyaossa. Grun mangedjin gele gaja apudan per maha viko."
home-sections-community-title = "Klaani"
home-sections-community-body = "Klaani fu Viossa surudan mange au stranidai, mange rurret kara, na hel gaja, grun na zerjet. Opetaklupau maha uten kjannos os metahanu plussnano au hel uslovanai ke joku tro plusbra kena andr. Viossaklupau mange chigau likk glossa au hanudjin. Na mangedjin, tro awen tel fu sebja. Grun Viossa deki chigaudai au naijam mange tsatain imi znachi ke Viossa blogeta na ishu grunan, likk maha paem os liid."
home-images-viossaFlag-alt = "Flakka fu Viossa"
resources-title = "Lerakran"
resources-resources-discord-title = "Diskordserver"
resources-resources-discord-subtitle = "Alting Viossa tsuite slucha na her! Da zetulla jo!"
resources-resources-discord-desc = "Mahajena na 2016, server rupnejena na mange, na ima jam plus kena 6000 pashun long. Bitte da se ruuru au de bruk zedvera na una per zetulla!"
resources-resources-discord-buttons-join-label = "Zetulla"
resources-resources-discord-buttons-rules-label = "Ruuru"
resources-images-discordLogo-alt = "Riso fu Diskord"
kotoba-title = "Tropos-egal suha"
kotoba-searchHelp = "Li vil suha uten tro-egal, tastatsa joku ko os fras na una."

View file

@ -0,0 +1,23 @@
localeName = "wodox"
vilanticLangs-viossa = "viosox"
vilanticLangs-wodox = "wodox"
vilanticLangs-minemiaha = "minemiox"
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 2016. 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."

View file

@ -17,10 +17,12 @@
--bulma-info-l: 50%;
--bulma-info-s: 45%;
--bulma-family-primary: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
--bulma-family-secondary: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
--bulma-body-family: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
}
--bulma-family-primary: Nunito,Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;
--bulma-family-secondary: Nunito,Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;
--bulma-body-family: Nunito,Inter,SF Pro,Segoe UI,Roboto,Oxygen,Ubuntu,Helvetica Neue,Helvetica,Arial,sans-serif;
};
// Correct for fixed-top header when jumping to element by id in link (anchor)
html {
scroll-padding-top: var(--bulma-navbar-height);
}

View file

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

View file

@ -0,0 +1,115 @@
<script setup lang="ts" generic="Slot extends string">
import {
getCurrentInstance,
onMounted,
type DeepReadonly,
type VNode,
} from "vue";
import MarkdownParts from "./MarkdownParts.vue";
import OptionalParent from "./OptionalParent.vue";
import type { Markdown } from "@/vi18n-lib/markdown";
import type { CssClass } from "@/utils/css";
const props = defineProps<{
// 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<Slot>]: () => VNode[] }>();
function tryResolveComponentName(type: unknown): string | undefined {
if (!type || typeof type !== "object") return undefined;
const maybeType = type as { name?: string; __file?: string };
if (maybeType.name) return maybeType.name;
if (maybeType.__file) {
const parts = maybeType.__file.split(/[\\/]/);
const filename = parts.at(-1);
if (filename === undefined) {
return undefined;
}
const filenameParts = filename.split(".");
filenameParts.pop();
return filenameParts.join(".");
}
return undefined;
}
function resolveComponentName(type: unknown): string {
return tryResolveComponentName(type) ?? "(unresolvable)";
}
const getComponentStack = () => {
const instance = getCurrentInstance();
if (!instance) return "";
const names: string[] = [resolveComponentName(instance.type)];
let current = instance.parent;
while (current) {
names.push(resolveComponentName(current.type));
current = current.parent;
}
return names.length > 0 ? `\n\tComponent Stack: ${names.join(" > ")}` : "";
};
// Validate required slots at runtime
onMounted(() => {
const requiredSlots = props.markdown.slots;
const missingSlots = [...requiredSlots].filter(
(slot) => providedSlots[slot] === undefined,
);
if (missingSlots.length > 0) {
const componentStack = getComponentStack();
throw new Error(
`Markdown component is missing slots!\n\tMissing Slots: ${missingSlots.join(", ")}${componentStack}`,
);
}
});
</script>
<template>
<OptionalParent :is="tag">
<template v-for="(line, index) in markdown.elements" :key="index">
<p v-if="line.type === 'paragraph'" :class="lineClass">
<MarkdownParts
:elements="line.paragraph.spans"
: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.header.spans"
: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'" :class="lineClass">
<li v-for="item in line.ulist.items">
<MarkdownParts :elements="item" :slots="markdown.slots">
<template
v-for="(slot, name) in providedSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</MarkdownParts>
</li>
</ul>
</template>
</OptionalParent>
</template>

View file

@ -0,0 +1,67 @@
<script setup lang="ts" generic="Slot extends string">
import { type DeepReadonly, type VNode } from "vue";
import SmartLink from "../atoms/SmartLink.vue";
import type { MarkdownSpan } from "@/vi18n-lib/markdown";
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
elements: DeepReadonly<MarkdownSpan<Slot>[]>;
slots: DeepReadonly<ReadonlySet<Slot>>;
}>();
const vueSlots = defineSlots<{ [K in Slot]: () => VNode[] }>();
</script>
<template>
<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">
<template v-if="name === part.slot">
<component :is="slot" />
</template>
</template>
</template>
<template v-else-if="part.type === 'bold'">
<b>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<MarkdownParts :elements="part.bold" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</MarkdownParts>
</b>
</template>
<template v-else-if="part.type === 'italic'">
<i>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<MarkdownParts :elements="part.italic" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</MarkdownParts>
</i>
</template>
<template v-else-if="part.type === 'link'">
<SmartLink :to="part.link.to" :new-tab="part.link.newTab">
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
<MarkdownParts :elements="part.link.label" :slots="slots">
<template
v-for="(slot, name) in vueSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</MarkdownParts>
</SmartLink>
</template>
</template>
</template>

View file

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

View file

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

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { CssClass } from "@/utils/css";
import { computed, ref } from "vue";
import type { SmartLinkProps } from "./SmartLink";
const props = defineProps<SmartLinkProps>();
type To = { type: "a"; a: string } | { type: "routerLink"; routerLink: string };
const to = ((): To => {
const { to, newTab } = props;
switch (to.type) {
case "external": {
return { type: "a", a: to.external };
}
case "internal": {
const { route, id } = to.internal;
const endpoint = `${route ?? ""}${id === undefined ? "" : `#${id}`}`;
if (newTab || id !== undefined) {
return { type: "a", a: endpoint };
}
// <RouterLink> can only be used for route-only endpoints
// that require no other special functionality of <a>
return { type: "routerLink", routerLink: endpoint };
}
}
})();
const isHovered = ref(false);
const classes = computed<CssClass>(() => [
props.covert && !isHovered.value && "has-text-text",
]);
</script>
<template>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we need to at least use <a> once to create this wrapper type -->
<a
v-if="to.type === 'a'"
:href="to.a"
:target="newTab ? '_blank' : undefined"
rel="noopener noreferrer nofollow"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
:class="classes">
<slot />
</a>
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we need to at least use <RouterLink> once to create this wrapper type -->
<RouterLink v-else :to="to.routerLink" :class="classes">
<span @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<slot />
</span>
</RouterLink>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import SmartLink from "../atoms/SmartLink.vue";
import type { Value } from "@/utils/types";
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
import type { Locale } from "@/i18n";
import type { DeepReadonly } from "vue";
import { isEmptyMarkdown } from "@/vi18n-lib/markdown";
const props = defineProps<{
overview: DeepReadonly<
Value<Locale["discord"]["rulesPage"]["rules"]>["overview"]
>;
ruleNumber: number;
}>();
const subtext = props.overview.subtext();
</script>
<template>
<span>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: `rule-${ruleNumber}` } }"
:style="{ width: 'fit-content', display: 'inline-block' }">
<li :style="{ width: 'fit-content' }">
<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' }"
:markdown="subtext" />
</ul>
</li>
</SmartLink>
</span>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { Locale } from "@/i18n";
import type { Value } from "@/utils/types";
import type { DeepReadonly } from "vue";
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
defineProps<{
section: DeepReadonly<
Value<Locale["discord"]["rulesPage"]["rules"]>["section"]
>;
ruleNumber: number;
}>();
</script>
<template>
<section class="section content" :id="`rule-${ruleNumber}`">
<h2>{{ section.header({ ruleNumber }) }}</h2>
<MarkdownDisplay :markdown="section.body()" />
</section>
</template>

View file

@ -1,8 +1,48 @@
<script setup lang="ts">
import type { SmartLinkProps } from "../atoms/SmartLink";
import type { CssClass } from "@/utils/css";
import SmartLink from "../atoms/SmartLink.vue";
export interface ResourceButton {
label: string;
link: SmartLinkProps;
style: ResourceButtonStyle;
}
export interface ResourceButtonStyle {
color: "primary" | "warning";
outlined?: boolean;
}
function buttonStyleToClasses(style: ResourceButtonStyle): CssClass[] {
const colorClass = (() => {
switch (style.color) {
case "primary": {
return "is-primary";
}
case "warning": {
return "is-warning";
}
}
})();
return [colorClass, style.outlined && "is-outlined"];
}
defineProps<{
title: string;
subtitle: string;
desc: string;
image?: { src: string; alt: string };
buttons: ResourceButton[];
}>();
</script>
<template>
<div class="box columns is-vcentered is-gap-4">
<div class="column is-one-quarter" v-if="image">
<figure class="image">
<img :src="image" :alt="alt" :title="alt" />
<img :src="image.src" :alt="image.alt" :title="image.alt" />
</figure>
</div>
<div class="column">
@ -11,43 +51,18 @@
<p class="content">{{ desc }}</p>
<div class="level">
<a :href="link"
target="_blank"
rel="noopener noreferrer nofollow"
class="button is-primary is-medium"
:text="joinText">
</a>
<a :href="``"
target="_blank"
rel="noopener noreferrer nofollow"
class="button is-info is-outlined is-medium"
:text="rulesText">
</a>
<a :href="rulesLink"
target="_blank"
rel="noopener noreferrer nofollow"
class="button is-warning is-outlined is-medium"
:text="rulesText">
</a>
<SmartLink
v-for="(button, index) in buttons"
:key="index"
v-bind="button.link"
:class="[
'button',
'is-medium',
buttonStyleToClasses(button.style),
]"
>{{ button.label }}</SmartLink
>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
title: String,
subtitle: String,
desc: String,
link: String,
rulesLink: String,
joinText: String,
rulesText: String,
image: { type: String, required: false },
alt: { type: String, required: false },
})
</script>

View file

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

View file

@ -1,46 +0,0 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { useLocale } from "@/i18n";
import { GREETINGS, type Greeting } from "@/i18n/greeting";
import { VILANTIC_ID_TO_FLAG } from "@/i18n/vilantic";
import { localizeLayout } from "@/utils/localizeLayout";
import { randomElement } from "@/utils/random";
const locale = useLocale();
const greeting: Greeting = randomElement(GREETINGS);
</script>
<template>
<div>
<section class="hero has-background-primary-soft is-primary">
<div
class="hero-body"
style="padding-top: 3.75rem; padding-bottom: 3rem">
<div class="title has-text-text-bold">{{ greeting.title }}</div>
<div class="subtitle has-text-text-bold mb-4">
{{ greeting.subtitle }}
</div>
<div
class="subtitle is-size-6 is-flex is-flex-direction-row is-align-items-center is-gap-1 has-text-text-bold">
&mdash; {{ greeting.author }} ({{
locale.vilanticLangs[greeting.lang]
}})
<figure class="image is-32x32">
<img :src="VILANTIC_ID_TO_FLAG[greeting.lang]" />
</figure>
</div>
</div>
</section>
<section class="section container">
<HomeSectionWrapper
v-for="(section, index) in localizeLayout(locale.home.layout)"
:key="index"
:title="section.title"
:text="section.text"
:image="section.image ?? undefined"
:alt="section.alt ?? undefined"
:reverse="index % 2 !== 0" />
</section>
</div>
</template>

View file

@ -1,32 +0,0 @@
<script setup lang="ts">
import LearningResourceWrapper from "@/components/molecules/LearningResourceWrapper.vue";
import { useLocale } from "@/i18n";
import { localizeLayout } from "@/utils/localizeLayout";
const locale = useLocale();
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.resources.title }}</h1>
</section>
<section class="section container">
<LearningResourceWrapper
v-for="(resource, index) in localizeLayout(
locale.resources.layout,
)"
:key="index"
:title="resource.title"
:subtitle="resource.subtitle"
:desc="resource.desc"
:link="resource.link"
:rulesLink="resource.rulesLink"
:image="resource.image"
:alt="resource.alt"
:joinText="resource.joinText"
:rulesText="resource.rulesText" />
</section>
</div>
</template>

View file

@ -0,0 +1,97 @@
import {
string,
markdown,
record,
type InferLocaleFromConfig,
type LocaleConfig,
type ConfigString,
} from "@/vi18n-lib/config";
function plainString(): ConfigString<object> {
return string({ placeables: {} });
}
const homeSectionConfig = { title: plainString(), body: plainString() };
const imageConfig = { alt: plainString() };
export interface Image extends InferLocaleFromConfig<typeof imageConfig> {}
const buttonConfig = { label: plainString() };
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
title: plainString(),
subtitle: plainString(),
desc: plainString(),
buttons: record(buttonKeys, () => buttonConfig),
});
const discordRuleConfig = {
overview: {
text: markdown({
placeables: {},
slots: {},
bold: true,
italic: true,
link: true,
}),
subtext: markdown({
placeables: {},
slots: {},
bold: true,
italic: true,
link: true,
}),
},
section: {
header: string({ placeables: { ruleNumber: { type: "number" } } }),
body: markdown({
placeables: {},
slots: {},
bold: true,
header: true,
italic: true,
link: true,
}),
},
};
export const localeConfig = {
localeName: plainString(),
vilanticLangs: record(["viossa", "wodox", "minemiaha"], () =>
plainString(),
),
navbar: record(["whatIsViossa", "resources", "kotoba"], () =>
plainString(),
),
home: {
sections: record(
["whatIsViossa", "historyOfViossa", "community"],
() => homeSectionConfig,
),
images: record(["viossaFlag"], () => imageConfig),
},
resources: {
title: plainString(),
resources: { discord: resourceConfig(["join", "rules"]) },
images: record(["discordLogo"], () => imageConfig),
},
kotoba: { title: plainString(), searchHelp: plainString() },
discord: {
rulesPage: {
title: plainString(),
overview: { title: plainString(), help: plainString() },
rules: record(
[
"noTranslation",
"lfsv",
"viossaOnlyChats",
"sfw",
"respectOthers",
"respectStaff",
"controversialTopics",
],
() => discordRuleConfig,
),
},
},
} as const satisfies LocaleConfig;

View file

@ -1,22 +1,26 @@
import en_US from "../locales/en_US";
import vp_VL from "../locales/vp_VL";
import wp_VL from "../locales/wp_VL";
import { computed, readonly, type DeepReadonly } from "vue";
import type { Locale } from "./locale";
import type { DeepPartial } from "@/utils/deep-partial";
import { type InferLocaleFromConfig } from "@/vi18n-lib/config";
import {
bundleToUncompiledLocaleRecord,
loadFluentBundle,
} from "@/vi18n-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";
import { compileLocale } from "@/vi18n-lib/compile";
import type { ComputedRef } from "vue";
export const LOCALE_IDS = ["en_US", "vp_VL", "wp_VL"] as const;
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
export type LocaleId = typeof LocaleId.infer;
export const LocaleId = type.enumerated(...LOCALE_IDS);
export const DEFAULT_LOCALE_ID = "en_US" satisfies LocaleId;
const locales = { en_US, vp_VL, wp_VL } as const satisfies {
en_US: Locale;
} & Record<Exclude<LocaleId, typeof DEFAULT_LOCALE_ID>, DeepPartial<Locale>>;
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>(
@ -44,23 +48,95 @@ export const localeId = computed({
},
});
export const useLocale = (opt: UseLocaleOptions = {}) => {
const locale = computed<DeepReadonly<Locale>>(() => {
return fallbackProxy<Locale>(
locales[opt.locale ?? localeId.value],
locales["en_US"],
);
});
export interface Locale extends InferLocaleFromConfig<typeof localeConfig> {}
return readonly(locale);
};
async function loadLocale(
localeId: LocaleId,
localeFtlSrc: string,
): Promise<Result<FluentBundle, string>> {
const bundleRes = await loadFluentBundle(localeId, localeFtlSrc);
if (bundleRes.type === "err") {
return bundleRes;
}
export interface UseLocaleOptions {
locale?: LocaleId;
const bundle = bundleRes.ok;
return { type: "ok", ok: bundle };
}
function isObject(value: unknown) {
return typeof value === "object" && value !== null;
interface SetupLocaleFallback {
bundle: FluentBundle;
locale: Locale;
}
function setupLocale(
localeId: LocaleId,
localeBundle: FluentBundle,
fallback: SetupLocaleFallback | undefined,
): Result<Locale, string> {
const fallbackBundle = fallback?.bundle;
const fallbackLocale = fallback?.locale;
const maybeFallbackedBundle = (() => {
if (fallbackBundle === undefined) {
return localeBundle;
}
const localeMessageIds = new Set(localeBundle._messages.keys());
const fallbackMessageIds = new Set(fallbackBundle._messages.keys());
const missingMessageIds =
fallbackMessageIds.difference(localeMessageIds);
for (const id of missingMessageIds) {
const fallbackMessage = fallbackBundle._messages.get(id);
if (fallbackMessage) {
localeBundle._messages.set(id, fallbackMessage);
}
}
return localeBundle;
})();
const uncompiledLocaleRecordRes = bundleToUncompiledLocaleRecord(
maybeFallbackedBundle,
);
if (uncompiledLocaleRecordRes.type === "err") {
return uncompiledLocaleRecordRes;
}
const uncompiledLocaleRecord = uncompiledLocaleRecordRes.ok;
const localeRes = compileLocale({
config: localeConfig,
bundle: maybeFallbackedBundle,
uncompiled: uncompiledLocaleRecord,
fallback: fallbackLocale,
messageIdChain: [],
});
const compilationErrorCount = localeRes.errors.length;
if (compilationErrorCount === 0) {
console.log(`[vi18n] Set up locale \`${localeId}\` with no errors!`);
} else {
console.error(
`[vi18n] Set up locale \`${localeId}\` with ${compilationErrorCount} following errors:`,
);
console.error(localeRes.errors);
}
const locale = localeRes.locale;
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> {
@ -68,59 +144,71 @@ function deepReadonly<T>(value: T): DeepReadonly<T> {
return value as DeepReadonly<T>;
}
function fallbackProxy<T extends object>(
mask: DeepPartial<T>,
fallback: T,
): DeepReadonly<T> {
const proxy = new Proxy(fallback, {
get: (_target, key): DeepReadonly<T[keyof T]> => {
// SAFETY: typescript should ensure we're only ever trying to access keys
// that exist on T, and if the key doesn't,
// just process its fallback as if it did,
// everything should work as expected still
const tKey = key as keyof T;
interface I18n {
useLocale: (opt?: UseLocaleOptions) => ComputedRef<DeepReadonly<Locale>>;
}
// value may not exist on mask
const value: DeepPartial<T>[keyof T] | undefined = mask[tKey];
// all values exist on fallback
const fallbackValue: T[keyof T] = fallback[tKey];
// this is *not* T[keyof T], because if value is an object,
// it may still have missing properties.
// this only handles the case where the current value is undefined, not nested ones.
// thus, `finalValue` is still a `DeepPartial<T[keyof T]>` (but not undefined)
const finalValue: DeepPartial<T>[keyof T] =
value === undefined ? fallbackValue : value;
// check if finalValue 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 `DeepPartial<T>[keyof T]` is the same as `T[keyof T]`
return deepReadonly(finalValue as T[keyof T]);
}
// 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<T[keyof T] & object>(
finalValue,
fallbackValue,
async function initI18n(): Promise<I18n> {
const defaultLocaleBundle = unwrap(await loadLocale("en-US", enUsFtlSrc));
const defaultLocale = unwrap(
setupLocale(DEFAULT_LOCALE_ID, defaultLocaleBundle, undefined),
);
},
set: () => {
throw new Error("Cannot mutate locale at runtime");
},
const doItAllForLocale = async (
localeId: LocaleId,
localeFtlSrc: string,
): Promise<DeepReadonly<Locale>> =>
deepReadonly(
unwrap(
setupLocale(
localeId,
unwrap(await loadLocale(localeId, localeFtlSrc)),
{ bundle: defaultLocaleBundle, locale: defaultLocale },
),
),
);
const [vpVl, wpVl] = await Promise.all([
doItAllForLocale("vp-VL", vpVlFtlSrc),
doItAllForLocale("wp-VL", wpVlFtlSrc),
]);
const localeIdToLocale = {
"en-US": deepReadonly(defaultLocale),
"vp-VL": vpVl,
"wp-VL": wpVl,
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
const useLocale = (opt: UseLocaleOptions = {}) =>
computed<DeepReadonly<Locale>>(() => {
const localLocaleId = opt.locale ?? localeId.value;
return localeIdToLocale[localLocaleId];
});
// we're just disallowing mutations to the proxy, since its setter panics if used at runtime
return deepReadonly(proxy);
return { useLocale };
}
export interface UseLocaleOptions {
locale?: LocaleId;
}
const initI18nPromise = initI18n();
let i18n: I18n | null = null;
initI18nPromise.then((x) => {
i18n = x;
});
export const onI18nInit = (f: () => void) => {
initI18nPromise.then(f);
};
export const useLocale = (
opt: UseLocaleOptions = {},
): ComputedRef<DeepReadonly<Locale>> => {
if (i18n === null) {
throw new Error("Cannot use i18n before initialized!");
}
return i18n.useLocale(opt);
};

View file

@ -1,66 +0,0 @@
import type { VilanticId } from "./vilantic";
export interface Locale {
localeName: string;
vilanticLangs: VilanticLangs;
navbar: Navbar;
home: HomePage;
resources: ResourcesPage;
kotoba: KotobaPage;
}
export interface Layout<T> {
order: (keyof T)[];
data: { [K in keyof T]: T[K] };
}
export type VilanticLangs = Record<VilanticId, string>;
export interface Navbar {
whatIsViossa: string;
resources: string;
kotoba: string;
}
export interface HomePage {
layout: Layout<HomeSections>;
}
export interface HomeSections {
whatIsViossa: HomeSection;
historyOfViossa: HomeSection;
community: HomeSection;
}
export interface HomeSection {
title: string;
text: string;
image: string | null;
alt: string | null;
}
export interface ResourcesPage {
title: string;
layout: Layout<Resources>;
}
export interface Resources {
discord: Resource;
}
export interface Resource {
title: string;
subtitle: string;
desc: string;
link: string;
rulesLink: string;
image: string;
alt: string;
joinText: string;
rulesText: string;
}
export interface KotobaPage {
title: string;
searchHelp: string;
}

View file

@ -1,62 +0,0 @@
import type { Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png";
export default {
localeName: "English",
vilanticLangs: { viossa: "Viossa", wodox: "Wodoch", minemiaha: "Minemiaha"},
navbar: {
whatIsViossa: "What is Viossa?",
resources: "Resources",
kotoba: "Kotoba",
},
home: {
layout: {
order: ["whatIsViossa", "historyOfViossa", "community"],
data: {
whatIsViossa: {
title: "What is Viossa?",
text: "Viossa is a community-created artificial pidgin language, created to simulate the formation of natural pidgin languages. Viossa is characterized by its lack of standardization, with each speaker developing a personal idiolect. Spelling and pronunciation can vary greatly, and serve as a form of personal self-expression. Viossa is learnt and taught entirely by immersion — translation is prohibited while learning.",
image: flakkaImg,
alt: "Flag of the Viossa Language",
},
historyOfViossa: {
title: "History of Viossa",
text: "Viossa began as a Skype group in 2014, created by members of the r/conlangs community on Reddit, as an experiment to simulate the formation of a pidgin language. Pidgins are simplified languages resulting from contact between populations with no shared common language. Unlike most pidgins, which usually have two to three contributor languages, Viossa comes from many diverse languages. This is because people from all around the world helped to contribute to Viossa's vocabulary.",
image: flakkaImg,
alt: "Flag of the Viossa Language",
},
community: {
title: "Community",
text: "The Viossa community is rich and colourful, drawing from many global traditions due to its worldwide online membership. Since the teaching culture puts an emphasis on linguistic immersion, and discourages prescriptivism, the culture of Viossa is as diverse and varied as the language and the people who speak it. For many, their personal dialect is a key form of identity and expression. The fluid nature of Viossa and lack of defined meanings makes Viossa popular for creative purposes, such as poetry and songwriting.",
image: null,
alt: null,
},
},
},
},
resources: {
title: "Learning Resources",
layout: {
order: ["discord"],
data: {
discord: {
title: "Discord Server",
subtitle:
"This is where most of the action happens! Hop on in!",
desc: "Viossa Diskordserver (VDS) was founded in 2016, as the successor to the original Viossa chat on Skype, since then it has grown to have over 6,000 members. Via the buttons, please read the rules and then join the server!",
link: "https://discord.gg/g3mG2gYjZD",
rulesLink: "https://viossadiskordserver.github.io/rules",
image: discordImg,
alt: "Discord logo",
joinText: "Join",
rulesText: "Rules",
},
},
},
},
kotoba: {
title: "Tropos-agnostic search",
searchHelp: "To searcn tropos-agnostically, enter a term below.",
},
} as const satisfies Locale;

View file

@ -1,63 +0,0 @@
import type { Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import type { DeepPartial } from "@/utils/deep-partial";
import discordImg from "@/assets/discord.png";
export default {
localeName: "Viossa",
vilanticLangs: { viossa: "Viossa", wodox: "Wodossa" },
navbar: {
whatIsViossa: "Ka Viossa?",
resources: "Lerakran",
kotoba: "Kotoba",
},
home: {
layout: {
order: ["whatIsViossa", "historyOfViossa", "community"],
data: {
whatIsViossa: {
title: "Ka Viossa?",
text: "Viossa viskena-mahaossa mahajena na klaani, per mverm hur gvir viskossa mahajena. Viossa nai har rasmi, tont pashun bruk aparchigau tropos. Kakutro au hanutro deki chigaudai, au deki brukena per impla pashun. Viossa lerajena au opetajena na hel na hanu/kaku — dekinai kjannos per lera.",
image: flakkaImg,
alt: "Flakka fu Viossa",
},
historyOfViossa: {
title: "Danvimi fu Viossa",
text: "Viossa hadjidan na Skype na 2014, mahajena na klaani fu r/conlangs na Reddit, grun tuvat per mverm hur viskossa mahajena. Viskossa plussimper fal fu glossa grun klaani uten kamagglossa na sama plas. Na leste viskossa jam na snano 2-3 ranyaossa, men Viossa mahajena grun mange chigau ranyaossa. Grun mangedjin gele gaja apudan per maha viko.",
image: flakkaImg,
alt: "Flakka fu Viossa",
},
community: {
title: "Klaani",
text: "Klaani fu Viossa surudan mange au stranidai, mange rurret kara, na hel gaja, grun na zerjet. Opetaklupau maha uten kjannos os metahanu plussnano au hel uslovanai ke joku tro plusbra kena andr. Viossaklupau mange chigau likk glossa au hanudjin. Na mangedjin, tro awen tel fu sebja. Grun Viossa deki chigaudai au naijam mange tsatain imi znachi ke Viossa blogeta na ishu grunan, likk maha paem os liid.",
image: null,
alt: null,
},
},
},
},
resources: {
title: "Lerakran",
layout: {
order: ["discord"],
data: {
discord: {
title: "Diskordserver",
subtitle:
"Alting Viossa tsuite slucha na her! Da zetulla jo!",
desc: "Mahajena na 2016, server rupnejena na mange, na ima jam plus kena 6000 pashun long. Bitte da se ruuru au de bruk zedvera na una per zetulla!",
link: "https://discord.gg/g3mG2gYjZD",
rulesLink: "https://viossadiskordserver.github.io/rules",
image: discordImg,
alt: "Riso fu Diskord",
joinText: "Zetulla",
rulesText: "Ruuru",
},
},
},
},
kotoba: {
title: "Tropos-egal suha",
searchHelp: "Li vil suha uten tro-egal, tastatsa joku ko os fras na una.",
},
} as const satisfies DeepPartial<Locale>;

View file

@ -1,63 +0,0 @@
import type { Locale } from "@/i18n/locale";
import flakkaImg from "@/assets/flakka.png";
import discordImg from "@/assets/discord.png";
import type { DeepPartial } from "@/utils/deep-partial";
export default {
localeName: "wodox",
vilanticLangs: { viossa: "viosox", wodox: "wodox" },
navbar: {
whatIsViossa: "viosox e ano?",
resources: "tropos",
kotoba: "mot o viosox",
},
home: {
layout: {
order: ["whatIsViossa", "historyOfViossa", "community"],
data: {
whatIsViossa: {
title: "viosox e ano?",
text: "viosox e hez ox pamzal, zoz stende zalkun tuo mit multa nengwi ox. zal o viosox stende lik zal o hez il ox keta, zalilkun wi tuo mit multa nengwi ox. mono i fal o viosox stendenai; omni axsi o viosox zal nengokun fal o viosox, de falmot wi falax o il stende e keko trenengwi tua o nengwi stende, ge fala e keko lik ro o tuo viosoxsi. genil viosox ibe il wi nengwi stende axkun ge pisakun po tuo ox — stende gen muskunnai mit zaiox.",
image: flakkaImg,
alt: "fomma o viosox",
},
historyOfViossa: {
title: "zal o viosox",
text: "wi o zal o viosox stende po multa o Skype wi 2014 ibe stendera o multa r/conlangs o Reddit. zalsi o viosox danzalgo hez, tuo zal o viosox e lik zal o hez il ox keta, zalil tuo ox mit nengwi multa ox ibe zalsi fiemnaikun sama i ox. viosox e nengwi tuo ox pamzal keta; zalilkun keko ox keta mit lik du wi tre ox, aga zalil viosox mit multa wi plus obo o ox na il ox keta ibe zalsi o viosox stende po multa mi o mo.",
image: flakkaImg,
alt: "fomma o viosox",
},
community: {
title: "viosoxsi",
text: "nengwi multa ro o viosoxsi stende ibe stendenura po nengwi multa mi o mo ge wekakunnura zai nengwi viosoxsi po jilobo. ibe mono i fal o viosox stendenai ge ibe viosoxsi zalkun nengwi multa fal o viosox, de nengwi zoz ko o ro lik ro o viosoxsi stende po ro o viosa. po multa hez viosoxsi, falmot wi falax o tuo stende stende po ro o tuo stende. ibe mono i fal o viosox stendenai ge ibe ro o mot inkun nengwi po nengwi viosoxsi, de multa stende amanata hez, zal zalgonukun surat au mola au sucik.",
image: null,
alt: null,
},
},
},
},
resources: {
title: "tropos o gen",
layout: {
order: ["discord"],
data: {
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!",
link: "https://discord.gg/g3mG2gYjZD",
rulesLink: "https://viossadiskordserver.github.io/rules",
image: discordImg,
alt: "surat o Diskord",
joinText: "wekatutsa",
rulesText: "musra",
},
},
},
},
kotoba: {
title: "zalkuketutsa mot o viosox mit il o omni falmot",
searchHelp:
"ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun.",
},
} as const satisfies DeepPartial<Locale>;

View file

@ -1,5 +1,8 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./routes";
import router from "./router";
import { onI18nInit } from "./i18n";
createApp(App).use(router).mount("#app");
onI18nInit(() => {
createApp(App).use(router).mount("#app");
});

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
import { useLocale } from "@/i18n";
import { computed } from "vue";
const locale = useLocale();
const pageI18n = computed(() => locale.value.discord.rulesPage);
const rulesI18n = computed(() => pageI18n.value.rules);
const RULE_ORDER = [
"noTranslation",
"lfsv",
"viossaOnlyChats",
"sfw",
"respectOthers",
"respectStaff",
"controversialTopics",
] as const satisfies (keyof typeof rulesI18n.value)[];
</script>
<template>
<div>
<section class="section">
<h1 class="title">
{{ pageI18n.title() }}
</h1>
</section>
<section class="section content">
<h2>{{ pageI18n.overview.title() }}</h2>
<blockquote>
{{ pageI18n.overview.help() }}
</blockquote>
<ol :style="{ display: 'flex', flexDirection: 'column' }">
<DiscordRuleOverview
v-for="(id, index) in RULE_ORDER"
:key="index"
:rule-number="index + 1"
:overview="rulesI18n[id].overview" />
</ol>
</section>
<DiscordRuleSection
v-for="(id, index) in RULE_ORDER"
:key="index"
:section="rulesI18n[id].section"
:rule-number="index + 1" />
</div>
</template>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { GREETINGS, type Greeting } from "@/i18n/greeting";
import { VILANTIC_ID_TO_FLAG } from "@/i18n/vilantic";
import { randomElement } from "@/utils/random";
import { computed } from "vue";
import flakkaImg from "@/assets/flakka.png";
import { useLocale, type Locale } from "@/i18n";
import type * as i18n from "@/i18n/config";
interface SectionConfig {
id: keyof Locale["home"]["sections"];
image?: keyof typeof imagesI18n.value;
}
const SECTION_CONFIGS = [
{ id: "whatIsViossa", image: "viossaFlag" },
{ id: "historyOfViossa", image: "viossaFlag" },
{ id: "community" },
] as const satisfies SectionConfig[];
const locale = useLocale();
const homeI18n = computed(() => locale.value.home);
interface ImageI18n {
src: string;
metadata: i18n.Image;
}
const imagesI18n = computed(() => {
const imagesI18n = homeI18n.value.images;
return {
viossaFlag: { src: flakkaImg, metadata: imagesI18n.viossaFlag },
} as const satisfies Record<string, ImageI18n>;
});
const greeting: Greeting = randomElement(GREETINGS);
const sectionsI18n = computed(() =>
SECTION_CONFIGS.map(({ id, image }: SectionConfig) => ({
text: homeI18n.value.sections[id],
image: image && imagesI18n.value[image],
})),
);
</script>
<template>
<div>
<section class="hero has-background-primary-soft is-primary">
<div
class="hero-body"
style="padding-top: 3.75rem; padding-bottom: 3rem">
<div class="title has-text-text-bold">{{ greeting.title }}</div>
<div class="subtitle has-text-text-bold mb-4">
{{ greeting.subtitle }}
</div>
<div
class="subtitle is-size-6 is-flex is-flex-direction-row is-align-items-center is-gap-1 has-text-text-bold">
&mdash; {{ greeting.author }} ({{
locale.vilanticLangs[greeting.lang]()
}})
<figure class="image is-32x32">
<img :src="VILANTIC_ID_TO_FLAG[greeting.lang]" />
</figure>
</div>
</div>
</section>
<section class="section container">
<HomeSectionWrapper
v-for="(sectioni18n, index) in sectionsI18n"
:key="index"
:title="sectioni18n.text.title()"
:text="sectioni18n.text.body()"
:image="sectioni18n.image?.src"
:alt="sectioni18n.image?.metadata.alt()"
:reverse="index % 2 !== 0" />
</section>
</div>
</template>

View file

@ -1,30 +1,25 @@
<script setup lang="ts">
import { useLocale } from '@/i18n';
import { useLocale } from "@/i18n";
const locale = useLocale();
</script>
<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">
<input class="input" type="text"></input>
</div>
<div class="container">
<input class="input" type="text" />
</div>
<div class="container"></div>
</section>
</div>
</template>

View file

@ -0,0 +1,101 @@
<script setup lang="ts">
import LearningResourceWrapper, {
type ResourceButton,
} from "@/components/molecules/LearningResourceWrapper.vue";
import { useLocale, type Locale } from "@/i18n";
import type * as i18n from "@/i18n/config";
import { ignore } from "@/utils/ignore";
import { computed } from "vue";
import discordImg from "@/assets/discord.png";
interface ResourceConfig {
id: keyof Locale["resources"]["resources"];
image?: keyof typeof imagesI18n.value;
}
const RESOURCE_CONFIGS = [
{ id: "discord", image: "discordLogo" },
] as const satisfies ResourceConfig[];
const locale = useLocale();
const pageI18n = computed(() => locale.value.resources);
interface ImageI18n {
src: string;
metadata: i18n.Image;
}
const imagesI18n = computed(() => {
const imagesI18n = pageI18n.value.images;
return {
discordLogo: { src: discordImg, metadata: imagesI18n.discordLogo },
} as const satisfies Record<string, ImageI18n>;
});
const resourcesI18n = computed(() =>
RESOURCE_CONFIGS.map(
({ id, image }: ResourceConfig) =>
({
id,
text: pageI18n.value.resources[id],
image: image && imagesI18n.value[image],
}) as const,
),
);
const computeButtons = (
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
ignore<"discord">(id);
const buttons = pageI18n.value.resources[id].buttons;
return [
{
link: {
to: {
type: "external",
external: "https://discord.viossa.net",
},
newTab: true,
},
label: buttons.join.label(),
style: { color: "primary" },
},
{
link: {
to: { type: "internal", internal: { route: "/discord/rules" } },
},
label: buttons.rules.label(),
style: { color: "warning", outlined: true },
},
];
};
</script>
<template>
<div>
<section class="section">
<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()"
:image="
resourceI18n.image && {
src: resourceI18n.image.src,
alt: resourceI18n.image.metadata.alt(),
}
"
:buttons="computeButtons(resourceI18n.id)" />
</section>
</div>
</template>

View file

@ -0,0 +1,10 @@
import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";
const router = createRouter({ history: createWebHistory(), routes });
if (import.meta.hot) {
handleHotUpdate(router);
}
export default router;

View file

@ -1,21 +0,0 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import HomePage from "@/components/pages/HomePage.vue";
import ResourcesPage from "@/components/pages/ResourcesPage.vue";
import KotobaPage from "@/components/pages/KotobaPage.vue";
const routes: RouteRecordRaw[] = [
{ path: "/", name: "Home", component: HomePage },
{ path: "/resources", name: "Resources", component: ResourcesPage },
{ path: "/kotoba", name: "Kotoba", component: KotobaPage },
// {
// path: '/:pathMatch(.*)*', // Vue Router 4 catch-all for 404s
// name: 'NotFound',
// component: NotFoundPage,
// },
];
const router = createRouter({ history: createWebHistory(), routes });
export default router;

View file

@ -1,10 +1,15 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// noinspection ES6UnusedImports
// Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-resolver' {
export type ParamParserCustom = never
}
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
@ -18,5 +23,81 @@ declare module 'vue-router/auto-routes' {
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<
'/',
'/',
Record<never, never>,
Record<never, never>,
| never
>,
'/discord/rules': RouteRecordInfo<
'/discord/rules',
'/discord/rules',
Record<never, never>,
Record<never, never>,
| never
>,
'/kotoba': RouteRecordInfo<
'/kotoba',
'/kotoba',
Record<never, never>,
Record<never, never>,
| never
>,
'/resources': RouteRecordInfo<
'/resources',
'/resources',
Record<never, never>,
Record<never, never>,
| never
>,
}
/**
* Route file to route info map by unplugin-vue-router.
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
*
* Each key is a file path relative to the project root with 2 properties:
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
* - views: names of nested views (can be passed to <RouterView name="...">)
*
* @internal
*/
export interface _RouteFileInfoMap {
'pages/index.vue': {
routes:
| '/'
views:
| never
}
'pages/discord/rules.vue': {
routes:
| '/discord/rules'
views:
| never
}
'pages/kotoba.vue': {
routes:
| '/kotoba'
views:
| never
}
'pages/resources.vue': {
routes:
| '/resources'
views:
| never
}
}
/**
* Get a union of possible route names in a certain route component file.
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
*
* @internal
*/
export type _RouteNamesForFilePath<FilePath extends string> =
_RouteFileInfoMap extends Record<FilePath, infer Info>
? Info['routes']
: keyof RouteNamedMap
}

View file

@ -0,0 +1 @@
export type CssClass = string | false | null | undefined | CssClass[];

View file

@ -1,3 +0,0 @@
export type DeepPartial<T extends object> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

View file

@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unused-vars
export function ignore<T>(_: T): void {}

View file

@ -1,16 +0,0 @@
import type { Layout } from "@/i18n/locale";
import type { DeepReadonly } from "vue";
export function localizeLayout<T>(
layout: DeepReadonly<Layout<T>>,
): T[keyof T][] {
const sections: T[keyof T][] = [];
for (const sectionId of layout.order ?? []) {
const section = (layout.data as T)[sectionId as keyof T];
if (section) {
sections.push(section);
}
}
return sections;
}

View file

@ -0,0 +1,11 @@
import type { RouteNamedMap } from "vue-router/auto-routes";
export type SmartDest =
| { type: "internal"; internal: SmartInternalDest }
| { type: "external"; external: SmartExternalDest };
export type SmartInternalDest =
| { route: keyof RouteNamedMap; id?: string }
| { route?: keyof RouteNamedMap; id: string };
export type SmartExternalDest = `https://${string}` | `http://${string}`;

View file

@ -0,0 +1,8 @@
export type DeepPartial<T extends object> =
T extends Function ? T
: { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
export type Prettify<T> = T extends object ? { [K in keyof T]: T[K] } & {} : T;
export type Value<T> = T[keyof T];
export type Result<T, E> = { type: "ok"; ok: T } | { type: "err"; err: E };

View file

@ -0,0 +1,11 @@
import type { Result } from "./types";
export async function unsafeAsync<R>(
f: () => Promise<R>,
): Promise<Result<R, unknown>> {
try {
return { type: "ok", ok: await f() };
} catch (e) {
return { type: "err", err: e };
}
}

View file

@ -0,0 +1,636 @@
import type { Result, Value } from "@/utils/types";
import {
computeAllVariants,
selectionChainToString,
type PatternVariant,
type UncompiledLocale,
} from "./setup";
import type { FluentBundle, FluentVariable, Message } from "@fluent/bundle";
import { parseMarkdown, type Markdown } from "./markdown";
import {
configMarkdownSymbol,
configMessageTypeSymbol,
configStringSymbol,
type ConfigMarkdown,
type ConfigString,
type InferLocaleFromConfig,
type LocaleConfig,
} from "./config";
import type { Pattern } from "@fluent/bundle/esm/ast";
type GenericLocale = { [id: string]: GenericLocale | GenericMessageFn };
type GenericMessageFn = GenericStringMessageFn | GenericMarkdownMessageFn;
type GenericStringMessageFn = (
placeableArgs?: GenericMessageFnPlaceableArgs,
) => string;
type GenericMarkdownMessageFn = (
placeableArgs?: GenericMessageFnPlaceableArgs,
) => Markdown;
type GenericMessageFnPlaceableArgs = Record<string, PlaceableValue>;
type PlaceableValue = string | number;
export interface CompileLocaleCtx<Config extends LocaleConfig> {
bundle: FluentBundle;
uncompiled: UncompiledLocale;
config: Config;
fallback: InferLocaleFromConfig<Config> | undefined;
messageIdChain: readonly string[];
}
export interface CompileLocaleRes<Locale> {
locale: Locale;
errors: readonly string[];
}
export function compileLocale<Config extends LocaleConfig>(
ctx: CompileLocaleCtx<Config>,
): CompileLocaleRes<InferLocaleFromConfig<Config>> {
const {
bundle,
uncompiled,
config,
fallback,
messageIdChain: localeMessageIdChain = [],
} = ctx;
const errors: string[] = [];
const uncompiledKeys = new Set(Object.keys(uncompiled ?? {}));
const configKeys = new Set(Object.keys(config));
const excessKeys = uncompiledKeys.difference(configKeys);
if (excessKeys.size > 0) {
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
}
const locale: GenericLocale = {};
for (const [messageId, configValue] of Object.entries(config)) {
const uncompiledValue = uncompiled?.[messageId];
const fallbackValue = fallback?.[messageId];
const valueMessageIdChain = [
...localeMessageIdChain,
messageId,
] as const;
const compiledValue = ((): Value<GenericLocale> => {
if (configMessageTypeSymbol in configValue) {
const compiledMessage = (() => {
if (uncompiledValue?.type !== "message") {
errors.push(
`Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
);
return undefined;
}
const uncompiledMessage = uncompiledValue.message;
const compiledMessageRes = compileMessage({
bundle,
messageIdChain: valueMessageIdChain,
configValue,
uncompiledMessage,
});
if (compiledMessageRes.type === "err") {
errors.push(compiledMessageRes.err);
return undefined;
}
const compiledMessage = compiledMessageRes.ok;
return compiledMessage;
})();
if (compiledMessage !== undefined) {
return compiledMessage;
}
if (
fallbackValue !== undefined
&& typeof fallbackValue === "function"
) {
return fallbackValue as GenericMessageFn;
}
switch (configValue[configMessageTypeSymbol]) {
case configStringSymbol: {
return () =>
createMissingStringFallback(valueMessageIdChain);
}
case configMarkdownSymbol: {
return () =>
createMissingMarkdownFallback(
valueMessageIdChain,
new Set(Object.keys(configValue.slots)),
);
}
}
} else {
const uncompiledSubrecord = (() => {
if (uncompiledValue?.type !== "subrecord") {
errors.push(
`Expected subrecord for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
);
return undefined;
}
return uncompiledValue.subrecord;
})();
return compileSublocale({
subconfig: configValue,
uncompiledSublocale: uncompiledSubrecord,
fallbackSublocale:
typeof fallbackValue === "function" ? undefined : (
fallbackValue
),
errors,
bundle,
messageIdChain: valueMessageIdChain,
});
}
})();
locale[messageId] = compiledValue;
}
// SAFETY: validated above that all keys exist and are the correct type
return { locale: locale as InferLocaleFromConfig<Config>, errors };
}
function fmtMessageIdChain(
messageIdChain: readonly [...string[], string],
): string {
return messageIdChain.join("-");
}
interface CompileMessageCtx {
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
configValue: ConfigString<object> | ConfigMarkdown<object, object>;
uncompiledMessage: Message;
}
function compileMessage(
ctx: CompileMessageCtx,
): Result<GenericMessageFn, string> {
const { bundle, messageIdChain, configValue, uncompiledMessage } = ctx;
const pattern = uncompiledMessage.value;
if (pattern === null) {
return {
type: "err",
err: `Pattern is null for message with ID: ${fmtMessageIdChain(messageIdChain)}`,
};
}
// validate placeables
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 ID: ${fmtMessageIdChain(messageIdChain)}; Found: ${selector.type}`,
};
}
if (
!Object.keys(configValue.placeables).includes(
selector.name,
)
) {
return {
type: "err",
err: `Found unexpected placeable name \`${selector.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
};
}
break;
}
case "var": {
if (
!Object.keys(configValue.placeables).includes(
element.name,
)
) {
return {
type: "err",
err: `Found unexpected placeable name \`${element.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
};
}
break;
}
case "term":
case "mesg":
case "func":
case "str":
case "num": {
break; // ignore
}
}
}
}
const allVariantsRes = computeAllVariants(pattern);
if (allVariantsRes.type === "err") {
return {
type: "err",
err: `Failed to compute variants for ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${allVariantsRes.err}`,
};
}
const allVariants = allVariantsRes.ok;
switch (configValue[configMessageTypeSymbol]) {
case configStringSymbol: {
return compileStringMessage({
bundle,
messageIdChain,
allVariants,
pattern,
});
}
case configMarkdownSymbol: {
return compileMarkdownMessage({
bundle,
slots: new Set(Object.keys(configValue.slots)),
messageIdChain,
allVariants,
pattern,
});
}
}
}
interface CompileStringMessageCtx {
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
allVariants: readonly PatternVariant[];
pattern: Pattern;
}
function compileStringMessage(
ctx: CompileStringMessageCtx,
): Result<GenericStringMessageFn, string> {
const { bundle, messageIdChain, allVariants, pattern } = ctx;
// typecheck string
// check if all variants are valid markdown
for (const variant of allVariants) {
const stringLiteralRes = parseMessageLiteral("string", variant.string);
if (stringLiteralRes.type === "err") {
return {
type: "err",
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.err}`,
};
}
const stringLiteral = stringLiteralRes.ok;
const stringRes = parseString(stringLiteral);
if (stringRes.type === "err") {
return {
type: "err",
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
};
}
}
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
return {
type: "ok",
ok: (args: Record<string, PlaceableValue> = {}) => {
const stringRes = ((): Result<string, string> => {
const stringLiteralRes = parseMessageLiteral(
"string",
bundle.formatPattern(pattern, args),
);
if (stringLiteralRes.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid strings above
return {
type: "err",
err: `Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
};
}
const stringLiteral = stringLiteralRes.ok;
const res = parseString(stringLiteral);
if (res.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid strings above
// TODO: no we dont, do that
return {
type: "err",
err: `Failed to parse string after compilation!\n${res.err}`,
};
}
const string = res.ok;
return { type: "ok", ok: string };
})();
switch (stringRes.type) {
case "ok": {
const string = stringRes.ok;
return string;
}
case "err": {
const error = stringRes.err;
console.error(error);
return createMissingStringFallback(messageIdChain);
}
}
},
};
}
interface CompileMarkdownMessageCtx {
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
slots: ReadonlySet<string>;
allVariants: readonly PatternVariant[];
pattern: Pattern;
}
function compileMarkdownMessage(
ctx: CompileMarkdownMessageCtx,
): Result<GenericMarkdownMessageFn, string> {
const { bundle, messageIdChain, slots, allVariants, pattern } = ctx;
// typecheck markdown
// check if all variants are valid markdown
for (const variant of allVariants) {
const markdownLiteralRes = parseMessageLiteral("md", variant.string);
if (markdownLiteralRes.type === "err") {
return {
type: "err",
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownLiteralRes.err}`,
};
}
const markdownLiteral = markdownLiteralRes.ok;
const markdownRes = parseMarkdown(markdownLiteral, slots);
if (markdownRes.type === "err") {
return {
type: "err",
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownRes.err}`,
};
}
}
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
return {
type: "ok",
ok: (args: Record<string, PlaceableValue> = {}): Markdown => {
const escapedArgs = Object.fromEntries(
Object.entries(args).map(([id, value]) => {
const escapedValue = (() => {
switch (typeof value) {
case "number": {
return value;
}
case "string": {
return value
.split("")
.map((c) => {
switch (c) {
case "\\": {
return "\\\\";
}
case "*": {
return "\\*";
}
case "#": {
return "\\#";
}
case "[": {
return "\\[";
}
case "]": {
return "\\]";
}
case "(": {
return "\\(";
}
case ")": {
return "\\)";
}
case "-": {
return "\\-";
}
case "<": {
return "\\<";
}
case ">": {
return "\\>";
}
default: {
return c;
}
}
})
.join("");
}
}
})();
return [id, escapedValue] as const;
}),
);
const markdownRes = ((): Result<Markdown, string> => {
const markdownLiteralRes = parseMessageLiteral(
"md",
bundle.formatPattern(pattern, escapedArgs),
);
if (markdownLiteralRes.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above
return {
type: "err",
err: `Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
};
}
const markdownLiteral = markdownLiteralRes.ok;
const res = parseMarkdown(markdownLiteral, slots);
if (res.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above
return {
type: "err",
err: `Failed to parse markdown after compilation!\n${res.err}`,
};
}
return { type: "ok", ok: res.ok };
})();
switch (markdownRes.type) {
case "ok": {
const markdown = markdownRes.ok;
return markdown;
}
case "err": {
const error = markdownRes.err;
console.error(error);
return createMissingMarkdownFallback(messageIdChain, slots);
}
}
},
};
}
function createMissingStringFallback(
messageIdChain: readonly [...string[], string],
): string {
return `[#${fmtMessageIdChain(messageIdChain)}#]`;
}
function createMissingMarkdownFallback<Slot extends string>(
messageIdChain: readonly [...string[], string],
slots: ReadonlySet<Slot>,
): Markdown<Slot> {
return {
elements: [
{
type: "paragraph",
paragraph: {
spans: [
{
type: "plain",
plain: createMissingStringFallback(messageIdChain),
},
],
},
},
],
slots,
};
}
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
subconfig: Subconfig;
uncompiledSublocale: UncompiledLocale | undefined;
fallbackSublocale: InferLocaleFromConfig<Subconfig> | undefined;
errors: string[];
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
}
function compileSublocale<Subconfig extends LocaleConfig>(
ctx: CompileSublocaleCtx<Subconfig>,
): InferLocaleFromConfig<Subconfig> {
const {
subconfig: configValue,
uncompiledSublocale: recordValue,
fallbackSublocale: fallbackValue,
errors,
bundle,
messageIdChain,
} = ctx;
const subrecord = recordValue;
const compiledSubrecordRes = compileLocale({
bundle,
uncompiled: subrecord ?? {},
config: configValue,
fallback: fallbackValue,
messageIdChain,
});
errors.push(
...compiledSubrecordRes.errors.map(
(err) =>
`Error when compiling subrecord with ID: \`${fmtMessageIdChain(messageIdChain)}\`:\n${err}`,
),
);
return compiledSubrecordRes.locale;
}
function parseMessageLiteral(
type: "string" | "md",
message: string,
): Result<string, string> {
const trimmedMessage = message.trim();
const maybeStartIndexes: number[] = [];
const firstQuoteIndex = trimmedMessage.indexOf('"');
if (firstQuoteIndex !== -1) {
maybeStartIndexes.push(firstQuoteIndex);
}
const firstDashIndex = trimmedMessage.indexOf("-");
if (firstDashIndex !== -1) {
maybeStartIndexes.push(firstDashIndex);
}
const stringStartIndex = Math.min(...maybeStartIndexes);
const actualPrefix = trimmedMessage.substring(0, stringStartIndex).trim();
const expectedPrefix = (() => {
switch (type) {
case "string": {
return "";
}
case "md": {
return "md";
}
}
})();
if (actualPrefix !== expectedPrefix) {
return {
type: "err",
err: `Expected prefix "${expectedPrefix}" for message with type \`${type}\`; Found: "${actualPrefix}"`,
};
}
return {
type: "ok",
ok: trimmedMessage.substring(actualPrefix.length).trim(),
};
}
function parseString(message: string): Result<string, string> {
const AFFIX = '"';
if (!message.startsWith(AFFIX)) {
return {
type: "err",
err: `String message expected to start with \`${AFFIX}\``,
};
}
if (!message.endsWith(AFFIX)) {
return {
type: "err",
err: `String message expected to end with \`${AFFIX}\``,
};
}
const deprefixed = message.substring(AFFIX.length);
const dequoted = deprefixed.substring(0, deprefixed.length - AFFIX.length);
return { type: "ok", ok: dequoted };
}

View file

@ -0,0 +1,106 @@
import { type Markdown } from "./markdown";
export const configMessageTypeSymbol: unique symbol =
Symbol("configMessageType");
export const configStringSymbol: unique symbol = Symbol("configString");
export interface ConfigString<
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
> {
[configMessageTypeSymbol]: typeof configStringSymbol;
placeables: Placeables;
}
export const configMarkdownSymbol: unique symbol = Symbol("configMarkdown");
export interface ConfigMarkdown<
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
Slots extends Partial<Record<string, ConfigSlotInfo>>,
> {
[configMessageTypeSymbol]: typeof configMarkdownSymbol;
placeables: Placeables;
slots: Slots;
bold?: boolean;
italic?: boolean;
header?: boolean;
link?: boolean;
ulist?: boolean;
}
export interface ConfigPlaceableInfo<
Type extends "string" | "number" = "string" | "number",
> {
type: Type;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ConfigSlotInfo {}
export function string<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
>(
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
): ConfigString<Placeables> {
return { ...opt, [configMessageTypeSymbol]: configStringSymbol };
}
export function markdown<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
>(
opt: Omit<
ConfigMarkdown<Placeables, Slots>,
typeof configMessageTypeSymbol
>,
): ConfigMarkdown<Placeables, Slots> {
return { ...opt, [configMessageTypeSymbol]: configMarkdownSymbol };
}
export type LocaleConfig = {
[id: string]:
| ConfigString<object>
| ConfigMarkdown<object, object>
| LocaleConfig;
};
type MessageCtx<
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
> = {
[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 InferLocaleFromConfig<Config extends LocaleConfig> = {
[K in keyof Config]: Config[K] extends LocaleConfig ?
InferLocaleFromConfig<Config[K]>
: Config[K] extends ConfigString<infer Placeables> ?
object extends MessageCtx<Placeables> ?
() => string
: (ctx: MessageCtx<Placeables>) => string
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
object extends MessageCtx<Placeables> ?
() => Markdown
: (ctx: MessageCtx<Placeables>) => Markdown
: 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>;
}

View file

@ -0,0 +1,944 @@
import type {
SmartDest,
SmartExternalDest,
SmartInternalDest,
} from "@/utils/smart-dest";
import type { Result } from "@/utils/types";
import type { RouteNamedMap } from "vue-router/auto-routes";
import { routes } from "vue-router/auto-routes";
export interface Markdown<Slot extends string = string> {
elements: readonly MarkdownElement<Slot>[];
slots: ReadonlySet<Slot>;
}
export type MarkdownElement<Slot extends string = string> =
| { type: "paragraph"; paragraph: { spans: readonly MarkdownSpan<Slot>[] } }
| { type: "header"; header: { spans: readonly MarkdownSpan<Slot>[] } }
| {
type: "ulist";
ulist: { items: readonly (readonly MarkdownSpan<Slot>[])[] };
};
type MarkdownLine<Slot extends string = string> = {
type: "paragraph" | "header" | "ulistItem";
spans: readonly MarkdownSpan<Slot>[];
};
export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link";
export type MarkdownSpan<Slot extends string = string> =
| { type: "plain"; plain: string }
| { type: "italic"; italic: readonly MarkdownSpan[] }
| { type: "bold"; bold: readonly MarkdownSpan[] }
| {
type: "link";
link: {
label: readonly MarkdownSpan[];
to: SmartDest;
newTab: boolean;
};
}
| { type: "slot"; slot: Slot };
export function parseMarkdown<Slot extends string>(
markdownString: string,
slots: ReadonlySet<Slot>,
): Result<Markdown<Slot>, string> {
const linesRes = parseMarkdownLines(markdownString, slots);
if (linesRes.type === "err") {
return linesRes;
}
const lines = linesRes.ok;
const elements: MarkdownElement<Slot>[] = [];
while (true) {
const line = lines.shift();
if (line === undefined) {
break;
}
const element = ((): MarkdownElement<Slot> => {
switch (line.type) {
case "paragraph": {
return {
type: "paragraph",
paragraph: { spans: line.spans },
};
}
case "header": {
return { type: "header", header: { spans: line.spans } };
}
case "ulistItem": {
const items: (readonly MarkdownSpan<Slot>[])[] = [
line.spans,
];
while (true) {
const peekLine = lines[0];
if (
peekLine === undefined
|| peekLine.type !== "ulistItem"
) {
break;
}
items.push(peekLine.spans);
lines.shift();
}
return { type: "ulist", ulist: { items } };
}
}
})();
elements.push(element);
}
return { type: "ok", ok: { elements, slots: new Set(slots) } };
}
function parseMarkdownLines<Slot extends string>(
markdownString: string,
slots: ReadonlySet<Slot>,
): Result<MarkdownLine<Slot>[], string> {
if (markdownString.trim() === "--") {
return { type: "ok", ok: [] };
}
const lines = markdownString.split("\n");
const dequotedLines: string[] = [];
for (const line of lines) {
const MARKDOWN_LINE_AFFIX = '"';
if (!line.startsWith(MARKDOWN_LINE_AFFIX)) {
return {
type: "err",
err: `Line ${String(dequotedLines.length + 1)} of markdown must start with ${MARKDOWN_LINE_AFFIX}`,
};
}
if (!line.endsWith(MARKDOWN_LINE_AFFIX)) {
return {
type: "err",
err: `Line ${String(dequotedLines.length + 1)} of markdown must end with ${MARKDOWN_LINE_AFFIX}`,
};
}
const deprefixed = line.substring(MARKDOWN_LINE_AFFIX.length);
const dequoted = deprefixed.substring(
0,
deprefixed.length - MARKDOWN_LINE_AFFIX.length,
);
dequotedLines.push(dequoted);
}
const markdownLines: MarkdownLine<Slot>[] = [];
for (const line of dequotedLines) {
const markdownLineRes = parseMarkdownLine(line, slots);
if (markdownLineRes.type === "err") {
return {
type: "err",
err: `On line ${String(markdownLines.length + 1)}:\n${markdownLineRes.err}`,
};
}
const markdownLine = markdownLineRes.ok;
markdownLines.push(markdownLine);
}
return { type: "ok", ok: markdownLines };
}
function parseMarkdownLine<Slot extends string>(
line: string,
slots: ReadonlySet<Slot>,
): Result<MarkdownLine<Slot>, string> {
interface ResolvedLine {
deprefixedLine: string;
type: MarkdownLine["type"];
}
const { deprefixedLine, type } = ((): ResolvedLine => {
if (line.startsWith("#")) {
return { deprefixedLine: line.substring(1), type: "header" };
} else if (line.startsWith("-")) {
return { deprefixedLine: line.substring(1), type: "ulistItem" };
} else {
return { deprefixedLine: line, type: "paragraph" };
}
})();
const spansRes = parseMarkdownSpans(deprefixedLine, slots);
if (spansRes.type === "err") {
return {
type: "err",
err: `While parsing ${type} spans:\n${spansRes.err}`,
};
}
const spans = spansRes.ok;
return { type: "ok", ok: { type, spans } };
}
function parseMarkdownSpans<Slot extends string>(
line: string,
slots: ReadonlySet<Slot>,
): Result<MarkdownSpan<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
// subheader support is implemented.
// making subheaders a compile error for now ensures
// all current i18n is backwards-compatible when/if they are implemented
return { type: "err", err: "Subheaders are not supported." };
}
const chars = line.split("");
const spansRes = readMarkdownSpans(
chars,
slots,
ParseMarkdownSpansManager.new(),
);
if (spansRes.type === "err") {
return spansRes;
}
const spans = spansRes.ok;
return { type: "ok", ok: spans };
}
class ParseMarkdownSpansManager {
private inItalic: boolean;
private inBold: boolean;
private constructor() {
this.inItalic = false;
this.inBold = false;
}
public static new(): ParseMarkdownSpansManager {
return new this();
}
public tryUseItalic<R>(f: () => Result<R, string>): Result<R, string> {
if (this.inItalic) {
return {
type: "err",
err: "Cannot nest italic span (*) inside of another italic span",
};
}
this.inItalic = true;
const fRes = f();
if (fRes.type === "err") {
return fRes;
}
this.inItalic = false;
const fOk = fRes.ok;
return { type: "ok", ok: fOk };
}
public tryUseBold<R>(f: () => Result<R, string>): Result<R, string> {
if (this.inBold) {
return {
type: "err",
err: "Cannot nest bold span (**) inside of another bold span",
};
}
this.inBold = true;
const fRes = f();
if (fRes.type === "err") {
return fRes;
}
this.inBold = false;
const fOk = fRes.ok;
return { type: "ok", ok: fOk };
}
}
function readMarkdownSpans<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>[], string> {
const spans: MarkdownSpan<Slot>[] = [];
while (true) {
const spanRes = readMarkdownSpan(chars, slots, manager);
if (spanRes.type === "err") {
return spanRes;
}
const span = spanRes.ok;
if (span === undefined) {
break;
}
spans.push(span);
}
return { type: "ok", ok: spans };
}
function readMarkdownSpan<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot> | undefined, string> {
const [firstChar, secondChar, thirdChar] = chars;
if (firstChar === undefined) {
return { type: "ok", ok: undefined };
} else if (firstChar === "<") {
return readMarkdownSpanSlot(chars, slots);
} else if (firstChar === "[") {
return readMarkdownSpanLink(chars, slots, manager);
} else if (firstChar === "*") {
if (secondChar !== "*") {
return readMarkdownSpanItalic(chars, slots, manager);
}
if (thirdChar !== "*") {
return readMarkdownSpanBold(chars, slots, manager);
}
return readMarkdownSpanBoldItalic(chars, slots, manager);
} else {
return readMarkdownSpanPlain(chars);
}
}
function iterableIsArray<T>(iterable: Iterable<T>): iterable is readonly T[] {
return Array.isArray(iterable);
}
function iterableIsSet<T>(iterable: Iterable<T>): iterable is ReadonlySet<T> {
return iterable instanceof Set;
}
function iterableContains<T, U extends T>(
iterable: Iterable<U>,
value: T,
): value is U {
// SAFETY: this is just an equality check, it is safe to pass in any value
const target = value as U;
// specializations
if (iterableIsArray(iterable)) {
return iterable.includes(target);
}
if (iterableIsSet(iterable)) {
return iterable.has(target);
}
// generic case
for (const x of iterable) {
if (x === target) {
return true;
}
}
return false;
}
function readMarkdownSpanSlot<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
): Result<MarkdownSpan<Slot>, string> {
const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") {
return openAngleRes;
}
const slotNameRes = readUntilClosing({
chars,
elementName: "slot",
closingChar: ">",
});
if (slotNameRes.type === "err") {
return slotNameRes;
}
const slotName = slotNameRes.ok;
if (!iterableContains(slots, slotName)) {
return { type: "err", err: `Unexpected slot name: ${slotName}` };
}
return { type: "ok", ok: { type: "slot", slot: slotName } };
}
function readMarkdownSpanLink<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") {
return openSquareRes;
}
const labelElementsRes = readMarkdownSpans(chars, slots, manager);
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: ")",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const externalDestRes = validateExternalDest(dest);
if (externalDestRes.type === "err") {
return externalDestRes;
}
const externalDest = externalDestRes.ok;
return {
type: "ok",
ok: {
dest: { type: "external", external: externalDest },
newTab,
},
};
} 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: ")",
});
if (destRes.type === "err") {
return destRes;
}
const dest = destRes.ok;
const internalDestRes = validateInternalDest(dest);
if (internalDestRes.type === "err") {
return internalDestRes;
}
const internalDest = internalDestRes.ok;
return {
type: "ok",
ok: {
dest: { type: "internal", internal: internalDest },
newTab,
},
};
} else {
return {
type: "err",
err: `Expected external or internal link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
};
}
})();
if (linkPropsRes.type === "err") {
return linkPropsRes;
}
const { dest, newTab } = linkPropsRes.ok;
const resolvedLabel: MarkdownSpan[] =
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;
return {
type: "ok",
ok: { type: "link", link: { label: resolvedLabel, to: dest, newTab } },
};
}
function validateExternalDest(dest: string): Result<SmartExternalDest, string> {
const HTTPS_PREFIX = "https://";
const HTTP_PREFIX = "http://";
if (dest.startsWith(HTTPS_PREFIX)) {
return {
type: "ok",
ok: `${HTTPS_PREFIX}${dest.substring(HTTPS_PREFIX.length)}`,
};
}
if (dest.startsWith(HTTP_PREFIX)) {
return {
type: "ok",
ok: `${HTTP_PREFIX}${dest.substring(HTTP_PREFIX.length)}`,
};
}
return {
type: "err",
err: `External dest must start with https:// or http://`,
};
}
function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
const [routeString, id] = dest.split("#");
const validatedRouteRes = ((): Result<
keyof RouteNamedMap | undefined,
string
> => {
if (routeString === undefined || routeString.length === 0) {
return { type: "ok", ok: undefined };
}
const route = routes.find((route) => route.path === routeString);
if (route === undefined) {
return {
type: "err",
err: `Route with ID \`${routeString}\` does not exist`,
};
}
return {
type: "ok",
// SAFETY: we validated the route exists in the router about
ok: route.path as keyof RouteNamedMap,
};
})();
if (validatedRouteRes.type === "err") {
return validatedRouteRes;
}
const validatedRoute = validatedRouteRes.ok;
if (validatedRoute !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else if (id !== undefined) {
return { type: "ok", ok: { route: validatedRoute, id } };
} else {
return {
type: "err",
err: `Either route or ID must be defined for internal dest`,
};
}
}
function readMarkdownSpanItalic<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseItalic(() => {
const singleStarRes = expectReadString(chars, "*");
if (singleStarRes.type === "err") {
return singleStarRes;
}
const spans: MarkdownSpan<Slot>[] = [];
let closed = false;
while (true) {
const spanRes = readMarkdownSpan(chars, slots, manager);
if (spanRes.type === "err") {
return spanRes;
}
const span = spanRes.ok;
if (span === undefined) {
break;
}
spans.push(span);
const [firstChar] = chars;
if (firstChar === "*") {
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
if (!closed) {
return { type: "err", err: "Italic span (*) is never closed" };
}
return { type: "ok", ok: { type: "italic", italic: spans } };
});
}
function readMarkdownSpanBold<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseBold(() => {
const doubleStarRes = expectReadString(chars, "**");
if (doubleStarRes.type === "err") {
return doubleStarRes;
}
const spans: MarkdownSpan<Slot>[] = [];
let closed = false;
while (true) {
const spanRes = readMarkdownSpan(chars, slots, manager);
if (spanRes.type === "err") {
return spanRes;
}
const span = spanRes.ok;
if (span === undefined) {
break;
}
spans.push(span);
const [firstChar, secondChar] = chars;
if (firstChar === "*" && secondChar === "*") {
chars.shift();
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
if (!closed) {
return { type: "err", err: "Bold span (**) is never closed" };
}
return { type: "ok", ok: { type: "bold", bold: spans } };
});
}
function readMarkdownSpanBoldItalic<Slot extends string>(
chars: string[],
slots: ReadonlySet<Slot>,
manager: ParseMarkdownSpansManager,
): Result<MarkdownSpan<Slot>, string> {
return manager.tryUseBold(() =>
manager.tryUseItalic(() => {
const tripleStarRes = expectReadString(chars, "***");
if (tripleStarRes.type === "err") {
return tripleStarRes;
}
const spans: MarkdownSpan<Slot>[] = [];
let closed = false;
while (true) {
const spanRes = readMarkdownSpan(chars, slots, manager);
if (spanRes.type === "err") {
return spanRes;
}
const span = spanRes.ok;
if (span === undefined) {
break;
}
spans.push(span);
const [firstChar, secondChar, thirdChar] = chars;
if (
firstChar === "*"
&& secondChar === "*"
&& thirdChar === "*"
) {
chars.shift();
chars.shift();
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
if (!closed) {
return {
type: "err",
err: "Bold italic span (***) is never closed",
};
}
return {
type: "ok",
ok: { type: "bold", bold: [{ type: "italic", italic: spans }] },
};
}),
);
}
function readMarkdownSpanPlain<Slot extends string>(
chars: string[],
): Result<MarkdownSpan<Slot> | undefined, string> {
let plain = "";
let escaped = false;
while (true) {
const peek = chars[0];
if (peek === undefined) {
break;
}
if (escaped) {
escaped = false;
} else {
if (peek === "\\") {
escaped = true;
chars.shift();
continue;
}
if (
peek === "*"
|| peek === "<"
|| peek === ">"
|| peek === "["
|| peek === "]"
) {
break;
}
}
plain += peek;
chars.shift();
}
return {
type: "ok",
ok: plain.length === 0 ? undefined : { type: "plain", plain },
};
}
function expectReadChar(
chars: string[],
expectedChar: string,
): Result<void, string> {
const nextChar = chars.shift();
if (nextChar !== expectedChar) {
return {
type: "err",
err: `Expected: "${expectedChar}"; Found: ${nextChar === undefined ? "undefined" : `"${nextChar}"`}`,
};
}
return { type: "ok", ok: undefined };
}
function peekStringEq(chars: string[], expectedString: string): boolean {
return chars.slice(0, expectedString.length).join("") === expectedString;
}
function expectReadString(
chars: string[],
expectedString: string,
): Result<void, string> {
let foundString: string | undefined = undefined;
for (const expectedChar of expectedString) {
const nextChar = chars.shift();
if (nextChar !== undefined) {
foundString = (foundString ?? "") + nextChar;
}
if (expectedChar !== nextChar) {
return {
type: "err",
err: `Expected: "${expectedString}"; Found: ${foundString === undefined ? "undefined" : `"${foundString}"`}`,
};
}
}
return { type: "ok", ok: undefined };
}
interface ReadUntilClosingCtx {
chars: string[];
elementName: string;
closingChar: string;
}
function readUntilClosing(ctx: ReadUntilClosingCtx): Result<string, string> {
const { chars, elementName, closingChar } = ctx;
let value = "";
let closed = false;
let escaped = false;
while (true) {
const char = chars.shift();
if (char === undefined) {
closed = false;
break;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === closingChar && !escaped) {
closed = true;
break;
}
value += char;
}
if (!closed) {
return {
type: "err",
err: `Unclosed ${elementName}: <${value.replaceAll(closingChar, `\\${closingChar}`)}`,
};
}
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.elements.length === 0;
}

View file

@ -0,0 +1,201 @@
import type { Result } from "@/utils/types";
import { FluentBundle, FluentResource } from "@fluent/bundle";
import { unsafeAsync } from "@/utils/unsafe";
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
export async function loadFluentBundle(
localeId: string,
src: string,
): Promise<Result<FluentBundle, string>> {
const ftlFileResponseRes = await unsafeAsync(() => fetch(src));
if (ftlFileResponseRes.type === "err") {
return { type: "err", err: `Failed to fetch locale from src: ${src}` };
}
const ftlFileResponse = ftlFileResponseRes.ok;
const ftlFileTextRes = await unsafeAsync(() => ftlFileResponse.text());
if (ftlFileTextRes.type === "err") {
return {
type: "err",
err: `Failed to fetch text content of FTL file from src: ${src}`,
};
}
const ftlFileText = ftlFileTextRes.ok;
const resource = new FluentResource(ftlFileText);
const bundle = new FluentBundle(localeId);
const errors = bundle.addResource(resource);
if (errors.length > 0) {
return {
type: "err",
err: `Failed to add Fluent resource to bundle:\n${errors.join("\n")}`,
};
}
return { type: "ok", ok: bundle };
}
export type UncompiledLocale = {
[id: string]:
| { type: "message"; message: Message }
| { type: "subrecord"; subrecord: UncompiledLocale };
};
export function bundleToUncompiledLocaleRecord(
bundle: FluentBundle,
): Result<UncompiledLocale, string> {
const record: UncompiledLocale = {};
for (const [id, message] of bundle._messages) {
const idChain = id.split("-");
let subrecord = record;
while (true) {
const subId = idChain.shift();
if (subId === undefined) {
return {
type: "err",
err: `Reached end of message ID chain before terminating for message ID: ${id}`,
};
}
if (idChain.length === 0) {
subrecord[subId] = { type: "message", message };
break;
} else {
const maybeSubrecord = (subrecord[subId] ??= {
type: "subrecord",
subrecord: {},
});
if (maybeSubrecord.type === "subrecord") {
subrecord = maybeSubrecord.subrecord;
} else {
return {
type: "err",
err: `Found message when expected subrecord for message ID: ${id} @ subId: ${subId}`,
};
}
}
}
}
return { type: "ok", ok: record };
}
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("+");
}
export interface PatternVariant {
selectionChain: SelectionChain;
string: string;
}
export function computeAllVariants(
pattern: Pattern,
): Result<PatternVariant[], string> {
if (typeof pattern === "string") {
return { type: "ok", ok: [{ selectionChain: [], string: pattern }] };
}
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
for (const element of pattern) {
if (typeof element === "string") {
variants = variants.map((variant) => ({
selectionChain: variant.selectionChain,
string: variant.string + element,
}));
continue;
}
switch (element.type) {
case "select": {
const selectVariants: PatternVariant[] = [];
for (const selectVariant of element.variants) {
const variantComputedRes = computeAllVariants(
selectVariant.value,
);
if (variantComputedRes.type === "err") {
return {
type: "err",
err: `Failed to compute select variants:\n${variantComputedRes.err}`,
};
}
const variantComputed = variantComputedRes.ok;
selectVariants.push(
...variantComputed.map(
(v): PatternVariant => ({
selectionChain: [
selectVariant.key,
...v.selectionChain,
],
string: v.string,
}),
),
);
}
variants = variants.flatMap((variant) =>
selectVariants.map(
(selectVariant): PatternVariant => ({
selectionChain: [
...variant.selectionChain,
selectVariant.selectionChain,
],
string: variant.string + selectVariant.string,
}),
),
);
break;
}
case "var": {
// used as a stand-in for runtime-provided values
const DUMMY_STRING = "$$$";
variants = variants.map((variant) => ({
selectionChain: variant.selectionChain,
string: variant.string + DUMMY_STRING,
}));
break;
}
case "str": {
variants = variants.map((variant) => ({
selectionChain: variant.selectionChain,
string: variant.string + element.value,
}));
break;
}
default: {
return {
type: "err",
err: `Unhandled PatternElement type: ${element.type}`,
};
}
}
}
return { type: "ok", ok: variants };
}

View file

@ -0,0 +1,79 @@
# Viossa I18n Message Spec
## Types & Literals
There are two types of messages, `string` & `markdown`. Each type is specified by a prefix:
- `string` literal (no prefix): `"Hello world!"`
- `markdown` literal (`md` prefix): `md "Hello world!"`
Message literals are made up of lines. Each line is surrounded by quotes.
## String Literals
String literals take exactly one line. They have no special formatting or behavior, exactly what is in the string will be what is displayed:
- `"Hello world!"` => Hello world!
- `"123 *456* **789**"` => 123 \*456\* \*\*789\*\*
## Markdown Literals
Markdown literals can take any number of lines. Lines are separated by newline characters.
```
example-markdownMessage = md
"Line 1"
"Line 2"
"Line 3"
```
They can also consist of a single line:
```
example-markdownMessage = md "Line 1"
```
A special sigil exists for denoting that no lines exist:
```
example-markdownMessage = md --
```
### Line Types
- Paragraph: `Example`
- Header: `# Example` (subheaders are not supported)
- Unordered List Item: `- Example`
### Line Features
- Italic: `*Example*` => *Example*
- Bold: `**Example**` => **Example**
- Bold + Italic: `***Example***` => ***Example***
- Links: `[Example](external.new:https://example.com/)` => [Example](https://example.com/)
- Slots: `<example>`
Characters used for line feature syntax can be escaped to remove their effect and place the raw character in the string: `\*Example\*` => \*Example\*
### Links
Links are made up of 4 components:
```
[Example](external.new:https://example.com/)
^^^^^^^ ^^^^^^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^
name type tab destination
```
`name` is the text displayed to the user on the webpage. It is optional; If blank, it will display the destination directly to the user.
`type` can be either `internal` or `external`, and changes what is deemed a valid `destination`.
If `tab` is `new`, the link will open the `destination` in a new tab. If `tab` is `replace`, it will open in the current tab.
`destination` is where the link will take the user when clicked. Its value depends on the value of `type` as follows:
- If `type` is `external`: `destination` is any link starting with `http://` or `https://`
- `http://example.com/`
- `https://google.com/`
- `https://viossa.net/`
- If `type` is `internal`: `destination` consists of a `route` and `id`, in any of the following patterns:
- `route`-only: brings the user to another route on the website
- `/`
- `/resources`
- `/kotoba`
- `/discord/rules`
- `id`-only: jumps the user to a specific element ID on the current route
- `#top`
- `#header`
- `#rule-1`
- `route` with `id`: brings the user to another route and jumps to an element ID on that page
- `/discord/rules#rule-1`
- `/#top`

View file

@ -4,22 +4,20 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
"target": "esnext",
"lib": ["ESNext", "DOM"],
"rootDir": "src",
"paths": { "@/*": ["./src/*"] },
},
},
"include": [
"src"
]
"vueCompilerOptions": { "strictTemplates": true },
"include": ["src"],
"exclude": ["./eslint.config.js"],
}

View file

@ -1,7 +1,11 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

View file

@ -1,9 +1,11 @@
import path from "path";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueRouter from "unplugin-vue-router/vite";
export default defineConfig({
plugins: [vue({})],
plugins: [vueRouter({ root: "src", routesFolder: "pages" }), vue({})],
resolve: { alias: { "@": path.resolve(import.meta.dirname, "src") } },
server: { port: 1224 },
assetsInclude: ["**/*.ftl"],
});

View file

@ -1,6 +1,7 @@
{
"devDependencies": {
"turbo": "^2.5.4"
"turbo": "^2.5.4",
"typescript": "~5.8.3"
},
"packageManager": "pnpm@10.11.0",
"scripts": {

1214
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,6 @@ SOURCE_DIR=$1
cd "$SOURCE_DIR"
git fetch --all && git branch "backup-$(date +'%s')"
npm i -g pnpm && pnpm setup && pnpm i -g turbo typescript \
npm i -g pnpm && pnpm setup && pnpm i -g turbo \
&& git checkout -f origin/main \
&& pnpm i && turbo build